• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
Build has been canceled!

evolvedbinary / elemental / 982

29 Apr 2025 08:34PM UTC coverage: 56.409% (+0.007%) from 56.402%
982

push

circleci

adamretter
[feature] Improve README.md badges

28451 of 55847 branches covered (50.94%)

Branch coverage included in aggregate %.

77468 of 131924 relevant lines covered (58.72%)

0.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.85
/exist-core/src/main/java/org/exist/xquery/functions/fn/FunXmlToJson.java
1
/*
2
 * Elemental
3
 * Copyright (C) 2024, Evolved Binary Ltd
4
 *
5
 * admin@evolvedbinary.com
6
 * https://www.evolvedbinary.com | https://www.elemental.xyz
7
 *
8
 * Use of this software is governed by the Business Source License 1.1
9
 * included in the LICENSE file and at www.mariadb.com/bsl11.
10
 *
11
 * Change Date: 2028-04-27
12
 *
13
 * On the date above, in accordance with the Business Source License, use
14
 * of this software will be governed by the Apache License, Version 2.0.
15
 *
16
 * Additional Use Grant: Production use of the Licensed Work for a permitted
17
 * purpose. A Permitted Purpose is any purpose other than a Competing Use.
18
 * A Competing Use means making the Software available to others in a commercial
19
 * product or service that: substitutes for the Software; substitutes for any
20
 * other product or service we offer using the Software that exists as of the
21
 * date we make the Software available; or offers the same or substantially
22
 * similar functionality as the Software.
23
 *
24
 * NOTE: Parts of this file contain code from 'The eXist-db Authors'.
25
 *       The original license header is included below.
26
 *
27
 * =====================================================================
28
 *
29
 * eXist-db Open Source Native XML Database
30
 * Copyright (C) 2001 The eXist-db Authors
31
 *
32
 * info@exist-db.org
33
 * http://www.exist-db.org
34
 *
35
 * This library is free software; you can redistribute it and/or
36
 * modify it under the terms of the GNU Lesser General Public
37
 * License as published by the Free Software Foundation; either
38
 * version 2.1 of the License, or (at your option) any later version.
39
 *
40
 * This library is distributed in the hope that it will be useful,
41
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
42
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
43
 * Lesser General Public License for more details.
44
 *
45
 * You should have received a copy of the GNU Lesser General Public
46
 * License along with this library; if not, write to the Free Software
47
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
48
 */
49
package org.exist.xquery.functions.fn;
50

51
import com.fasterxml.jackson.core.*;
52
import org.apache.logging.log4j.LogManager;
53
import org.apache.logging.log4j.Logger;
54
import org.exist.dom.QName;
55
import org.exist.xquery.*;
56
import org.exist.xquery.functions.map.MapType;
57
import org.exist.xquery.value.*;
58

59
import javax.xml.stream.XMLStreamException;
60
import javax.xml.stream.XMLStreamReader;
61
import java.io.IOException;
62
import java.io.StringWriter;
63
import java.io.Writer;
64
import java.math.BigDecimal;
65
import java.util.ArrayList;
66

67
import static org.exist.xquery.FunctionDSL.*;
68

69
/**
70
 * @author <a href="mailto:from-github-existdb@agh2342.de">Adrian Hamm</a>
71
 */
72
public class FunXmlToJson extends BasicFunction {
73

74
    private static final Logger logger = LogManager.getLogger();
1✔
75

76
    private static final String FS_XML_TO_JSON_NAME = "xml-to-json";
77
    private static final FunctionParameterSequenceType FS_XML_TO_JSON_OPT_PARAM_NODE = optParam("node", Type.NODE, "The input node");
1✔
78
    private static final FunctionParameterSequenceType FS_XML_TO_JSON_OPT_PARAM_OPTIONS = param("options", Type.MAP_ITEM, "The options map");
1✔
79
    static final FunctionSignature[] FS_XML_TO_JSON = functionSignatures(
1✔
80
            new QName(FS_XML_TO_JSON_NAME, Function.BUILTIN_FUNCTION_NS),
1✔
81
            "Converts an XML tree (in w3c 'XML Representation of JSON' format) into a string conforming to the JSON grammar. Basic string (un)escaping.",
1✔
82
            returnsOpt(Type.STRING, "The JSON representation of the input node"),
1✔
83
            arities(
1✔
84
                    arity(FS_XML_TO_JSON_OPT_PARAM_NODE),
1✔
85
                    arity(FS_XML_TO_JSON_OPT_PARAM_NODE, FS_XML_TO_JSON_OPT_PARAM_OPTIONS)
1✔
86
            )
87
    );
1✔
88

89
    public FunXmlToJson(final XQueryContext context, final FunctionSignature signature) {
90
        super(context, signature);
1✔
91
    }
1✔
92

93
    public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
94
        final Sequence result;
95
        final Sequence seq = (getArgumentCount() > 0) ? args[0] : Sequence.EMPTY_SEQUENCE;
1!
96
        //TODO: implement handling of options
97
        final MapType options = (getArgumentCount() == 2) ? (MapType) args[1].itemAt(0) : new MapType(this, context);
1!
98

99
        if (seq.isEmpty()) {
1✔
100
            result = Sequence.EMPTY_SEQUENCE;
1✔
101
        } else {
1✔
102
            result = new ValueSequence();
1✔
103
            final Item item = seq.itemAt(0);
1✔
104
            if (item.getType() != Type.DOCUMENT && item.getType() != Type.ELEMENT) {
1!
105
                throw new XPathException(this, ErrorCodes.FOJS0006, "Invalid XML representation of JSON.");
×
106
            }
107
            final NodeValue nodeValue = (NodeValue) item;
1✔
108
            final StringWriter stringWriter = new StringWriter();
1✔
109
            nodeValueToJson(nodeValue, stringWriter);
1✔
110
            final String jsonString = stringWriter.toString();
1✔
111
            result.add(new StringValue(this, jsonString));
1✔
112
        }
113
        return result;
1✔
114
    }
115

116
    /**
117
     * Generate a JSON representation of a NodeValue which conforms to
118
     * https://www.w3.org/TR/xpath-functions-31/schema-for-json.xsd
119
     * Traverse a NodeValue via XMLStreamReader and fill a Writer with its JSON representation
120
     * by calling com.fasterxml.jackson write functions according to input type.
121
     * <p>
122
     * Implements basic part of the specification. String (un)escaping is fully delegated to jackson
123
     * and NOT fully conforming to spec.
124
     *
125
     * @param nodeValue the NodeValue to be read
126
     * @param writer    the Writer to be used
127
     * @throws XPathException on error in XML JSON input according to specification
128
     */
129
    private void nodeValueToJson(final NodeValue nodeValue, final Writer writer) throws XPathException {
130
        final StringBuilder tempStringBuilder = new StringBuilder();
1✔
131
        final JsonFactory jsonFactory = new JsonFactory();
1✔
132
        final Integer stackSeparator = 0;
1✔
133
        //use ArrayList<Object> to store String type keys and non-string type separators
134
        final ArrayList<Object> mapkeyArrayList = new ArrayList<>();
1✔
135
        boolean elementKeyIsEscaped = false;
1✔
136
        boolean elementValueIsEscaped = false;
1✔
137
        XMLStreamReader reader = null;
1✔
138
        try (
1✔
139
                final JsonGenerator jsonGenerator = jsonFactory.createGenerator(writer)
1✔
140
        ) {
141
            reader = context.getXMLStreamReader(nodeValue);
1✔
142
            int previous = XMLStreamReader.START_DOCUMENT;
1✔
143
            int status = XMLStreamReader.START_DOCUMENT;
1✔
144
            while (reader.hasNext()) {
1✔
145
                previous = status;
1✔
146
                status = reader.next();
1✔
147
                switch (status) {
1✔
148
                    case XMLStreamReader.START_ELEMENT:
149
                        tempStringBuilder.setLength(0);
1✔
150
                        final String elementAttributeEscapedValue = reader.getAttributeValue(null, "escaped");
1✔
151
                        elementValueIsEscaped = "true".equals(elementAttributeEscapedValue);
1✔
152
                        final String elementAttributeEscapedKeyValue = reader.getAttributeValue(null, "escaped-key");
1✔
153
                        elementKeyIsEscaped = "true".equals(elementAttributeEscapedKeyValue);
1✔
154
                        final String elementKeyValue;
155
                        if (elementKeyIsEscaped) {
1!
156
                            elementKeyValue = unescapeEscapedJsonString(reader.getAttributeValue(null, "key"));
×
157
                        } else {
×
158
                            elementKeyValue = reader.getAttributeValue(null, "key");
1✔
159
                        }
160
                        if (elementKeyValue != null && previous != XMLStreamReader.START_DOCUMENT) {
1✔
161
                            if (mapkeyArrayList.lastIndexOf(elementKeyValue) == -1 || (mapkeyArrayList.lastIndexOf(elementKeyValue) < mapkeyArrayList.lastIndexOf(stackSeparator))) {
1!
162
                                //key not found or found beyond separator, add key, continue
163
                                mapkeyArrayList.add(elementKeyValue);
1✔
164
                                jsonGenerator.writeFieldName(elementKeyValue);
1✔
165
                            } else if (mapkeyArrayList.lastIndexOf(elementKeyValue) > mapkeyArrayList.lastIndexOf(stackSeparator)) {
1!
166
                                //key found, before separator, error double key use in same map
167
                                logger.error("fn:xml-to-json(): FOJS0006: Invalid XML representation of JSON. Found map with double key use. Offending key in double quotes: \"{}\"", elementKeyValue);
×
168
                                throw new XPathException(this, ErrorCodes.FOJS0006, "Invalid XML representation of JSON. Found map with double key use. Offending key in error logs.");
×
169
                            }
170
                        }
171
                        switch (reader.getLocalName()) {
1✔
172
                            case "array":
173
                                checkNamespace(reader.getNamespaceURI());
1✔
174
                                jsonGenerator.writeStartArray();
1✔
175
                                break;
1✔
176
                            case "map":
177
                                checkNamespace(reader.getNamespaceURI());
1✔
178
                                mapkeyArrayList.add(stackSeparator);
1✔
179
                                jsonGenerator.writeStartObject();
1✔
180
                                break;
1✔
181
                            default:
182
                                break;
183
                        }
184
                        break;
1✔
185
                    case XMLStreamReader.CHARACTERS:
186
                    case XMLStreamReader.CDATA:
187
                        tempStringBuilder.append(reader.getText());
1✔
188
                        break;
1✔
189
                    case XMLStreamReader.END_ELEMENT:
190
                        final String tempString = tempStringBuilder.toString();
1✔
191
                        switch (reader.getLocalName()) {
1✔
192
                            case "array":
193
                                checkNamespace(reader.getNamespaceURI());
1✔
194
                                jsonGenerator.writeEndArray();
1✔
195
                                break;
1✔
196
                            case "boolean":
197
                                final boolean tempBoolean = !(tempString.isEmpty() || "0".equals(tempString) || "false".equals(tempString));
1✔
198
                                jsonGenerator.writeBoolean(tempBoolean);
1✔
199
                                break;
1✔
200
                            case "map":
201
                                checkNamespace(reader.getNamespaceURI());
1✔
202
                                while (!mapkeyArrayList.isEmpty() && mapkeyArrayList.remove(mapkeyArrayList.size() - 1) != stackSeparator) {
1!
203
                                }
204
                                jsonGenerator.writeEndObject();
1✔
205
                                break;
1✔
206
                            case "null":
207
                                if (tempStringBuilder.length() != 0) {
1✔
208
                                    throw new XPathException(this, ErrorCodes.FOJS0006, "Invalid XML representation of JSON. Found non-empty XML null element.");
1✔
209
                                }
210
                                jsonGenerator.writeNull();
1✔
211
                                break;
1✔
212
                            case "number":
213
                                try{
214
                                    final BigDecimal tempDouble = new BigDecimal(tempString);
1✔
215
                                    jsonGenerator.writeNumber(tempDouble);
1✔
216
                                } catch (NumberFormatException ex){
1✔
217
                                    throw new XPathException(this, ErrorCodes.FOJS0006, "Cannot convert '" + tempString + "' to a number.");
1✔
218
                                }
219
                                break;
220
                            case "string":
221
                                if (elementValueIsEscaped == true) {
1✔
222
                                    //TODO: any unescaped occurrence of quotation mark, backspace, form-feed, newline, carriage return, tab, or solidus is replaced by \", \b, \f, \n, \r, \t, or \/ respectively;
223
                                    //TODO: any other codepoint in the range 1-31 or 127-159 is replaced by an escape in the form <backslash>uHHHH where HHHH is the upper-case hexadecimal representation of the codepoint value.
224
                                    jsonGenerator.writeString(unescapeEscapedJsonString(tempString));
1✔
225
                                } else {
1✔
226
                                    //TODO: any other codepoint in the range 1-31 or 127-159 is replaced by an escape in the form <backslash>uHHHH where HHHH is the upper-case hexadecimal representation of the codepoint value.
227
                                    jsonGenerator.writeString(tempString);
1✔
228
                                }
229
                                break;
1✔
230
                            default:
231
                                throw new XPathException(this, ErrorCodes.FOJS0006, "Invalid XML representation of JSON. Found XML element which is not one of [map, array, null, boolean, number, string].");
1✔
232
                        }
233
                    default:
234
                        break;
235
                }
236
            }
237
        } catch (JsonGenerationException e) {
×
238
            throw new XPathException(this, ErrorCodes.FOJS0006, "Invalid XML representation of JSON.");
×
239
        } catch (XMLStreamException | IOException e) {
×
240
            throw new XPathException(this, ErrorCodes.FOER0000, e.getMessage(), e);
×
241
        } finally {
242
            if (reader != null) {
1!
243
                try {
244
                    reader.close();
1✔
245
                } catch (XMLStreamException e) {
1✔
246
                    throw new XPathException(this, ErrorCodes.FOER0000, "XMLStreamException", e);
×
247
                }
248
            }
249
        }
250
    }
1✔
251

252
    /**
253
     * Generate an unescaped JSON string by parsing an escaped JSON string.
254
     *
255
     * @param escapedJsonString the escaped JSON string
256
     * @return the unescaped JSON string
257
     * @throws IOException    in case of an unhandled error reading the JSON
258
     * @throws XPathException in case of dynamic error
259
     */
260
    private String unescapeEscapedJsonString(final String escapedJsonString) throws IOException, XPathException {
261
        final JsonFactory jsonFactory = new JsonFactory();
1✔
262
        final StringBuilder unescapedJsonStringBuilder = new StringBuilder();
1✔
263
        final String unescapedJsonString;
264
        try {
265
            final JsonParser jsonParser = jsonFactory.createParser("\"" + escapedJsonString + "\"");
1✔
266
            while (!jsonParser.isClosed()) {
1✔
267
                jsonParser.nextToken();
1✔
268
                if (jsonParser.hasTextCharacters()) {
1✔
269
                    unescapedJsonStringBuilder.append(jsonParser.getValueAsString());
1✔
270
                }
271
            }
272
        } catch (JsonParseException e) {
1✔
273
            logger.error("fn:xml-to-json(): FOJS0007: Bad JSON escape sequence. XML claims string is escaped. String does not parse as valid JSON string. Offending string in double quotes : \"{}\"", escapedJsonString);
1✔
274
            throw new XPathException(this, ErrorCodes.FOJS0007, "Bad JSON escape sequence. XML claims string is escaped. String does not parse as valid JSON string. Offending string in error logs.");
1✔
275
        }
276
        unescapedJsonString = unescapedJsonStringBuilder.toString();
1✔
277
        return unescapedJsonString;
1✔
278
    }
279

280
    private void checkNamespace(final String namespaceUri) throws XPathException {
281
        if (!Function.BUILTIN_FUNCTION_NS.equals(namespaceUri)) {
1✔
282
            throw new XPathException(this, ErrorCodes.FOJS0006, "Element was in namespace: " + namespaceUri + ", but should have been in namespace: " + Function.BUILTIN_FUNCTION_NS);
1✔
283
        }
284
    }
1✔
285
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc