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