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