001/* 002 * Licensed under the Apache License, Version 2.0 (the "License"); 003 * you may not use this file except in compliance with the License. 004 * You may obtain a copy of the License at 005 * 006 * http://www.apache.org/licenses/LICENSE-2.0 007 * 008 * Unless required by applicable law or agreed to in writing, software 009 * distributed under the License is distributed on an "AS IS" BASIS, 010 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 011 * See the License for the specific language governing permissions and 012 * limitations under the License. 013 */ 014package org.atteo.xmlcombiner; 015 016import java.io.FileNotFoundException; 017import java.io.FileOutputStream; 018import java.io.IOException; 019import java.io.InputStream; 020import java.io.OutputStream; 021import java.nio.file.Path; 022import java.nio.file.Paths; 023import java.util.ArrayList; 024import java.util.Arrays; 025import java.util.HashSet; 026import java.util.List; 027import java.util.Map; 028import java.util.Set; 029 030import javax.xml.parsers.DocumentBuilder; 031import javax.xml.parsers.DocumentBuilderFactory; 032import javax.xml.parsers.ParserConfigurationException; 033import javax.xml.transform.Result; 034import javax.xml.transform.Source; 035import javax.xml.transform.Transformer; 036import javax.xml.transform.TransformerException; 037import javax.xml.transform.TransformerFactory; 038import javax.xml.transform.dom.DOMSource; 039import javax.xml.transform.stream.StreamResult; 040 041import org.w3c.dom.Attr; 042import org.w3c.dom.Document; 043import org.w3c.dom.Element; 044import org.w3c.dom.NamedNodeMap; 045import org.w3c.dom.Node; 046import org.w3c.dom.NodeList; 047import org.xml.sax.SAXException; 048 049import static java.util.Collections.emptyList; 050import static java.util.Collections.singletonList; 051 052/** 053 * Combines two or more XML DOM trees. 054 * 055 * <p> 056 * The merging algorithm is as follows:<br/> 057 * First direct subelements of selected node are examined. 058 * The elements from both trees with matching keys are paired. 059 * Based on selected behavior the content of the paired elements is then merged. 060 * Finally the paired elements are recursively combined. Any not paired elements are appended. 061 * </p> 062 * <p> 063 * You can control merging behavior using {@link CombineSelf 'combine.self'} 064 * and {@link CombineChildren 'combine.children'} attributes. 065 * </p> 066 * <p> 067 * The merging algorithm was inspired by similar functionality in Plexus Utils. 068 * </p> 069 * 070 * @see <a href="http://www.sonatype.com/people/2011/01/maven-how-to-merging-plugin-configuration-in-complex-projects/">merging in Maven</a> 071 * @see <a href="http://plexus.codehaus.org/plexus-utils/apidocs/org/codehaus/plexus/util/xml/Xpp3DomUtils.html">Plexus utils implementation of merging</a> 072 */ 073public class XmlCombiner { 074 /** 075 * Allows to filter the result of the merging. 076 */ 077 public interface Filter { 078 /** 079 * Post process the matching elements after merging. 080 * @param recessive recessive element, can be null, should not be modified 081 * @param dominant dominant element, can be null, should not be modified 082 * @param result result element, will not be null, it can be freely modified 083 */ 084 void postProcess(Element recessive, Element dominant, Element result); 085 } 086 087 private final DocumentBuilder documentBuilder; 088 private final Document document; 089 private final List<String> defaultAttributeNames; 090 private static final Filter NULL_FILTER = new Filter() { 091 @Override 092 public void postProcess(Element recessive, Element dominant, Element result) { 093 } 094 }; 095 private Filter filter = NULL_FILTER; 096 private final ChildContextsMapper childContextMapper = new KeyAttributesChildContextsMapper(); 097 098 public static void main(String[] args) throws ParserConfigurationException, SAXException, IOException, 099 TransformerException { 100 List<Path> files = new ArrayList<>(); 101 List<String> ids = new ArrayList<>(); 102 103 boolean onlyFiles = false; 104 105 for (int i = 0; i < args.length; i++) { 106 if (!onlyFiles) { 107 switch (args[i]) { 108 case "--key": 109 ids.add(args[i+1]); 110 i++; 111 break; 112 case "--": 113 onlyFiles = true; 114 break; 115 default: 116 files.add(Paths.get(args[i])); 117 } 118 } else { 119 files.add(Paths.get(args[i])); 120 } 121 } 122 123 XmlCombiner xmlCombiner = new XmlCombiner(ids); 124 125 for (Path file : files) { 126 xmlCombiner.combine(file); 127 } 128 129 xmlCombiner.buildDocument(System.out); 130 } 131 132 /** 133 * Creates XML combiner using default {@link DocumentBuilder}. 134 * @throws ParserConfigurationException when {@link DocumentBuilder} creation fails 135 */ 136 public XmlCombiner() throws ParserConfigurationException { 137 this(DocumentBuilderFactory.newInstance().newDocumentBuilder()); 138 } 139 140 public XmlCombiner(DocumentBuilder documentBuilder) { 141 this(documentBuilder, emptyList()); 142 } 143 144 /** 145 * Creates XML combiner using given attribute as an id. 146 */ 147 public XmlCombiner(String idAttributeName) throws ParserConfigurationException { 148 this(singletonList(idAttributeName)); 149 } 150 151 public XmlCombiner(List<String> keyAttributeNames) throws ParserConfigurationException { 152 this(DocumentBuilderFactory.newInstance().newDocumentBuilder(), keyAttributeNames); 153 } 154 155 /** 156 * Creates XML combiner using given document builder and an id attribute name. 157 */ 158 public XmlCombiner(DocumentBuilder documentBuilder, String keyAttributeNames) { 159 this(documentBuilder, singletonList(keyAttributeNames)); 160 } 161 162 public XmlCombiner(DocumentBuilder documentBuilder, List<String> keyAttributeNames) { 163 this.documentBuilder = documentBuilder; 164 document = documentBuilder.newDocument(); 165 this.defaultAttributeNames = keyAttributeNames; 166 } 167 168 /** 169 * Sets the filter. 170 */ 171 public void setFilter(Filter filter) { 172 if (filter == null) { 173 this.filter = NULL_FILTER; 174 return; 175 } 176 this.filter = filter; 177 } 178 179 /** 180 * Combine given file. 181 * @param file file to combine 182 */ 183 public void combine(Path file) throws SAXException, IOException { 184 combine(documentBuilder.parse(file.toFile())); 185 } 186 187 /** 188 * Combine given input stream. 189 * @param stream input stream to combine 190 */ 191 public void combine(InputStream stream) throws SAXException, IOException { 192 combine(documentBuilder.parse(stream)); 193 } 194 195 /** 196 * Combine given document. 197 * @param document document to combine 198 */ 199 public void combine(Document document) { 200 combine(document.getDocumentElement()); 201 } 202 203 /** 204 * Combine given element. 205 * @param element element to combine 206 */ 207 public void combine(Element element) { 208 Element parent = document.getDocumentElement(); 209 if (parent != null) { 210 document.removeChild(parent); 211 } 212 Context result = combine(Context.fromElement(parent), Context.fromElement(element)); 213 if (result != null) { 214 result.addAsChildTo(document); 215 } 216 } 217 218 /** 219 * Return the result of the merging process. 220 */ 221 public Document buildDocument() { 222 Element element = document.getDocumentElement(); 223 if (element != null) { 224 filterOutDefaults(Context.fromElement(element)); 225 filterOutCombines(element); 226 } 227 return document; 228 } 229 230 /** 231 * Stores the result of the merging process. 232 */ 233 public void buildDocument(OutputStream out) throws TransformerException { 234 Document result = buildDocument(); 235 236 Transformer transformer = TransformerFactory.newInstance().newTransformer(); 237 Result output = new StreamResult(out); 238 Source input = new DOMSource(result); 239 240 transformer.transform(input, output); 241 } 242 243 /** 244 * Stores the result of the merging process. 245 */ 246 public void buildDocument(Path path) throws TransformerException, FileNotFoundException { 247 buildDocument(new FileOutputStream(path.toFile())); 248 } 249 250 private Context combine(Context recessive, Context dominant) { 251 CombineSelf dominantCombineSelf = getCombineSelf(dominant.getElement()); 252 CombineSelf recessiveCombineSelf = getCombineSelf(recessive.getElement()); 253 254 if (dominantCombineSelf == CombineSelf.REMOVE) { 255 return null; 256 } else if (dominantCombineSelf == CombineSelf.OVERRIDE 257 || (recessiveCombineSelf == CombineSelf.OVERRIDABLE)) { 258 Context result = copyRecursively(dominant); 259 result.getElement().removeAttribute(CombineSelf.ATTRIBUTE_NAME); 260 return result; 261 } 262 263 CombineChildren combineChildren = getCombineChildren(dominant.getElement()); 264 if (combineChildren == null) { 265 combineChildren = getCombineChildren(recessive.getElement()); 266 if (combineChildren == null) { 267 combineChildren = CombineChildren.MERGE; 268 } 269 } 270 271 if (combineChildren == CombineChildren.APPEND) { 272 if (recessive.getElement() != null) { 273 removeWhitespaceTail(recessive.getElement()); 274 appendRecursively(dominant, recessive); 275 return recessive; 276 } else { 277 return copyRecursively(dominant); 278 } 279 } 280 281 Element resultElement = document.createElement(dominant.getElement().getTagName()); 282 283 copyAttributes(recessive.getElement(), resultElement); 284 copyAttributes(dominant.getElement(), resultElement); 285 286 // when dominant combineSelf is null or DEFAULTS use combineSelf from recessive 287 CombineSelf combineSelf = dominantCombineSelf; 288 if ((combineSelf == null && recessiveCombineSelf != CombineSelf.DEFAULTS)) { 289 //|| (combineSelf == CombineSelf.DEFAULTS && recessive.getElement() != null)) { 290 combineSelf = recessiveCombineSelf; 291 } 292 if (combineSelf != null) { 293 resultElement.setAttribute(CombineSelf.ATTRIBUTE_NAME, combineSelf.name().toLowerCase()); 294 } else { 295 resultElement.removeAttribute(CombineSelf.ATTRIBUTE_NAME); 296 } 297 298 List<String> keys = defaultAttributeNames; 299 if (recessive.getElement() != null) { 300 Attr keysNode = recessive.getElement().getAttributeNode(Context.KEYS_ATTRIBUTE_NAME); 301 if (keysNode != null) { 302 keys = Arrays.asList(keysNode.getValue().split(",")); 303 } 304 } 305 if (dominant.getElement() != null) { 306 Attr keysNode = dominant.getElement().getAttributeNode(Context.KEYS_ATTRIBUTE_NAME); 307 if (keysNode != null) { 308 keys = Arrays.asList(keysNode.getValue().split(",")); 309 } 310 } 311 312 Map<Key, List<Context>> recessiveContexts = childContextMapper.mapChildContexts(recessive, keys); 313 Map<Key, List<Context>> dominantContexts = childContextMapper.mapChildContexts(dominant, keys); 314 315 Set<String> tagNamesInDominant = getTagNames(dominantContexts); 316 317 // Execute only if there is at least one subelement in recessive 318 if (!recessiveContexts.isEmpty()) { 319 for (Key key : recessiveContexts.keySet()) { 320 for (Context recessiveContext : recessiveContexts.getOrDefault(key, emptyList())) { 321 322 if (key == Key.BEFORE_END) { 323 continue; 324 } 325 326 if (getCombineSelf(recessiveContext.getElement()) == CombineSelf.OVERRIDABLE_BY_TAG) { 327 if (!tagNamesInDominant.contains(key.getName())) { 328 recessiveContext.addAsChildTo(resultElement); 329 filter.postProcess(recessiveContext.getElement(), null, recessiveContext.getElement()); 330 } 331 continue; 332 } 333 334 if (dominantContexts.getOrDefault(key, emptyList()).size() == 1 335 && recessiveContexts.getOrDefault(key, emptyList()).size() == 1) { 336 337 Context dominantContext = dominantContexts.get(key).iterator().next(); 338 339 Context combined = combine(recessiveContext, dominantContext); 340 if (combined != null) { 341 combined.addAsChildTo(resultElement); 342 } 343 } else { 344 recessiveContext.addAsChildTo(resultElement); 345 if (recessiveContext.getElement() != null) { 346 filter.postProcess(recessiveContext.getElement(), null, recessiveContext.getElement()); 347 } 348 } 349 } 350 } 351 } 352 353 for (Key key : dominantContexts.keySet()) { 354 for (Context dominantContext : dominantContexts.get(key)) { 355 356 if (key == Key.BEFORE_END) { 357 dominantContext.addAsChildTo(resultElement, document); 358 if (dominantContext.getElement() != null) { 359 filter.postProcess(null, dominantContext.getElement(), dominantContext.getElement()); 360 } 361 // break? this should be the last anyway... 362 continue; 363 } 364 List<Context> associatedRecessives = recessiveContexts.getOrDefault(key, emptyList()); 365 if (dominantContexts.getOrDefault(key, emptyList()).size() == 1 && associatedRecessives.size() == 1 366 && getCombineSelf(associatedRecessives.get(0).getElement()) != CombineSelf.OVERRIDABLE_BY_TAG) { 367 // already added 368 } else { 369 Context combined = combine(Context.fromElement(null), dominantContext); 370 if (combined != null) { 371 combined.addAsChildTo(resultElement); 372 } 373 } 374 } 375 } 376 377 Context result = new Context(); 378 result.setElement(resultElement); 379 appendNeighbours(dominant, result); 380 381 filter.postProcess(recessive.getElement(), dominant.getElement(), result.getElement()); 382 383 return result; 384 } 385 386 /** 387 * Copy element recursively. 388 * @param context context to copy, it is assumed it is from unrelated document 389 * @return copied element in current document 390 */ 391 private Context copyRecursively(Context context) { 392 Context copy = new Context(); 393 394 appendNeighbours(context, copy); 395 396 Element element = (Element) document.importNode(context.getElement(), false); 397 copy.setElement(element); 398 399 appendRecursively(context, copy); 400 401 return copy; 402 } 403 404 /** 405 * Append neighbors from source to destination 406 * @param source source element, it is assumed it is from unrelated document 407 * @param destination destination element 408 */ 409 private void appendNeighbours(Context source, Context destination) { 410 for (Node neighbour : source.getNeighbours()) { 411 destination.addNeighbour(document.importNode(neighbour, true)); 412 } 413 } 414 415 /** 416 * Appends all attributes and subelements from source element do destination element. 417 * @param source source element, it is assumed it is from unrelated document 418 * @param destination destination element 419 */ 420 private void appendRecursively(Context source, Context destination) { 421 copyAttributes(source.getElement(), destination.getElement()); 422 423 List<Context> contexts = source.groupChildContexts(); 424 425 for (Context context : contexts) { 426 if (context.getElement() == null) { 427 context.addAsChildTo(destination.getElement(), document); 428 continue; 429 } 430 Context combined = combine(Context.fromElement(null), context); 431 if (combined != null) { 432 combined.addAsChildTo(destination.getElement()); 433 } 434 } 435 } 436 437 /** 438 * Copies attributes from one {@link Element} to the other. 439 * @param source source element 440 * @param destination destination element 441 */ 442 private void copyAttributes(Element source, Element destination) { 443 if (source == null) { 444 return; 445 } 446 NamedNodeMap attributes = source.getAttributes(); 447 for (int i = 0; i < attributes.getLength(); i++) { 448 Attr attribute = (Attr) attributes.item(i); 449 Attr destAttribute = destination.getAttributeNodeNS(attribute.getNamespaceURI(), attribute.getName()); 450 451 if (destAttribute == null) { 452 destination.setAttributeNodeNS((Attr) document.importNode(attribute, true)); 453 } else { 454 destAttribute.setValue(attribute.getValue()); 455 } 456 } 457 } 458 459 private static CombineSelf getCombineSelf(Element element) { 460 CombineSelf combine = null; 461 if (element == null) { 462 return null; 463 } 464 Attr combineAttribute = element.getAttributeNode(CombineSelf.ATTRIBUTE_NAME); 465 if (combineAttribute != null) { 466 try { 467 combine = CombineSelf.valueOf(combineAttribute.getValue().toUpperCase()); 468 } catch (IllegalArgumentException e) { 469 throw new RuntimeException("The attribute 'combine' of element '" 470 + element.getTagName() + "' has invalid value '" 471 + combineAttribute.getValue(), e); 472 } 473 } 474 return combine; 475 } 476 477 private static CombineChildren getCombineChildren(Element element) { 478 CombineChildren combine = null; 479 if (element == null) { 480 return null; 481 } 482 Attr combineAttribute = element.getAttributeNode(CombineChildren.ATTRIBUTE_NAME); 483 if (combineAttribute != null) { 484 try { 485 combine = CombineChildren.valueOf(combineAttribute.getValue().toUpperCase()); 486 } catch (IllegalArgumentException e) { 487 throw new RuntimeException("The attribute 'combine' of element '" 488 + element.getTagName() + "' has invalid value '" 489 + combineAttribute.getValue(), e); 490 } 491 } 492 return combine; 493 } 494 495 private static void removeWhitespaceTail(Element element) { 496 NodeList list = element.getChildNodes(); 497 for (int i = list.getLength() - 1; i >= 0; i--) { 498 Node node = list.item(i); 499 if (node instanceof Element) { 500 break; 501 } 502 element.removeChild(node); 503 } 504 } 505 506 private static void filterOutDefaults(Context context) { 507 Element element = context.getElement(); 508 List<Context> childContexts = context.groupChildContexts(); 509 510 for (Context childContext : childContexts) { 511 if (childContext.getElement() == null) { 512 continue; 513 } 514 CombineSelf combineSelf = getCombineSelf(childContext.getElement()); 515 if (combineSelf == CombineSelf.DEFAULTS) { 516 for (Node neighbour : childContext.getNeighbours()) { 517 element.removeChild(neighbour); 518 } 519 element.removeChild(childContext.getElement()); 520 } else { 521 filterOutDefaults(childContext); 522 } 523 } 524 } 525 526 private static void filterOutCombines(Element element) { 527 element.removeAttribute(CombineSelf.ATTRIBUTE_NAME); 528 element.removeAttribute(CombineChildren.ATTRIBUTE_NAME); 529 element.removeAttribute(Context.KEYS_ATTRIBUTE_NAME); 530 element.removeAttribute(Context.ID_ATTRIBUTE_NAME); 531 532 NodeList childNodes = element.getChildNodes(); 533 for (int i = 0; i < childNodes.getLength(); i++) { 534 Node item = childNodes.item(i); 535 if (item instanceof Element) { 536 filterOutCombines((Element) item); 537 } 538 } 539 } 540 541 private static Set<String> getTagNames(Map<Key, List<Context>> dominantContexts) { 542 Set<String> names = new HashSet<>(); 543 for (Key key : dominantContexts.keySet()) { 544 names.add(key.getName()); 545 } 546 547 return names; 548 } 549}