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.config.xmlmerge;
015
016import java.util.List;
017import java.util.Map.Entry;
018
019import javax.annotation.Nullable;
020import javax.xml.parsers.DocumentBuilder;
021import javax.xml.parsers.DocumentBuilderFactory;
022import javax.xml.parsers.ParserConfigurationException;
023
024import org.w3c.dom.Attr;
025import org.w3c.dom.Document;
026import org.w3c.dom.Element;
027import org.w3c.dom.NamedNodeMap;
028import org.w3c.dom.Node;
029import org.w3c.dom.NodeList;
030
031import com.google.common.collect.ListMultimap;
032
033/**
034 * Combines two or more XML DOM trees.
035 *
036 * <p>
037 * The merging algorithm is as follows:<br/>
038 * First direct subelements of selected node are examined.
039 * The elements from both trees with matching tag names and the value of 'id' attributes are paired.
040 * Based on selected behavior the content of the paired elements is then merged.
041 * Finally the paired elements are recursively combined. Any not paired elements are appended.
042 * </p>
043 * <p>
044 * You can control merging behavior using {@link CombineSelf 'combine.self'}
045 * and {@link CombineChildren 'combine.children'} attributes.
046 * </p>
047 * <p>
048 * The merging algorithm was inspired by similar functionality in Plexus Utils.
049 * </p>
050 *
051 * @see <a href="http://www.sonatype.com/people/2011/01/maven-how-to-merging-plugin-configuration-in-complex-projects/">merging in Maven</a>
052 * @see <a href="http://plexus.codehaus.org/plexus-utils/apidocs/org/codehaus/plexus/util/xml/Xpp3DomUtils.html">Plexus utils implementation of merging</a>
053 */
054public class XmlCombiner {
055
056    private DocumentBuilder documentBuilder;
057    private Document document;
058
059    /**
060     * Creates XML combiner using default {@link DocumentBuilder}.
061     * @throws ParserConfigurationException when {@link DocumentBuilder} creation fails
062     */
063    public XmlCombiner() throws ParserConfigurationException {
064        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
065        documentBuilder = factory.newDocumentBuilder();
066        document = documentBuilder.newDocument();
067    }
068
069    public XmlCombiner(DocumentBuilder documentBuilder) {
070        this.documentBuilder = documentBuilder;
071        document = documentBuilder.newDocument();
072    }
073
074    /**
075     * Combine given document.
076     * @param document document to combine
077     */
078    public void combine(Document document) {
079        combine(document.getDocumentElement());
080    }
081
082    /**
083     * Combine given element.
084     * @param element element to combine
085     */
086    public void combine(Element element) {
087        Element parent = document.getDocumentElement();
088        if (parent != null) {
089            document.removeChild(parent);
090        }
091        Context result = combine(Context.fromElement(parent), Context.fromElement(element));
092        result.addAsChildTo(document);
093    }
094
095    public Document buildDocument() {
096        filterOutDefaults(Context.fromElement(document.getDocumentElement()));
097        return document;
098    }
099
100    private Context combine(Context recessive, Context dominant) {
101        CombineSelf dominantCombineSelf = getCombineSelf(dominant.getElement());
102        CombineSelf recessiveCombineSelf = getCombineSelf(recessive.getElement());
103
104        if (dominantCombineSelf == CombineSelf.REMOVE) {
105            return null;
106        } else if (dominantCombineSelf == CombineSelf.OVERRIDE) {
107            Context result = copyRecursively(dominant);
108            result.getElement().removeAttribute(CombineSelf.ATTRIBUTE_NAME);
109            return result;
110        }
111
112        CombineChildren combineChildren = getCombineChildren(dominant.getElement());
113        if (combineChildren == null) {
114            combineChildren = getCombineChildren(recessive.getElement());
115            if (combineChildren == null) {
116                combineChildren = CombineChildren.MERGE;
117            }
118        }
119
120        if (combineChildren == CombineChildren.APPEND) {
121            if (recessive.getElement() != null) {
122                removeWhitespaceTail(recessive.getElement());
123                appendRecursively(dominant, recessive);
124                return recessive;
125            } else {
126                return copyRecursively(dominant);
127            }
128        }
129
130        Element resultElement = document.createElement(dominant.getElement().getTagName());
131
132        copyAttributes(recessive.getElement(), resultElement);
133        copyAttributes(dominant.getElement(), resultElement);
134
135        // when dominant combineSelf is null or DEFAULTS use combineSelf from recessive
136        CombineSelf combineSelf = dominantCombineSelf;
137        if ((combineSelf == null && recessiveCombineSelf != CombineSelf.DEFAULTS)) {
138                //|| (combineSelf == CombineSelf.DEFAULTS && recessive.getElement() != null)) {
139            combineSelf = recessiveCombineSelf;
140        }
141        if (combineSelf != null) {
142            resultElement.setAttribute(CombineSelf.ATTRIBUTE_NAME, combineSelf.name().toLowerCase());
143        } else {
144            resultElement.removeAttribute(CombineSelf.ATTRIBUTE_NAME);
145        }
146
147        ListMultimap<Key, Context> recessiveContexts = recessive.mapChildContexts();
148        ListMultimap<Key, Context> dominantContexts = dominant.mapChildContexts();
149
150        // Execute only if there is at least one subelement in recessive
151        if (!recessiveContexts.isEmpty()) {
152            for (Entry<Key, Context> entry : recessiveContexts.entries()) {
153                Key key = entry.getKey();
154                Context recessiveContext = entry.getValue();
155
156                if (key == Key.BEFORE_END) {
157                    continue;
158                }
159
160                if (dominantContexts.get(key).size() == 1 && recessiveContexts.get(key).size() == 1) {
161                    Context dominantContext = dominantContexts.get(key).iterator().next();
162
163                    Context combined = combine(recessiveContext, dominantContext);
164                    if (combined != null) {
165                        combined.addAsChildTo(resultElement);
166                    }
167                } else {
168                    recessiveContext.addAsChildTo(resultElement);
169                }
170            }
171        }
172
173        for (Entry<Key, Context> entry : dominantContexts.entries()) {
174            Key key = entry.getKey();
175            Context dominantContext = entry.getValue();
176
177            if (key == Key.BEFORE_END) {
178                dominantContext.addAsChildTo(resultElement, document);
179                // break? this should be the last anyway...
180                continue;
181            }
182
183            if (dominantContexts.get(key).size() == 1 && recessiveContexts.get(key).size() == 1) {
184                // already added
185            } else {
186                Context combined = combine(Context.fromElement(null), dominantContext);
187                if (combined != null) {
188                    combined.addAsChildTo(resultElement);
189                }
190            }
191        }
192
193        Context result = new Context();
194        result.setElement(resultElement);
195        appendNeighbours(dominant, result);
196
197        return result;
198    }
199
200    /**
201     * Copy element recursively.
202     * @param context context to copy, it is assumed it is from unrelated document
203     * @return copied element in current document
204     */
205    private Context copyRecursively(Context context) {
206        Context copy = new Context();
207
208        appendNeighbours(context, copy);
209
210        Element element = (Element) document.importNode(context.getElement(), false);
211        copy.setElement(element);
212
213        appendRecursively(context, copy);
214
215        return copy;
216    }
217
218    /**
219     * Append neighbours from source to destination
220     * @param source source element, it is assumed it is from unrelated document
221     * @param destination destination element
222     */
223    private void appendNeighbours(Context source, Context destination) {
224        for (Node neighbour : source.getNeighbours()) {
225            destination.addNeighbour(document.importNode(neighbour, true));
226        }
227    }
228
229    /**
230     * Appends all attributes and subelements from source element do destination element.
231     * @param source source element, it is assumed it is from unrelated document
232     * @param destination destination element
233     */
234    private void appendRecursively(Context source, Context destination) {
235        copyAttributes(source.getElement(), destination.getElement());
236
237        List<Context> contexts = source.groupChildContexts();
238
239        for (Context context : contexts) {
240            if (context.getElement() == null) {
241                context.addAsChildTo(destination.getElement(), document);
242                continue;
243            }
244            Context combined = combine(Context.fromElement(null), context);
245            if (combined != null) {
246                combined.addAsChildTo(destination.getElement());
247            }
248        }
249    }
250
251    /**
252     * Copies attributes from one {@link Element} to the other.
253     * @param source source element
254     * @param destination destination element
255     */
256    private void copyAttributes(@Nullable Element source, Element destination) {
257        if (source == null) {
258            return;
259        }
260        NamedNodeMap attributes = source.getAttributes();
261        for (int i = 0; i < attributes.getLength(); i++) {
262            Attr attribute = (Attr) attributes.item(i);
263            Attr destAttribute = destination.getAttributeNodeNS(attribute.getNamespaceURI(), attribute.getName());
264
265            if (destAttribute == null) {
266                destination.setAttributeNodeNS((Attr) document.importNode(attribute, true));
267            } else {
268                destAttribute.setValue(attribute.getValue());
269            }
270        }
271    }
272
273    private static CombineSelf getCombineSelf(@Nullable Element element) {
274        CombineSelf combine = null;
275        if (element == null) {
276            return null;
277        }
278        Attr combineAttribute = element.getAttributeNode(CombineSelf.ATTRIBUTE_NAME);
279        if (combineAttribute != null) {
280            try {
281                combine = CombineSelf.valueOf(combineAttribute.getValue().toUpperCase());
282            } catch (IllegalArgumentException e) {
283                throw new RuntimeException("The attribute 'combine' of element '"
284                        + element.getTagName() + "' has invalid value '"
285                        + combineAttribute.getValue(), e);
286            }
287        }
288        return combine;
289    }
290
291    private static CombineChildren getCombineChildren(@Nullable Element element) {
292        CombineChildren combine = null;
293        if (element == null) {
294            return null;
295        }
296        Attr combineAttribute = element.getAttributeNode(CombineChildren.ATTRIBUTE_NAME);
297        if (combineAttribute != null) {
298            try {
299                combine = CombineChildren.valueOf(combineAttribute.getValue().toUpperCase());
300            } catch (IllegalArgumentException e) {
301                throw new RuntimeException("The attribute 'combine' of element '"
302                        + element.getTagName() + "' has invalid value '"
303                        + combineAttribute.getValue(), e);
304            }
305        }
306        return combine;
307    }
308
309    private static void removeWhitespaceTail(Element element) {
310        NodeList list = element.getChildNodes();
311        for (int i = list.getLength() - 1; i >= 0; i--) {
312            Node node = list.item(i);
313            if (node instanceof Element) {
314                break;
315            }
316            element.removeChild(node);
317        }
318    }
319
320    private static void filterOutDefaults(Context context) {
321        Element element = context.getElement();
322        List<Context> childContexts = context.groupChildContexts();
323
324        for (Context childContext : childContexts) {
325            if (childContext.getElement() == null) {
326                continue;
327            }
328            CombineSelf combineSelf = getCombineSelf(childContext.getElement());
329            if (combineSelf == CombineSelf.DEFAULTS) {
330                for (Node neighbour : childContext.getNeighbours()) {
331                    element.removeChild(neighbour);
332                }
333                element.removeChild(childContext.getElement());
334            } else {
335                filterOutDefaults(childContext);
336            }
337        }
338    }
339}