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
021package org.granite.util;
022
023import java.io.ByteArrayInputStream;
024import java.io.ByteArrayOutputStream;
025import java.io.IOException;
026import java.io.InputStream;
027import java.io.ObjectInputStream;
028import java.io.ObjectOutputStream;
029import java.io.Serializable;
030import java.util.ArrayList;
031import java.util.List;
032
033import org.granite.logging.Logger;
034import org.w3c.dom.Attr;
035import org.w3c.dom.Document;
036import org.w3c.dom.Element;
037import org.w3c.dom.Node;
038import org.xml.sax.EntityResolver;
039import 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 */
048public 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}