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