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}