Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 164 additions & 23 deletions exist-core/src/main/java/org/exist/xquery/functions/fn/JSON.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import io.lacuna.bifurcan.IEntry;
import org.exist.Namespaces;
import org.exist.dom.QName;
import org.exist.dom.memtree.MemTreeBuilder;
Expand All @@ -40,6 +41,7 @@
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.Set;

import static java.nio.file.Files.isReadable;
import static java.nio.file.Files.newInputStream;
Expand Down Expand Up @@ -111,11 +113,27 @@ public class JSON extends BasicFunction {
public static final String OPTION_DUPLICATES_REJECT = "reject";
public static final String OPTION_DUPLICATES_USE_FIRST = "use-first";
public static final String OPTION_DUPLICATES_USE_LAST = "use-last";
public static final String OPTION_DUPLICATES_RETAIN = "retain";
public static final String OPTION_LIBERAL = "liberal";
public static final String OPTION_ESCAPE = "escape";
public static final String OPTION_UNESCAPE = "unescape";
public static final String OPTION_VALIDATE = "validate";
public static final String OPTION_FALLBACK = "fallback";
public static final QName KEY = new QName("key",null);

// Per F&O 3.1 §17.4.1 (fn:parse-json) and §17.5.1 (fn:json-to-xml): the permitted
// 'duplicates' values differ between the two functions. parse-json permits use-last
// (preserving the last value); json-to-xml permits retain (keeping all duplicates in
// the XML output, which is incompatible with validate=true).
private static final Set<String> PERMITTED_DUPLICATES_PARSE_JSON = Set.of(
OPTION_DUPLICATES_REJECT, OPTION_DUPLICATES_USE_FIRST, OPTION_DUPLICATES_USE_LAST);

private static final Set<String> PERMITTED_DUPLICATES_JSON_TO_XML = Set.of(
OPTION_DUPLICATES_REJECT, OPTION_DUPLICATES_USE_FIRST, OPTION_DUPLICATES_RETAIN);

private static final Set<String> JSON_TO_XML_KNOWN_OPTIONS = Set.of(
OPTION_LIBERAL, OPTION_DUPLICATES, OPTION_VALIDATE, OPTION_ESCAPE, OPTION_FALLBACK);

public JSON(XQueryContext context, FunctionSignature signature) {
super(context, signature);
}
Expand All @@ -128,30 +146,12 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
}
// process options if present
// TODO: jackson does not allow access to raw string, so option "unescape" is not supported
boolean liberal = false;
String handleDuplicates = OPTION_DUPLICATES_USE_LAST;
if (getArgumentCount() == 2) {
final MapType options = (MapType)args[1].itemAt(0);
final Sequence liberalOpt = options.get(new StringValue(OPTION_LIBERAL));
if (liberalOpt.hasOne()) {
liberal = liberalOpt.itemAt(0).convertTo(Type.BOOLEAN).effectiveBooleanValue();
}
final Sequence duplicateOpt = options.get(new StringValue(OPTION_DUPLICATES));
if (duplicateOpt.hasOne()) {
handleDuplicates = duplicateOpt.itemAt(0).getStringValue();
}
final Sequence escapeOpt = options.get(new StringValue(OPTION_ESCAPE));
if (escapeOpt.hasOne()) {
try {
escapeOpt.itemAt(0).convertTo(Type.BOOLEAN);
} catch (final XPathException e) {
throw new XPathException(this, ErrorCodes.FOJS0005,
"Value of option 'escape' is not a valid xs:boolean: " + escapeOpt.itemAt(0).getStringValue());
}
}
}
final ParsedOptions opts = getArgumentCount() == 2
? parseOptions((MapType) args[1].itemAt(0), isCalledAs(FS_JSON_TO_XML_NAME))
: ParsedOptions.DEFAULTS;

JsonFactory factory = createJsonFactory(liberal);
JsonFactory factory = createJsonFactory(opts.liberal);
final String handleDuplicates = opts.handleDuplicates;

if (isCalledAs(FS_PARSE_JSON_NAME)) {
return parse(args[0], handleDuplicates, factory);
Expand All @@ -162,6 +162,147 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
}
}

private record ParsedOptions(boolean liberal, String handleDuplicates) {
static final ParsedOptions DEFAULTS = new ParsedOptions(false, OPTION_DUPLICATES_USE_LAST);
}

/**
* Validate and extract options per F&O 3.1 §2.4 (option-parameter conventions) and §17.5.3.
* Wrong-typed values raise XPTY0004; bad permitted values raise FOJS0005.
*/
private ParsedOptions parseOptions(final MapType options, final boolean isJsonToXml) throws XPathException {
final Boolean liberalOpt = requireBooleanOpt(options, OPTION_LIBERAL);
final String duplicatesOpt = requireStringOpt(options, OPTION_DUPLICATES);
final Boolean validateOpt = requireBooleanOpt(options, OPTION_VALIDATE);
requireBooleanOpt(options, OPTION_ESCAPE);
requireFunctionOpt(options, OPTION_FALLBACK, 1);

String handleDuplicates = OPTION_DUPLICATES_USE_LAST;
if (duplicatesOpt != null) {
final Set<String> permitted = isJsonToXml
? PERMITTED_DUPLICATES_JSON_TO_XML
: PERMITTED_DUPLICATES_PARSE_JSON;
if (!permitted.contains(duplicatesOpt)) {
throw new XPathException(this, ErrorCodes.FOJS0005,
"Value of option 'duplicates' is not permitted: " + duplicatesOpt);
}
handleDuplicates = duplicatesOpt;
} else if (isJsonToXml) {
handleDuplicates = Boolean.TRUE.equals(validateOpt)
? OPTION_DUPLICATES_REJECT
: OPTION_DUPLICATES_RETAIN;
}

if (isJsonToXml) {
if (Boolean.TRUE.equals(validateOpt) && OPTION_DUPLICATES_RETAIN.equals(duplicatesOpt)) {
throw new XPathException(this, ErrorCodes.FOJS0005,
"Option 'duplicates' value 'retain' is not permitted when 'validate' is true");
}
rejectUnknownJsonToXmlOptions(options);
}

return new ParsedOptions(Boolean.TRUE.equals(liberalOpt), handleDuplicates);
}

private void rejectUnknownJsonToXmlOptions(final MapType options) throws XPathException {
for (final IEntry<AtomicValue, Sequence> entry : options) {
final String key = entry.key().getStringValue();
if (!JSON_TO_XML_KNOWN_OPTIONS.contains(key)) {
throw new XPathException(this, ErrorCodes.FOJS0005,
"Unknown option key for fn:json-to-xml: " + key);
}
}
}

/**
* Return the value of an option as an xs:boolean, or null if the option is absent.
* Raises XPTY0004 if the value is the wrong type or has cardinality other than 1.
* An xs:untypedAtomic value is cast to xs:boolean per F&O 3.1 §2.4.
*/
private Boolean requireBooleanOpt(final MapType options, final String key) throws XPathException {
final Sequence value = options.get(new StringValue(key));
if (value == null) {
return null;
}
if (value.isEmpty()) {
if (!options.contains(new StringValue(key))) {
return null;
}
// Key present with empty-sequence value: cardinality mismatch.
throw new XPathException(this, ErrorCodes.XPTY0004,
"Option '" + key + "' must be a single value, got empty sequence");
}
if (!value.hasOne()) {
throw new XPathException(this, ErrorCodes.XPTY0004,
"Option '" + key + "' must be a single xs:boolean, got cardinality " + value.getItemCount());
}
final AtomicValue atom = value.itemAt(0).atomize();
final int t = atom.getType();
if (Type.subTypeOf(t, Type.BOOLEAN)) {
return ((BooleanValue) atom).getValue();
}
if (t == Type.UNTYPED_ATOMIC) {
return atom.convertTo(Type.BOOLEAN).effectiveBooleanValue();
}
throw new XPathException(this, ErrorCodes.XPTY0004,
"Option '" + key + "' must be an xs:boolean, got " + Type.getTypeName(t));
}

/**
* Return the value of an option as an xs:string, or null if the option is absent.
* Raises XPTY0004 if the value is the wrong type or has cardinality other than 1.
*/
private String requireStringOpt(final MapType options, final String key) throws XPathException {
final Sequence value = options.get(new StringValue(key));
if (value == null) {
return null;
}
if (value.isEmpty()) {
if (!options.contains(new StringValue(key))) {
return null;
}
// Key present with empty-sequence value: cardinality mismatch.
throw new XPathException(this, ErrorCodes.XPTY0004,
"Option '" + key + "' must be a single value, got empty sequence");
}
if (!value.hasOne()) {
throw new XPathException(this, ErrorCodes.XPTY0004,
"Option '" + key + "' must be a single xs:string, got cardinality " + value.getItemCount());
}
final AtomicValue atom = value.itemAt(0).atomize();
final int t = atom.getType();
if (Type.subTypeOf(t, Type.STRING) || t == Type.UNTYPED_ATOMIC) {
return atom.getStringValue();
}
throw new XPathException(this, ErrorCodes.XPTY0004,
"Option '" + key + "' must be an xs:string, got " + Type.getTypeName(t));
}

/**
* Validate that an option, if present, is a single function reference of the given arity.
* Raises XPTY0004 on type or arity mismatch (or wrong cardinality).
*/
private void requireFunctionOpt(final MapType options, final String key, final int arity) throws XPathException {
final Sequence value = options.get(new StringValue(key));
if (value == null || value.isEmpty()) {
return;
}
if (!value.hasOne()) {
throw new XPathException(this, ErrorCodes.XPTY0004,
"Option '" + key + "' must be a single function, got cardinality " + value.getItemCount());
}
final Item item = value.itemAt(0);
if (!(item instanceof final FunctionReference fr)) {
throw new XPathException(this, ErrorCodes.XPTY0004,
"Option '" + key + "' must be a function, got " + Type.getTypeName(item.getType()));
}
final int actualArity = fr.getSignature().getArgumentCount();
if (actualArity != arity) {
throw new XPathException(this, ErrorCodes.XPTY0004,
"Option '" + key + "' must be a function with arity " + arity + ", got arity " + actualArity);
}
}

/**
* Create and initialize JSON factory.
*
Expand Down
60 changes: 59 additions & 1 deletion exist-core/src/test/xquery/xquery3/json-to-xml.xql
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function jsonxml:json-to-xml-error-1() {


declare
%test:assertError("err:FOJS0005")
%test:assertError("err:XPTY0004")
function jsonxml:json-to-xml-error-2() {
json-to-xml('{"x": "\\", "y": "\u0025"}', map{'escape': 'invalid-value'})
};
Expand All @@ -67,3 +67,61 @@ function jsonxml:json-to-xml-error-2() {




(: F&O 3.1 section 2.4 option-parameter conventions: wrong-typed option value -> XPTY0004. :)

declare
%test:assertError("err:XPTY0004")
function jsonxml:json-to-xml-liberal-wrong-type() {
json-to-xml('[1]', map{'liberal': 'x'})
};

declare
%test:assertError("err:XPTY0004")
function jsonxml:json-to-xml-validate-empty-seq() {
json-to-xml('[1]', map{'validate': ()})
};

declare
%test:assertError("err:XPTY0004")
function jsonxml:json-to-xml-validate-wrong-type() {
json-to-xml('[1]', map{'validate': 'EMCA-262'})
};

declare
%test:assertError("err:XPTY0004")
function jsonxml:json-to-xml-escape-empty-seq() {
json-to-xml('[1]', map{'escape': ()})
};

declare
%test:assertError("err:XPTY0004")
function jsonxml:json-to-xml-fallback-not-function() {
json-to-xml('[1]', map{'fallback': 'dummy'})
};

declare
%test:assertError("err:XPTY0004")
function jsonxml:json-to-xml-fallback-wrong-arity() {
json-to-xml('[1]', map{'fallback': concat#2})
};

(: F&O 3.1 section 17.5.3: permitted-value and consistency checks -> FOJS0005. :)

declare
%test:assertError("err:FOJS0005")
function jsonxml:json-to-xml-duplicates-bad-value() {
json-to-xml('{"a":1,"a":2}', map{'duplicates': 'use-fish'})
};

declare
%test:assertError("err:FOJS0005")
function jsonxml:json-to-xml-validate-retain-incompat() {
json-to-xml('{}', map{'validate': true(), 'duplicates': 'retain'})
};

declare
%test:assertError("err:FOJS0005")
function jsonxml:json-to-xml-unknown-option() {
json-to-xml('[1]', map{'unknown-opt': 'x'})
};
Loading