001    /*
002      GRANITE DATA SERVICES
003      Copyright (C) 2011 GRANITE DATA SERVICES S.A.S.
004    
005      This file is part of Granite Data Services.
006    
007      Granite Data Services is free software; you can redistribute it and/or modify
008      it under the terms of the GNU Library General Public License as published by
009      the Free Software Foundation; either version 2 of the License, or (at your
010      option) any later version.
011    
012      Granite Data Services is distributed in the hope that it will be useful, but
013      WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
014      FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public License
015      for more details.
016    
017      You should have received a copy of the GNU Library General Public License
018      along with this library; if not, see <http://www.gnu.org/licenses/>.
019    */
020    
021    package org.granite.util;
022    
023    import java.io.ByteArrayInputStream;
024    import java.io.ByteArrayOutputStream;
025    import java.io.IOException;
026    import java.io.InputStream;
027    import java.io.ObjectInputStream;
028    import java.io.ObjectOutputStream;
029    import java.io.Serializable;
030    import java.util.ArrayList;
031    import java.util.List;
032    
033    import javax.xml.transform.TransformerException;
034    
035    import org.granite.logging.Logger;
036    import org.w3c.dom.Attr;
037    import org.w3c.dom.Document;
038    import org.w3c.dom.Element;
039    import org.w3c.dom.Node;
040    import org.xml.sax.EntityResolver;
041    import org.xml.sax.SAXException;
042    
043    /**
044     * Utility class that makes XML fragment tree manipulation easier.
045     * <br />
046     * This class relies on JDK DOM & XPath built-in implementations.
047     * 
048     * @author Franck WOLFF
049     */
050    public class XMap implements Serializable {
051    
052            private static final Logger log = Logger.getLogger(XMap.class);
053            
054            private static final long serialVersionUID = 1L;
055    
056            protected static final String DEFAULT_ROOT_NAME = "root";
057            
058            /**
059             * An empty and unmodifiable XMap instance.
060             */
061            public static final XMap EMPTY_XMAP = new XMap(null, null, false) {
062    
063                    private static final long serialVersionUID = 1L;
064    
065                    @Override
066                    public String put(String key, String value) {
067                            throw new RuntimeException("Immutable XMap");
068                    }
069                    
070                    @Override
071                    public String remove(String key) {
072                            throw new RuntimeException("Immutable XMap");
073                    }
074            };
075            
076            private transient DOM dom = null;
077            private transient Element root = null;
078            
079            /**
080             * Constructs a new XMap instance.
081             */
082            public XMap() {
083                    this(null, null, false);
084            }
085            
086            /**
087             * Constructs a new XMap instance.
088             * 
089             * @param root the name of the root element (may be null).
090             */
091            public XMap(String root) {
092                    if (root != null) {
093                            this.dom = new DOM();
094                            this.root = dom.newDocument(root).getDocumentElement();
095                    }
096            }
097            
098            /**
099             * Constructs a new XMap instance from an XML input stream.
100             * 
101             * @param input an XML input stream.
102             */
103            public XMap(InputStream input) throws IOException, SAXException {
104                    this.dom = new DOM();
105                    this.root = dom.loadDocument(input).getDocumentElement();
106            }
107            
108            /**
109             * Constructs a new XMap instance from an XML input stream.
110             * 
111             * @param input an XML input stream.
112             */
113            public XMap(InputStream input, EntityResolver resolver) throws IOException, SAXException {
114                    this.dom = new DOM();
115                    this.root = dom.loadDocument(input, resolver).getDocumentElement();
116            }
117            
118            
119            /**
120             * Constructs a new XMap instance.
121             * 
122             * @param root a DOM element (may be null).
123             */
124            public XMap(Element root) {
125                    this(null, root, true);
126            }
127    
128            /**
129             * Constructs a new XMap instance based on an existing XMap and clone its content.
130             * 
131             * @param map the map to duplicate (root element is cloned so modification to this
132             *              new instance won't modify the original XMap). 
133             */
134            public XMap(XMap map) {
135                    this((map == null ? null : map.dom), (map == null ? null : map.root), true);
136            }
137            
138            /**
139             * Constructs a new XMap instance.
140             * 
141             * @param root the root element (may be null).
142             * @param clone should we clone the root element (prevent original node modification).
143             */
144            protected XMap(DOM dom, Element root, boolean clone) {
145                    this.dom = dom;
146                    this.root = (clone && root != null ? (Element)root.cloneNode(true) : root);
147                    
148            }
149            
150            /**
151             * Allows direct manipulation of the root element.
152             * 
153             * @return the root element of this XMap instance.
154             */
155            public Element getRoot() {
156                    return root;
157            }
158    
159            /**
160             * Returns the DOM instance of this XMap (one is created if it is null).
161             * 
162             * @return the DOM instance of this XMap.
163             */
164            protected DOM getDom() {
165                    if (dom == null)
166                            dom = new DOM();
167                    return dom;
168            }
169            
170            /**
171             * Returns true if the supplied key XPath expression matches at least one element, attribute
172             * or text in the root element of this XMap. 
173             * 
174             * @param key an XPath expression.
175             * @return true if the supplied key XPath expression matches at least one element, attribute
176             *              or text in the root element of this XMap, false otherwise.
177             * @throws RuntimeException if the XPath expression isn't correct.
178             */
179            public boolean containsKey(String key) {
180                    if (root == null)
181                            return false;
182                    try {
183                            Node result = getDom().selectSingleNode(root, key);
184                            return (
185                                    result != null && (
186                                            result.getNodeType() == Node.ELEMENT_NODE ||
187                                            result.getNodeType() == Node.TEXT_NODE ||
188                                            result.getNodeType() == Node.ATTRIBUTE_NODE
189                                    )
190                            );
191                    } catch (Exception e) {
192                            throw new RuntimeException(e);
193                    }
194            }
195    
196            /**
197             * Returns the text value of the element (or attribute or text) that matches the supplied
198             * XPath expression. 
199             * 
200             * @param key an XPath expression.
201             * @return the text value of the matched element or null if the element does not exist or have
202             *              no value.
203             * @throws RuntimeException if the XPath expression isn't correct.
204             */
205            public String get(String key) {
206                    if (root == null)
207                            return null;
208                    try {
209                            return getDom().getNormalizedValue(getDom().selectSingleNode(root, key));
210                    } catch (Exception e) {
211                            throw new RuntimeException(e);
212                    }
213            }
214    
215            public <T> T get(String key, Class<T> clazz, T defaultValue) {
216                    return get(key, clazz, defaultValue, false, true);
217            }
218    
219            public <T> T get(String key, Class<T> clazz, T defaultValue, boolean required, boolean warn) {
220    
221                    String sValue = get(key);
222                    
223            if (required && sValue == null)
224                    throw new RuntimeException(key + " value is required in XML file:\n" + toString());
225                    
226            Object oValue = defaultValue;
227            
228            boolean unsupported = false;
229            if (sValue != null) {
230                    try {
231                            if (clazz == String.class)
232                                    oValue = sValue;
233                            else if (clazz == Integer.class || clazz == Integer.TYPE)
234                                    oValue = Integer.valueOf(sValue);
235                            else if (clazz == Long.class || clazz == Long.TYPE)
236                                    oValue = Long.valueOf(sValue);
237                            else if (clazz == Boolean.class || clazz == Boolean.TYPE) {
238                                    if (!Boolean.TRUE.toString().equalsIgnoreCase(sValue) && !Boolean.FALSE.toString().equalsIgnoreCase(sValue))
239                                            throw new NumberFormatException(sValue);
240                                    oValue = Boolean.valueOf(sValue);
241                            }
242                            else if (clazz == Double.class || clazz == Double.TYPE)
243                                    oValue = Double.valueOf(sValue);
244                            else if (clazz == Float.class || clazz == Float.TYPE)
245                                    oValue = Float.valueOf(sValue);
246                            else if (clazz == Short.class || clazz == Short.TYPE)
247                                    oValue = Short.valueOf(sValue);
248                            else if (clazz == Byte.class || clazz == Byte.TYPE)
249                                    oValue = Byte.valueOf(sValue);
250                            else
251                                    unsupported = true; 
252                    }
253                    catch (Exception e) {
254                            if (warn)
255                                    log.warn(e, "Illegal %s value for %s=%s (using default: %s)", clazz.getSimpleName(), key, sValue, defaultValue);
256                    }
257            }
258            
259            if (unsupported)
260                    throw new UnsupportedOperationException("Unsupported value type: " + clazz.getName());
261            
262            @SuppressWarnings("unchecked")
263            T tValue = (T)oValue;
264            
265            return tValue;
266            }
267    
268            /**
269             * Returns a list of XMap instances with all elements that match the
270             * supplied XPath expression. Note that XPath result nodes that are not instance of
271             * Element are ignored. Note also that returned XMaps contain original child elements of
272             * the root element of this XMap so modifications made to child elements affect this XMap
273             * instance as well.  
274             * 
275             * @param key an XPath expression.
276             * @return an unmodifiable list of XMap instances.
277             * @throws RuntimeException if the XPath expression isn't correct.
278             */
279            public List<XMap> getAll(String key) {
280                    if (root == null)
281                            return new ArrayList<XMap>(0);
282                    try {
283                            List<Node> result = dom.selectNodes(root, key);
284                            List<XMap> xMaps = new ArrayList<XMap>(result.size());
285                            for (Node node : result) {
286                                    if (node.getNodeType() == Node.ELEMENT_NODE)
287                                            xMaps.add(new XMap(this.dom, (Element)node, false));
288                            }
289                            return xMaps;
290                    } catch (Exception e) {
291                            throw new RuntimeException(e);
292                    }
293            }
294    
295            /**
296             * Returns a new XMap instance with the first element that matches the
297             * supplied XPath expression or null if this XMap root element is null, or if XPath evaluation
298             * result is null, or this result is not an Element. Returned XMap contains original child element of
299             * the root element of this XMap so modifications made to the child element affect this XMap
300             * instance as well.  
301             * 
302             * @param key an XPath expression.
303             * @return a single new XMap instance.
304             * @throws RuntimeException if the XPath expression isn't correct.
305             */
306            public XMap getOne(String key) {
307                    if (root == null)
308                            return null;
309                    try {
310                            Node node = getDom().selectSingleNode(root, key);
311                            if (node == null || node.getNodeType() != Node.ELEMENT_NODE)
312                                    return null;
313                            return new XMap(getDom(), (Element)node, false);
314                    } catch (Exception e) {
315                            throw new RuntimeException(e);
316                    }
317            }
318            
319            /**
320             * Creates or updates the text value of the element (or text or attribute) matched by
321             * the supplied XPath expression. If the matched element (or text or attribute) does not exist,
322             * it is created with the last segment of the XPath expression (but its parent must already exist).
323             * 
324             * @param key an XPath expression.
325             * @param value the value to set (may be null).
326             * @return the previous value of the matched element (may be null).
327             * @throws RuntimeException if the root element of this XMap is null, if the XPath expression is not valid,
328             *              or (creation case) if the parent node does not exist or is not an element instance. 
329             */
330            public String put(String key, String value) {
331                    return put(key, value, false);
332            }
333            
334            /**
335             * Creates or updates the text value of the element (or text or attribute) matched by
336             * the supplied XPath expression. If the matched element (or text or attribute) does not exist or if append
337             * is <tt>true</tt>, it is created with the last segment of the XPath expression (but its parent must already
338             * exist).
339             * 
340             * @param key an XPath expression.
341             * @param value the value to set (may be null).
342             * @param append should the new element be appended (created) next to a possibly existing element(s) of
343             *              the same name?
344             * @return the previous value of the matched element (may be null).
345             * @throws RuntimeException if the root element of this XMap is null, if the XPath expression is not valid,
346             *              or (creation case) if the parent node does not exist or is not an element instance. 
347             */
348            public String put(String key, String value, boolean append) {
349                    if (root == null)
350                            root = getDom().newDocument(DEFAULT_ROOT_NAME).getDocumentElement();
351    
352                    if (!append) {
353                            try {
354                                    Node selectResult = getDom().selectSingleNode(root, key);
355                                    if (selectResult != null)
356                                            return getDom().setValue(selectResult, value);
357                            } catch(RuntimeException e) {
358                                    throw e;
359                            } catch(Exception e) {
360                                    throw new RuntimeException(e);
361                            }
362                    }
363                    
364                    Element parent = root;
365                    String name = key;
366                    
367                    int iLastSlash = key.lastIndexOf('/');
368                    if (iLastSlash != -1) {
369                            name = key.substring(iLastSlash + 1);
370                            Node selectResult = null;
371                            try {
372                                    selectResult = getDom().selectSingleNode(root, key.substring(0, iLastSlash));
373                            } catch (Exception e) {
374                                    throw new RuntimeException(e);
375                            }
376                            if (selectResult == null)
377                                    throw new RuntimeException("Parent node does not exist: " + key.substring(0, iLastSlash));
378                            if (!(selectResult instanceof Element))
379                                    throw new RuntimeException("Parent node must be an Element: " + key.substring(0, iLastSlash) + " -> " + selectResult);
380                            parent = (Element)selectResult;
381                    }
382                    
383                    if (name.length() > 0 && name.charAt(0) == '@')
384                            parent.setAttribute(name.substring(1), value);
385                    else
386                            getDom().newElement(parent, name, value);
387                    
388                    return null;
389            }
390            
391            /**
392             * Removes the element, text or attribute that matches the supplied XPath expression.
393             * 
394             * @param key  an XPath expression.
395             * @return the previous value of the matched node if any.
396             * @throws RuntimeException if the XPath expression isn't valid.
397             */
398            public String remove(String key) {
399                    if (root == null)
400                            return null;
401                    try {
402                            Node node = getDom().selectSingleNode(root, key);
403                            if (node != null) {
404                                    String value = getDom().getNormalizedValue(node);
405                                    if (node.getNodeType() == Node.ATTRIBUTE_NODE)
406                                            ((Attr)node).getOwnerElement().removeAttribute(node.getNodeName());
407                                    else
408                                            node.getParentNode().removeChild(node);
409                                    return value;
410                            }
411                    } catch(Exception e) {
412                            throw new RuntimeException(e);
413                    }
414                    return null;
415            }
416            
417            /**
418             * Returns a "pretty" XML representation of the root element of this XMap (may be null).
419             * 
420             * @return a "pretty" XML representation of the root element of this XMap (may be null).
421             */
422            @Override
423            public String toString() {
424                    return getDom().toString(root);
425            }
426            
427            /**
428             * Write java.io.Serializable method.
429             * 
430             * @param out the ObjectOutputStream where to write this XMap.
431             * @throws IOException if writing fails.
432             */
433            private void writeObject(ObjectOutputStream out) throws IOException {
434                    if (root == null)
435                            out.writeInt(0);
436                    else {
437                            ByteArrayOutputStream output = new ByteArrayOutputStream();
438                            try {
439                                    getDom().saveDocument(root.getOwnerDocument(), output);
440                            } catch (TransformerException e) {
441                                    IOException ioe = new IOException("Could not serialize this XMap");
442                                    ioe.initCause(e);
443                                    throw ioe;
444                            }
445                            out.writeInt(output.size());
446                            out.write(output.toByteArray());
447                    }
448            }
449            
450            /**
451             * Read java.io.Serializable method.
452             * 
453             * @param in the ObjectInputStream from which to read this XMap.
454             * @throws IOException if readind fails.
455             */
456            @SuppressWarnings("unused")
457            private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
458                    int size = in.readInt();
459                    if (size > 0) {
460                            byte[] content = new byte[size];
461                            in.readFully(content);
462                            Document doc = null;
463                            try {
464                                    doc = getDom().loadDocument(new ByteArrayInputStream(content));
465                            } catch (Exception e) {
466                                    IOException ioe = new IOException("Could not deserialize this XMap");
467                                    ioe.initCause(e);
468                                    throw ioe;
469                            }
470                            if (doc != null && doc.getDocumentElement() != null)
471                                    this.root = doc.getDocumentElement();
472                    }
473            }
474    }