diff --git a/.gitignore b/.gitignore
index 0a677773713..8ba285c898a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,4 @@ work/
# Claude planning files
plans/
+.xqts-runner/
diff --git a/exist-core/pom.xml b/exist-core/pom.xml
index 0a150cb21c0..c34dce2c684 100644
--- a/exist-core/pom.xml
+++ b/exist-core/pom.xml
@@ -323,6 +323,12 @@
+
+ nu.validator
+ htmlparser
+ 1.4.16
+
+
org.apache.ws.commons.util
ws-commons-util
@@ -390,6 +396,11 @@
Saxon-HE
+
+ de.bottlecaps
+ markup-blitz
+
+
org.exist-db
exist-saxon-regex
diff --git a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g
index 20308296806..399fa264dc6 100644
--- a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g
+++ b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g
@@ -267,14 +267,16 @@ throws PermissionDeniedException, EXistException, XPathException
v:VERSION_DECL
{
final String version = v.getText();
- if (version.equals("3.1")) {
+ if (version.equals("4.0")) {
+ context.setXQueryVersion(40);
+ } else if (version.equals("3.1")) {
context.setXQueryVersion(31);
} else if (version.equals("3.0")) {
context.setXQueryVersion(30);
} else if (version.equals("1.0")) {
context.setXQueryVersion(10);
} else {
- throw new XPathException(v, ErrorCodes.XQST0031, "Wrong XQuery version: require 1.0, 3.0 or 3.1");
+ throw new XPathException(v, ErrorCodes.XQST0031, "Wrong XQuery version: require 1.0, 3.0, 3.1, or 4.0");
}
}
( enc:STRING_LITERAL )?
diff --git a/exist-core/src/main/java/org/exist/util/Collations.java b/exist-core/src/main/java/org/exist/util/Collations.java
index 2d03138a291..ecb09f43cbe 100644
--- a/exist-core/src/main/java/org/exist/util/Collations.java
+++ b/exist-core/src/main/java/org/exist/util/Collations.java
@@ -346,7 +346,24 @@ public static boolean equals(@Nullable final Collator collator, final String s1,
*/
public static int compare(@Nullable final Collator collator, final String s1,final String s2) {
if (collator == null) {
- return s1 == null ? (s2 == null ? 0 : -1) : s1.compareTo(s2);
+ if (s1 == null) {
+ return s2 == null ? 0 : -1;
+ }
+ // Compare by Unicode codepoints, not UTF-16 code units.
+ // String.compareTo() compares char (UTF-16) values, which gives wrong
+ // ordering for supplementary characters (U+10000+) encoded as surrogate pairs.
+ int i1 = 0, i2 = 0;
+ while (i1 < s1.length() && i2 < s2.length()) {
+ final int cp1 = s1.codePointAt(i1);
+ final int cp2 = s2.codePointAt(i2);
+ if (cp1 != cp2) {
+ return cp1 - cp2;
+ }
+ i1 += Character.charCount(cp1);
+ i2 += Character.charCount(cp2);
+ }
+ // Shorter string is less; equal length means equal
+ return (s1.length() - i1) - (s2.length() - i2);
} else {
return collator.compare(s1, s2);
}
@@ -371,10 +388,16 @@ public static boolean startsWith(@Nullable final Collator collator, final String
return true;
} else if (s1.isEmpty()) {
return false;
- } else {
+ } else if (collator instanceof RuleBasedCollator rbc) {
final SearchIterator searchIterator =
- new StringSearch(s2, new StringCharacterIterator(s1), (RuleBasedCollator) collator);
+ new StringSearch(s2, new StringCharacterIterator(s1), rbc);
return searchIterator.first() == 0;
+ } else {
+ // Fallback for non-RuleBasedCollator (e.g., HtmlAsciiCaseInsensitiveCollator)
+ if (s1.length() >= s2.length()) {
+ return collator.compare(s1.substring(0, s2.length()), s2) == 0;
+ }
+ return false;
}
}
}
@@ -398,9 +421,9 @@ public static boolean endsWith(@Nullable final Collator collator, final String s
return true;
} else if (s1.isEmpty()) {
return false;
- } else {
+ } else if (collator instanceof RuleBasedCollator rbc) {
final SearchIterator searchIterator =
- new StringSearch(s2, new StringCharacterIterator(s1), (RuleBasedCollator) collator);
+ new StringSearch(s2, new StringCharacterIterator(s1), rbc);
int lastPos = SearchIterator.DONE;
int lastLen = 0;
for (int pos = searchIterator.first(); pos != SearchIterator.DONE;
@@ -410,6 +433,12 @@ public static boolean endsWith(@Nullable final Collator collator, final String s
}
return lastPos > SearchIterator.DONE && lastPos + lastLen == s1.length();
+ } else {
+ // Fallback for non-RuleBasedCollator
+ if (s1.length() >= s2.length()) {
+ return collator.compare(s1.substring(s1.length() - s2.length()), s2) == 0;
+ }
+ return false;
}
}
}
@@ -433,10 +462,18 @@ public static boolean contains(@Nullable final Collator collator, final String s
return true;
} else if (s1.isEmpty()) {
return false;
- } else {
+ } else if (collator instanceof RuleBasedCollator rbc) {
final SearchIterator searchIterator =
- new StringSearch(s2, new StringCharacterIterator(s1), (RuleBasedCollator) collator);
+ new StringSearch(s2, new StringCharacterIterator(s1), rbc);
return searchIterator.first() >= 0;
+ } else {
+ // Fallback for non-RuleBasedCollator
+ for (int i = 0; i <= s1.length() - s2.length(); i++) {
+ if (collator.compare(s1.substring(i, i + s2.length()), s2) == 0) {
+ return true;
+ }
+ }
+ return false;
}
}
}
@@ -459,10 +496,18 @@ public static int indexOf(@Nullable final Collator collator, final String s1, fi
return 0;
} else if (s1.isEmpty()) {
return -1;
- } else {
+ } else if (collator instanceof RuleBasedCollator rbc) {
final SearchIterator searchIterator =
- new StringSearch(s2, new StringCharacterIterator(s1), (RuleBasedCollator) collator);
+ new StringSearch(s2, new StringCharacterIterator(s1), rbc);
return searchIterator.first();
+ } else {
+ // Fallback for non-RuleBasedCollator
+ for (int i = 0; i <= s1.length() - s2.length(); i++) {
+ if (collator.compare(s1.substring(i, i + s2.length()), s2) == 0) {
+ return i;
+ }
+ }
+ return -1;
}
}
}
@@ -809,21 +854,105 @@ private static Collator getSamiskCollator() throws Exception {
return collator;
}
- private static Collator getHtmlAsciiCaseInsensitiveCollator() throws Exception {
+ private static Collator getHtmlAsciiCaseInsensitiveCollator() {
Collator collator = htmlAsciiCaseInsensitiveCollator.get();
if (collator == null) {
- collator = new RuleBasedCollator("&a=A &b=B &c=C &d=D &e=E &f=F &g=G &h=H "
- + "&i=I &j=J &k=K &l=L &m=M &n=N &o=O &p=P &q=Q &r=R &s=S &t=T "
- + "&u=U &v=V &w=W &x=X &y=Y &z=Z");
- collator.setStrength(Collator.PRIMARY);
+ // XQ4 html-ascii-case-insensitive: ASCII letters A-Z fold to a-z,
+ // all other characters compare by Unicode codepoint order.
+ // Cannot use RuleBasedCollator with PRIMARY strength because that
+ // makes ALL case/accent differences irrelevant, not just ASCII.
htmlAsciiCaseInsensitiveCollator.compareAndSet(null,
- collator.freeze());
+ new HtmlAsciiCaseInsensitiveCollator());
collator = htmlAsciiCaseInsensitiveCollator.get();
}
return collator;
}
+ /**
+ * Custom Collator for HTML ASCII case-insensitive comparison.
+ * Folds only ASCII letters A-Z to a-z, then compares by Unicode codepoint.
+ * Non-ASCII characters are compared by their codepoint value without folding.
+ */
+ private static final class HtmlAsciiCaseInsensitiveCollator extends Collator {
+
+ @Override
+ public int compare(final String source, final String target) {
+ int i1 = 0, i2 = 0;
+ while (i1 < source.length() && i2 < target.length()) {
+ int cp1 = source.codePointAt(i1);
+ int cp2 = target.codePointAt(i2);
+ // Fold ASCII uppercase to lowercase only
+ if (cp1 >= 'A' && cp1 <= 'Z') {
+ cp1 += 32;
+ }
+ if (cp2 >= 'A' && cp2 <= 'Z') {
+ cp2 += 32;
+ }
+ if (cp1 != cp2) {
+ return cp1 - cp2;
+ }
+ i1 += Character.charCount(cp1);
+ i2 += Character.charCount(cp2);
+ }
+ return (source.length() - i1) - (target.length() - i2);
+ }
+
+ @Override
+ public CollationKey getCollationKey(final String source) {
+ throw new UnsupportedOperationException("CollationKey not supported for HTML ASCII case-insensitive collation");
+ }
+
+ @Override
+ public RawCollationKey getRawCollationKey(final String source, final RawCollationKey key) {
+ throw new UnsupportedOperationException("RawCollationKey not supported for HTML ASCII case-insensitive collation");
+ }
+
+ @Override
+ public int setVariableTop(final String varTop) {
+ return 0;
+ }
+
+ @Override
+ public int getVariableTop() {
+ return 0;
+ }
+
+ @Override
+ public void setVariableTop(final int varTop) {
+ }
+
+ @Override
+ public VersionInfo getVersion() {
+ return VersionInfo.getInstance(1);
+ }
+
+ @Override
+ public VersionInfo getUCAVersion() {
+ return VersionInfo.getInstance(1);
+ }
+
+ @Override
+ public int hashCode() {
+ return HtmlAsciiCaseInsensitiveCollator.class.hashCode();
+ }
+
+ @Override
+ public Collator freeze() {
+ return this;
+ }
+
+ @Override
+ public boolean isFrozen() {
+ return true;
+ }
+
+ @Override
+ public Collator cloneAsThawed() {
+ return new HtmlAsciiCaseInsensitiveCollator();
+ }
+ }
+
private static Collator getXqtsAsciiCaseBlindCollator() throws Exception {
Collator collator = xqtsAsciiCaseBlindCollator.get();
if (collator == null) {
diff --git a/exist-core/src/main/java/org/exist/util/serializer/XQuerySerializer.java b/exist-core/src/main/java/org/exist/util/serializer/XQuerySerializer.java
index 366e3866cbc..acfb8d16de6 100644
--- a/exist-core/src/main/java/org/exist/util/serializer/XQuerySerializer.java
+++ b/exist-core/src/main/java/org/exist/util/serializer/XQuerySerializer.java
@@ -103,12 +103,25 @@ private void serializeXML(final Sequence sequence, final int start, final int ho
}
private void serializeJSON(final Sequence sequence, final long compilationTime, final long executionTime) throws SAXException, XPathException {
- // backwards compatibility: if the sequence contains a single element, we assume
- // it should be transformed to JSON following the rules of the old JSON writer
- if (sequence.hasOne() && (Type.subTypeOf(sequence.getItemType(), Type.DOCUMENT) || Type.subTypeOf(sequence.getItemType(), Type.ELEMENT))) {
+ // XDM serialization: use JSONSerializer for maps and arrays (W3C JSON output method).
+ // For element/document nodes, use the legacy XML-to-JSON conversion path for
+ // backward compatibility with eXist's traditional JSON serialization.
+ // TODO (eXist 8.0): Remove legacy XML-to-JSON conversion.
+ // The legacy path is deprecated in 7.0 — use fn:serialize($map, map{"method":"json"}) instead.
+ final boolean isXdmMapOrArray = sequence.hasOne()
+ && (sequence.getItemType() == Type.MAP_ITEM || sequence.getItemType() == Type.ARRAY_ITEM);
+
+ if (isXdmMapOrArray || (!sequence.hasOne())
+ || Type.subTypeOfUnion(sequence.getItemType(), Type.ANY_ATOMIC_TYPE)) {
+ // Maps, arrays, sequences, and atomic values: use W3C JSONSerializer
+ final JSONSerializer serializer = new JSONSerializer(broker, outputProperties);
+ serializer.serialize(sequence, writer);
+ } else if (sequence.hasOne()
+ && (Type.subTypeOf(sequence.getItemType(), Type.DOCUMENT) || Type.subTypeOf(sequence.getItemType(), Type.ELEMENT))) {
+ // Legacy path: single element/document → XML-to-JSON conversion
serializeXML(sequence, 1, 1, false, false, compilationTime, executionTime);
} else {
- JSONSerializer serializer = new JSONSerializer(broker, outputProperties);
+ final JSONSerializer serializer = new JSONSerializer(broker, outputProperties);
serializer.serialize(sequence, writer);
}
}
diff --git a/exist-core/src/main/java/org/exist/util/serializer/json/JSONSerializer.java b/exist-core/src/main/java/org/exist/util/serializer/json/JSONSerializer.java
index bd1f01a9454..7728633368a 100644
--- a/exist-core/src/main/java/org/exist/util/serializer/json/JSONSerializer.java
+++ b/exist-core/src/main/java/org/exist/util/serializer/json/JSONSerializer.java
@@ -64,7 +64,9 @@ public void serialize(Sequence sequence, Writer writer) throws SAXException {
if ("yes".equals(outputProperties.getProperty(OutputKeys.INDENT, "no"))) {
generator.useDefaultPrettyPrinter();
}
- if ("yes".equals(outputProperties.getProperty(EXistOutputKeys.ALLOW_DUPLICATE_NAMES, "yes"))) {
+ // allow-duplicate-names=no (default per W3C) → enable strict detection
+ // allow-duplicate-names=yes → disable strict detection (allow duplicates)
+ if ("no".equals(outputProperties.getProperty(EXistOutputKeys.ALLOW_DUPLICATE_NAMES, "no"))) {
generator.enable(JsonGenerator.Feature.STRICT_DUPLICATE_DETECTION);
} else {
generator.disable(JsonGenerator.Feature.STRICT_DUPLICATE_DETECTION);
diff --git a/exist-core/src/main/java/org/exist/xquery/AttributeConstructor.java b/exist-core/src/main/java/org/exist/xquery/AttributeConstructor.java
index bb1720e67d9..2dcbfe4652c 100644
--- a/exist-core/src/main/java/org/exist/xquery/AttributeConstructor.java
+++ b/exist-core/src/main/java/org/exist/xquery/AttributeConstructor.java
@@ -56,7 +56,7 @@ public void addValue(String value) {
public void addEnclosedExpr(Expression expr) throws XPathException {
if(isNamespaceDecl)
- {throw new XPathException(this, "enclosed expressions are not allowed in namespace " +
+ {throw new XPathException(this, ErrorCodes.XQST0022, "enclosed expressions are not allowed in namespace " +
"declaration attributes");}
contents.add(expr);
}
diff --git a/exist-core/src/main/java/org/exist/xquery/CastExpression.java b/exist-core/src/main/java/org/exist/xquery/CastExpression.java
index 8911c5c6144..a2453eaaddc 100644
--- a/exist-core/src/main/java/org/exist/xquery/CastExpression.java
+++ b/exist-core/src/main/java/org/exist/xquery/CastExpression.java
@@ -84,12 +84,13 @@ public Sequence eval(final Sequence contextSequence, final Item contextItem) thr
}
}
- // Should be handled by the parser
- if (requiredType == Type.ANY_ATOMIC_TYPE || (requiredType == Type.NOTATION && expression.returnsType() != Type.NOTATION)) {
+ // XPST0080: cannot cast to abstract or special types
+ if (requiredType == Type.ANY_ATOMIC_TYPE || requiredType == Type.ANY_SIMPLE_TYPE
+ || (requiredType == Type.NOTATION && expression.returnsType() != Type.NOTATION)) {
throw new XPathException(this, ErrorCodes.XPST0080, "cannot cast to " + Type.getTypeName(requiredType));
}
- if (requiredType == Type.ANY_SIMPLE_TYPE || expression.returnsType() == Type.ANY_SIMPLE_TYPE || requiredType == Type.UNTYPED || expression.returnsType() == Type.UNTYPED) {
+ if (expression.returnsType() == Type.ANY_SIMPLE_TYPE || requiredType == Type.UNTYPED || expression.returnsType() == Type.UNTYPED) {
throw new XPathException(this, ErrorCodes.XPST0051, "cannot cast to " + Type.getTypeName(requiredType));
}
diff --git a/exist-core/src/main/java/org/exist/xquery/CastableExpression.java b/exist-core/src/main/java/org/exist/xquery/CastableExpression.java
index 9a0769f9653..e923da9fe08 100644
--- a/exist-core/src/main/java/org/exist/xquery/CastableExpression.java
+++ b/exist-core/src/main/java/org/exist/xquery/CastableExpression.java
@@ -93,10 +93,11 @@ public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathExc
{context.getProfiler().message(this, Profiler.START_SEQUENCES, "CONTEXT ITEM", contextItem.toSequence());}
}
- if (requiredType == Type.ANY_ATOMIC_TYPE || (requiredType == Type.NOTATION && expression.returnsType() != Type.NOTATION))
+ if (requiredType == Type.ANY_ATOMIC_TYPE || requiredType == Type.ANY_SIMPLE_TYPE
+ || (requiredType == Type.NOTATION && expression.returnsType() != Type.NOTATION))
{throw new XPathException(this, ErrorCodes.XPST0080, "cannot convert to " + Type.getTypeName(requiredType));}
- if (requiredType == Type.ANY_SIMPLE_TYPE || expression.returnsType() == Type.ANY_SIMPLE_TYPE || requiredType == Type.UNTYPED || expression.returnsType() == Type.UNTYPED)
+ if (expression.returnsType() == Type.ANY_SIMPLE_TYPE || requiredType == Type.UNTYPED || expression.returnsType() == Type.UNTYPED)
{throw new XPathException(this, ErrorCodes.XPST0051, "cannot convert to " + Type.getTypeName(requiredType));}
Sequence result;
diff --git a/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java b/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java
index 46a54962ad5..93c4d4b1fff 100644
--- a/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java
+++ b/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java
@@ -47,7 +47,7 @@ public class DecimalFormat {
);
- // used both in the picture string, and in the formatted number
+ // Markers: used in the picture string to identify active elements
public final int decimalSeparator;
public final int exponentSeparator;
public final int groupingSeparator;
@@ -55,18 +55,38 @@ public class DecimalFormat {
public final int perMille;
public final int zeroDigit;
- // used in the picture string
+ // used in the picture string only
public final int digit;
public final int patternSeparator;
- //used in the result of formatting the number, but not in the picture string
+ // used in the result of formatting the number, but not in the picture string
public final String infinity;
public final String NaN;
public final int minusSign;
+ // XQ4 renditions: output strings for properties that support char:rendition.
+ // When marker != rendition, the marker is used for picture parsing and the
+ // rendition string appears in the formatted output.
+ public final String decimalSeparatorRendition;
+ public final String exponentSeparatorRendition;
+ public final String groupingSeparatorRendition;
+ public final String percentRendition;
+ public final String perMilleRendition;
+
public DecimalFormat(final int decimalSeparator, final int exponentSeparator, final int groupingSeparator,
final int percent, final int perMille, final int zeroDigit, final int digit,
final int patternSeparator, final String infinity, final String NaN, final int minusSign) {
+ this(decimalSeparator, exponentSeparator, groupingSeparator, percent, perMille,
+ zeroDigit, digit, patternSeparator, infinity, NaN, minusSign,
+ null, null, null, null, null);
+ }
+
+ public DecimalFormat(final int decimalSeparator, final int exponentSeparator, final int groupingSeparator,
+ final int percent, final int perMille, final int zeroDigit, final int digit,
+ final int patternSeparator, final String infinity, final String NaN, final int minusSign,
+ final String decimalSeparatorRendition, final String exponentSeparatorRendition,
+ final String groupingSeparatorRendition, final String percentRendition,
+ final String perMilleRendition) {
this.decimalSeparator = decimalSeparator;
this.exponentSeparator = exponentSeparator;
this.groupingSeparator = groupingSeparator;
@@ -78,5 +98,11 @@ public DecimalFormat(final int decimalSeparator, final int exponentSeparator, fi
this.infinity = infinity;
this.NaN = NaN;
this.minusSign = minusSign;
+ // Renditions default to the marker character as a string
+ this.decimalSeparatorRendition = decimalSeparatorRendition != null ? decimalSeparatorRendition : new String(Character.toChars(decimalSeparator));
+ this.exponentSeparatorRendition = exponentSeparatorRendition != null ? exponentSeparatorRendition : new String(Character.toChars(exponentSeparator));
+ this.groupingSeparatorRendition = groupingSeparatorRendition != null ? groupingSeparatorRendition : new String(Character.toChars(groupingSeparator));
+ this.percentRendition = percentRendition != null ? percentRendition : new String(Character.toChars(percent));
+ this.perMilleRendition = perMilleRendition != null ? perMilleRendition : new String(Character.toChars(perMille));
}
}
diff --git a/exist-core/src/main/java/org/exist/xquery/DocumentConstructor.java b/exist-core/src/main/java/org/exist/xquery/DocumentConstructor.java
index 3495fed460f..a67eaee3544 100644
--- a/exist-core/src/main/java/org/exist/xquery/DocumentConstructor.java
+++ b/exist-core/src/main/java/org/exist/xquery/DocumentConstructor.java
@@ -90,7 +90,7 @@ public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathExc
while(next != null) {
context.proceed(this, builder);
if (next.getType() == Type.ATTRIBUTE || next.getType() == Type.NAMESPACE)
- {throw new XPathException(this, "Found a node of type " +
+ {throw new XPathException(this, ErrorCodes.XPTY0004, "Found a node of type " +
Type.getTypeName(next.getType()) + " inside a document constructor");}
// if item is an atomic value, collect the string values of all
// following atomic values and seperate them by a space.
diff --git a/exist-core/src/main/java/org/exist/xquery/DynamicAttributeConstructor.java b/exist-core/src/main/java/org/exist/xquery/DynamicAttributeConstructor.java
index 168c2da95a6..583a4afc9ef 100644
--- a/exist-core/src/main/java/org/exist/xquery/DynamicAttributeConstructor.java
+++ b/exist-core/src/main/java/org/exist/xquery/DynamicAttributeConstructor.java
@@ -99,7 +99,7 @@ public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathExc
final Sequence nameSeq = qnameExpr.eval(contextSequence, contextItem);
if(!nameSeq.hasOne())
- {throw new XPathException(this, "The name expression should evaluate to a single value");}
+ {throw new XPathException(this, ErrorCodes.XPTY0004, "The name expression should evaluate to a single value");}
final Item qnItem = nameSeq.itemAt(0);
QName qn;
diff --git a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java
index 23226a155f2..4205d01484f 100644
--- a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java
+++ b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java
@@ -176,6 +176,9 @@ public class ErrorCodes {
public static final ErrorCode FORX0002 = new W3CErrorCode("FORX0002", "Invalid regular expression.");
public static final ErrorCode FORX0003 = new W3CErrorCode("FORX0003", "Regular expression matches zero-length string.");
public static final ErrorCode FORX0004 = new W3CErrorCode("FORX0004", "Invalid replacement string.");
+ public static final ErrorCode FOCV0001 = new W3CErrorCode("FOCV0001", "CSV quote error.");
+ public static final ErrorCode FOCV0002 = new W3CErrorCode("FOCV0002", "Invalid CSV delimiter.");
+ public static final ErrorCode FOCV0003 = new W3CErrorCode("FOCV0003", "Conflicting CSV delimiters.");
public static final ErrorCode FOTY0012 = new W3CErrorCode("FOTY0012", "Argument node does not have a typed value.");
public static final ErrorCode FOTY0013 = new W3CErrorCode("FOTY0013", "The argument to fn:data() contains a function item.");
@@ -211,6 +214,7 @@ public class ErrorCodes {
public static final ErrorCode FTDY0020 = new W3CErrorCode("FTDY0020", "");
public static final ErrorCode FODC0006 = new W3CErrorCode("FODC0006", "String passed to fn:parse-xml is not a well-formed XML document.");
+ public static final ErrorCode FODC0011 = new W3CErrorCode("FODC0011", "HTML parsing error.");
public static final ErrorCode FOAP0001 = new W3CErrorCode("FOAP0001", "Wrong number of arguments");
@@ -241,6 +245,10 @@ public class ErrorCodes {
public static final ErrorCode FOXT0004 = new W3CErrorCode("FOXT0004", "XSLT transformation has been disabled");
public static final ErrorCode FOXT0006 = new W3CErrorCode("FOXT0006", "XSLT output contains non-accepted characters");
+ // Invisible XML errors
+ public static final ErrorCode FOIX0001 = new W3CErrorCode("FOIX0001", "Invalid ixml grammar");
+ public static final ErrorCode FOIX0002 = new W3CErrorCode("FOIX0002", "ixml parse error");
+
public static final ErrorCode XTSE0165 = new W3CErrorCode("XTSE0165","It is a static error if the processor is not able to retrieve the resource identified by the URI reference [ in the href attribute of xsl:include or xsl:import] , or if the resource that is retrieved does not contain a stylesheet module conforming to this specification.");
/* eXist specific XQuery and XPath errors
diff --git a/exist-core/src/main/java/org/exist/xquery/FunctionFactory.java b/exist-core/src/main/java/org/exist/xquery/FunctionFactory.java
index adcf7d3d5cb..a1602539964 100644
--- a/exist-core/src/main/java/org/exist/xquery/FunctionFactory.java
+++ b/exist-core/src/main/java/org/exist/xquery/FunctionFactory.java
@@ -245,7 +245,14 @@ private static CastExpression castExpression(XQueryContext context,
ErrorCodes.XPST0017, "Wrong number of arguments for constructor function");
}
final Expression arg = params.getFirst();
- final int code = Type.getType(qname);
+ final int code;
+ try {
+ code = Type.getType(qname);
+ } catch (final XPathException e) {
+ // Unknown type name in xs: namespace → XPST0017 (no such function)
+ throw new XPathException(ast.getLine(), ast.getColumn(),
+ ErrorCodes.XPST0017, "Unknown constructor function: " + qname.getStringValue());
+ }
final CastExpression castExpr = new CastExpression(context, arg, code, Cardinality.ZERO_OR_ONE);
castExpr.setLocation(ast.getLine(), ast.getColumn());
return castExpr;
diff --git a/exist-core/src/main/java/org/exist/xquery/RangeSequence.java b/exist-core/src/main/java/org/exist/xquery/RangeSequence.java
index c23c663067e..eb3ecfa6507 100644
--- a/exist-core/src/main/java/org/exist/xquery/RangeSequence.java
+++ b/exist-core/src/main/java/org/exist/xquery/RangeSequence.java
@@ -21,8 +21,6 @@
*/
package org.exist.xquery;
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
import org.exist.dom.persistent.NodeSet;
import org.exist.xquery.value.AbstractSequence;
import org.exist.xquery.value.IntegerValue;
@@ -32,18 +30,40 @@
import org.exist.xquery.value.SequenceIterator;
import org.exist.xquery.value.Type;
-import java.math.BigInteger;
-
+/**
+ * An immutable, lazy sequence representing an integer range (start to end).
+ * Stores only the start and end values as primitive longs — no intermediate
+ * IntegerValue objects are created until accessed. Operations like count(),
+ * isEmpty(), itemAt(), and subsequence() are O(1).
+ */
public class RangeSequence extends AbstractSequence {
- private final static Logger LOG = LogManager.getLogger(AbstractSequence.class);
-
- private final IntegerValue start;
- private final IntegerValue end;
+ private final long start;
+ private final long end;
+ private final long size;
public RangeSequence(final IntegerValue start, final IntegerValue end) {
+ this(start.getLong(), end.getLong());
+ }
+
+ public RangeSequence(final long start, final long end) {
this.start = start;
this.end = end;
+ if (start <= end) {
+ final long diff = end - start;
+ // Overflow protection: if diff < 0, the range is too large
+ this.size = (diff >= 0) ? diff + 1 : Long.MAX_VALUE;
+ } else {
+ this.size = 0;
+ }
+ }
+
+ public long getStart() {
+ return start;
+ }
+
+ public long getEnd() {
+ return end;
}
@Override
@@ -62,16 +82,16 @@ public int getItemType() {
@Override
public SequenceIterator iterate() {
- return new RangeSequenceIterator(start.getLong(), end.getLong());
+ return new RangeSequenceIterator(start, end);
}
@Override
public SequenceIterator unorderedIterator() {
- return new RangeSequenceIterator(start.getLong(), end.getLong());
+ return new RangeSequenceIterator(start, end);
}
public SequenceIterator iterateInReverse() {
- return new ReverseRangeSequenceIterator(start.getLong(), end.getLong());
+ return new ReverseRangeSequenceIterator(start, end);
}
private static class RangeSequenceIterator implements SequenceIterator {
@@ -148,39 +168,30 @@ public long skip(final long n) {
@Override
public long getItemCountLong() {
- if (start.compareTo(end) > 0) {
- return 0;
- }
- try {
- return ((IntegerValue) end.minus(start)).getLong() + 1;
- } catch (final XPathException e) {
- LOG.warn("Unexpected exception when processing result of range expression: {}", e.getMessage(), e);
- return 0;
- }
+ return size;
}
@Override
public boolean isEmpty() {
- return getItemCountLong() == 0;
+ return size == 0;
}
@Override
public boolean hasOne() {
- return getItemCountLong() == 1;
+ return size == 1;
}
@Override
public boolean hasMany() {
- return getItemCountLong() > 1;
+ return size > 1;
}
@Override
public Cardinality getCardinality() {
- final long itemCount = getItemCountLong();
- if (itemCount <= 0) {
+ if (size == 0) {
return Cardinality.EMPTY_SEQUENCE;
}
- if (itemCount == 1) {
+ if (size == 1) {
return Cardinality.EXACTLY_ONE;
}
return Cardinality._MANY;
@@ -188,12 +199,26 @@ public Cardinality getCardinality() {
@Override
public Item itemAt(final int pos) {
- if (pos < getItemCountLong()) {
- return new IntegerValue(start.getLong() + pos);
+ if (pos >= 0 && pos < size) {
+ return new IntegerValue(start + pos);
}
return null;
}
+ @Override
+ public boolean contains(final Item item) {
+ if (item instanceof IntegerValue) {
+ final long val = ((IntegerValue) item).getLong();
+ return val >= start && val <= end;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean containsReference(final Item item) {
+ return false; // primitives don't have reference identity
+ }
+
@Override
public NodeSet toNodeSet() throws XPathException {
throw new XPathException(this, "Type error: the sequence cannot be converted into" +
@@ -211,37 +236,7 @@ public void removeDuplicates() {
}
@Override
- public boolean containsReference(final Item item) {
- return start == item || end == item;
- }
-
- @Override
- public boolean contains(final Item item) {
- if (item instanceof IntegerValue) {
- try {
- final BigInteger other = item.toJavaObject(BigInteger.class);
- return other.compareTo(start.toJavaObject(BigInteger.class)) >= 0
- && other.compareTo(end.toJavaObject(BigInteger.class)) <= 0;
- } catch (final XPathException e) {
- LOG.warn(e.getMessage(), e);
- return false;
- }
- }
- return false;
+ public String toString() {
+ return "Range(" + start + " to " + end + ")";
}
-
- /**
- * Generates a string representation of the Range Sequence.
- *
- * Range sequences can potentially be
- * very large, so we generate a summary here
- * rather than evaluating to generate a (possibly)
- * huge sequence of objects.
- *
- * @return a string representation of the range sequence.
- */
- @Override
- public String toString() {
- return "Range(" + start + " to " + end + ")";
- }
}
diff --git a/exist-core/src/main/java/org/exist/xquery/XQueryContext.java b/exist-core/src/main/java/org/exist/xquery/XQueryContext.java
index b3721c34179..4153e3cf5da 100644
--- a/exist-core/src/main/java/org/exist/xquery/XQueryContext.java
+++ b/exist-core/src/main/java/org/exist/xquery/XQueryContext.java
@@ -2730,6 +2730,13 @@ private ExternalModule compileOrBorrowModule(final String namespaceURI, final St
* @return The compiled module, or null if the source is not a module
* @throws XPathException if the module could not be loaded (XQST0059) or compiled (XPST0003)
*/
+ /**
+ * Compile a module from a Source. Public wrapper for fn:load-xquery-module content option.
+ */
+ public @Nullable ExternalModule compileModuleFromSource(final String namespaceURI, final Source source) throws XPathException {
+ return compileModule(namespaceURI, null, "content", source);
+ }
+
private @Nullable ExternalModule compileModule(String namespaceURI, final String prefix, final String location,
final Source source) throws XPathException {
if (LOG.isDebugEnabled()) {
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayBuild.java b/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayBuild.java
new file mode 100644
index 00000000000..bcf73834e61
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayBuild.java
@@ -0,0 +1,87 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.array;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.value.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * array:build($seq, $fn?) — Build array from sequence with optional mapping function.
+ */
+public class ArrayBuild extends BasicFunction {
+
+ private AnalyzeContextInfo cachedContextInfo;
+
+ public static final FunctionSignature[] signatures = {
+ new FunctionSignature(
+ new QName("build", ArrayModule.NAMESPACE_URI, ArrayModule.PREFIX),
+ "Builds an array from the items of a sequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "The resulting array")),
+ new FunctionSignature(
+ new QName("build", ArrayModule.NAMESPACE_URI, ArrayModule.PREFIX),
+ "Builds an array by applying a function to each item of a sequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("action", Type.FUNCTION, Cardinality.EXACTLY_ONE, "The function to apply")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "The resulting array"))
+ };
+
+ public ArrayBuild(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public void analyze(AnalyzeContextInfo contextInfo) throws XPathException {
+ cachedContextInfo = new AnalyzeContextInfo(contextInfo);
+ super.analyze(contextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence input = args[0];
+ final List members = new ArrayList<>();
+
+ if (getArgumentCount() == 2) {
+ try (final FunctionReference fn = (FunctionReference) args[1].itemAt(0)) {
+ fn.analyze(cachedContextInfo);
+ for (final SequenceIterator i = input.iterate(); i.hasNext(); ) {
+ final Item item = i.nextItem();
+ members.add(fn.evalFunction(null, null, new Sequence[]{item.toSequence()}));
+ }
+ }
+ } else {
+ for (final SequenceIterator i = input.iterate(); i.hasNext(); ) {
+ members.add(i.nextItem().toSequence());
+ }
+ }
+
+ return new ArrayType(context, members);
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayFunction.java b/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayFunction.java
index ae46633a144..0559e1e473d 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayFunction.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayFunction.java
@@ -32,8 +32,10 @@
import org.exist.xquery.XPathException;
import org.exist.xquery.XQueryContext;
import org.exist.xquery.functions.fn.FunData;
+import org.exist.xquery.value.BooleanValue;
import org.exist.xquery.value.FunctionParameterSequenceType;
import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.StringValue;
import org.exist.xquery.value.FunctionReturnSequenceType;
import org.exist.xquery.value.IntegerValue;
import org.exist.xquery.value.Sequence;
@@ -276,6 +278,33 @@ public class ArrayFunction extends BasicFunction {
)
);
+ // --- XQuery 4.0 array functions ---
+ public static final FunctionSignature ARRAY_EMPTY = functionSignature(
+ Fn.EMPTY.fname, "Returns true if the supplied array is empty.",
+ returns(Type.BOOLEAN, "true if the array is empty"),
+ INPUT_ARRAY
+ );
+ public static final FunctionSignature ARRAY_FOOT = functionSignature(
+ Fn.FOOT.fname, "Returns the last member of an array.",
+ returns(Type.ITEM, Cardinality.ZERO_OR_MORE, "The last member"),
+ INPUT_ARRAY
+ );
+ public static final FunctionSignature ARRAY_TRUNK = functionSignature(
+ Fn.TRUNK.fname, "Returns all members except the last.",
+ RESULT_ARRAY,
+ INPUT_ARRAY
+ );
+ public static final FunctionSignature ARRAY_ITEMS = functionSignature(
+ Fn.ITEMS.fname, "Returns the members of an array as a sequence.",
+ returns(Type.ITEM, Cardinality.ZERO_OR_MORE, "The members as a sequence"),
+ INPUT_ARRAY
+ );
+ public static final FunctionSignature ARRAY_MEMBERS = functionSignature(
+ Fn.MEMBERS.fname, "Returns each member as a map with a 'value' key.",
+ returns(Type.MAP_ITEM, Cardinality.ZERO_OR_MORE, "Sequence of member maps"),
+ INPUT_ARRAY
+ );
+
private AnalyzeContextInfo cachedContextInfo;
public ArrayFunction(XQueryContext context, FunctionSignature signature) {
@@ -314,6 +343,11 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
case FOR_EACH_PAIR -> forEachPair(args);
case SORT -> sort(args);
case FLATTEN -> flatten(args);
+ case EMPTY -> arrayEmpty(args);
+ case FOOT -> foot(args);
+ case TRUNK -> trunk(args);
+ case ITEMS -> items(args);
+ case MEMBERS -> members(args);
};
}
@@ -493,6 +527,53 @@ private Sequence getFunction(Sequence arg, FunctionE fnMap = new HashMap<>();
private final String fname;
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayIndexOf.java b/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayIndexOf.java
new file mode 100644
index 00000000000..c57c93532cf
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayIndexOf.java
@@ -0,0 +1,63 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.array;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.functions.fn.FunDeepEqual;
+import org.exist.xquery.value.*;
+
+/**
+ * array:index-of($array, $target) — Returns positions of matching members.
+ */
+public class ArrayIndexOf extends BasicFunction {
+
+ public static final FunctionSignature[] signatures = {
+ new FunctionSignature(
+ new QName("index-of", ArrayModule.NAMESPACE_URI, ArrayModule.PREFIX),
+ "Returns the positions of members that are deep-equal to the target.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("array", Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "The array to search"),
+ new FunctionParameterSequenceType("target", Type.ITEM, Cardinality.ZERO_OR_MORE, "The value to search for")
+ },
+ new FunctionReturnSequenceType(Type.INTEGER, Cardinality.ZERO_OR_MORE, "The 1-based positions"))
+ };
+
+ public ArrayIndexOf(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final ArrayType array = (ArrayType) args[0].itemAt(0);
+ final Sequence target = args[1];
+ final ValueSequence result = new ValueSequence();
+
+ for (int i = 0; i < array.getSize(); i++) {
+ final Sequence member = array.get(i);
+ if (FunDeepEqual.deepEqualsSeq(member, target, null)) {
+ result.add(new IntegerValue(this, i + 1));
+ }
+ }
+ return result;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayIndexWhere.java b/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayIndexWhere.java
new file mode 100644
index 00000000000..78d3b359b12
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayIndexWhere.java
@@ -0,0 +1,105 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.array;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.BooleanValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+/**
+ * Implements array:index-where (XQuery 4.0).
+ *
+ * Returns the positions in an input array of members that match a supplied
+ * predicate function, as a sequence of integers in ascending order.
+ */
+public class ArrayIndexWhere extends BasicFunction {
+
+ public static final FunctionSignature[] signatures = {
+ new FunctionSignature(
+ new QName("index-where", ArrayModule.NAMESPACE_URI, ArrayModule.PREFIX),
+ "Returns positions of array members matching the predicate.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("array", Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "The input array"),
+ new FunctionParameterSequenceType("predicate", Type.FUNCTION, Cardinality.EXACTLY_ONE,
+ "The predicate function")
+ },
+ new FunctionReturnSequenceType(Type.INTEGER, Cardinality.ZERO_OR_MORE,
+ "positions of matching members"))
+ };
+
+ public ArrayIndexWhere(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final ArrayType array = (ArrayType) args[0].itemAt(0);
+ final int size = array.getSize();
+ if (size == 0) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ try (final FunctionReference func = (FunctionReference) args[1].itemAt(0)) {
+ func.analyze(cachedContextInfo);
+
+ final int arity = func.getSignature().getArgumentCount();
+ final ValueSequence result = new ValueSequence();
+
+ for (int i = 0; i < size; i++) {
+ final Sequence member = array.get(i);
+ final Sequence[] funcArgs;
+ if (arity >= 2) {
+ funcArgs = new Sequence[] { member, new IntegerValue(this, i + 1) };
+ } else {
+ funcArgs = new Sequence[] { member };
+ }
+
+ final Sequence predResult = func.evalFunction(null, null, funcArgs);
+ if (!predResult.isEmpty() && predResult.effectiveBooleanValue()) {
+ result.add(new IntegerValue(this, i + 1));
+ }
+ }
+ return result;
+ }
+ }
+
+ private org.exist.xquery.AnalyzeContextInfo cachedContextInfo =
+ new org.exist.xquery.AnalyzeContextInfo();
+
+ @Override
+ public void analyze(org.exist.xquery.AnalyzeContextInfo contextInfo) throws XPathException {
+ cachedContextInfo = new org.exist.xquery.AnalyzeContextInfo(contextInfo);
+ super.analyze(contextInfo);
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayModule.java b/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayModule.java
index a9eec0d3db9..f86dffdae10 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayModule.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayModule.java
@@ -43,28 +43,43 @@ public class ArrayModule extends AbstractInternalModule {
public static final String PREFIX = "array";
private static final FunctionDef[] functions = functionDefs(
- ArrayFunction.class,
- ArrayFunction.SIZE,
- ArrayFunction.GET,
- ArrayFunction.PUT,
- ArrayFunction.APPEND,
- ArrayFunction.SUBARRAY_1,
- ArrayFunction.SUBARRAY_2,
- ArrayFunction.REMOVE,
- ArrayFunction.INSERT_BEFORE,
- ArrayFunction.HEAD,
- ArrayFunction.TAIL,
- ArrayFunction.REVERSE,
- ArrayFunction.JOIN,
- ArrayFunction.FOR_EACH,
- ArrayFunction.FILTER,
- ArrayFunction.FOLD_LEFT,
- ArrayFunction.FOLD_RIGHT,
- ArrayFunction.FOR_EACH_PAIR,
- ArrayFunction.SORT_1,
- ArrayFunction.SORT_2,
- ArrayFunction.SORT_3,
- ArrayFunction.FLATTEN
+ functionDefs(ArrayFunction.class,
+ ArrayFunction.SIZE,
+ ArrayFunction.GET,
+ ArrayFunction.PUT,
+ ArrayFunction.APPEND,
+ ArrayFunction.SUBARRAY_1,
+ ArrayFunction.SUBARRAY_2,
+ ArrayFunction.REMOVE,
+ ArrayFunction.INSERT_BEFORE,
+ ArrayFunction.HEAD,
+ ArrayFunction.TAIL,
+ ArrayFunction.REVERSE,
+ ArrayFunction.JOIN,
+ ArrayFunction.FOR_EACH,
+ ArrayFunction.FILTER,
+ ArrayFunction.FOLD_LEFT,
+ ArrayFunction.FOLD_RIGHT,
+ ArrayFunction.FOR_EACH_PAIR,
+ ArrayFunction.SORT_1,
+ ArrayFunction.SORT_2,
+ ArrayFunction.SORT_3,
+ ArrayFunction.FLATTEN,
+ // --- XQuery 4.0 ---
+ ArrayFunction.ARRAY_EMPTY,
+ ArrayFunction.ARRAY_FOOT,
+ ArrayFunction.ARRAY_TRUNK,
+ ArrayFunction.ARRAY_ITEMS,
+ ArrayFunction.ARRAY_MEMBERS
+ ),
+ functionDefs(ArraySlice.class, ArraySlice.signatures),
+ functionDefs(ArrayIndexWhere.class, ArrayIndexWhere.signatures),
+ functionDefs(ArraySortWith.class, ArraySortWith.signatures),
+ functionDefs(ArraySortBy.class, ArraySortBy.signatures),
+ functionDefs(ArrayBuild.class, ArrayBuild.signatures),
+ functionDefs(ArrayIndexOf.class, ArrayIndexOf.signatures),
+ functionDefs(ArrayOfMembers.class, ArrayOfMembers.signatures),
+ functionDefs(ArraySplit.class, ArraySplit.signatures)
);
public ArrayModule(Map> parameters) {
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayOfMembers.java b/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayOfMembers.java
new file mode 100644
index 00000000000..7e0ef9d7cab
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/array/ArrayOfMembers.java
@@ -0,0 +1,62 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.array;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.functions.map.AbstractMapType;
+import org.exist.xquery.value.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * array:of-members($input as map(xs:string, item()*)*) — Construct array from member maps.
+ * Inverse of array:members.
+ */
+public class ArrayOfMembers extends BasicFunction {
+
+ public static final FunctionSignature[] signatures = {
+ new FunctionSignature(
+ new QName("of-members", ArrayModule.NAMESPACE_URI, ArrayModule.PREFIX),
+ "Constructs an array from a sequence of member maps (each with a 'value' key).",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.MAP_ITEM, Cardinality.ZERO_OR_MORE, "The member maps")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "The resulting array"))
+ };
+
+ public ArrayOfMembers(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final List members = new ArrayList<>();
+ for (final SequenceIterator i = args[0].iterate(); i.hasNext(); ) {
+ final AbstractMapType map = (AbstractMapType) i.nextItem();
+ final Sequence value = map.get(new StringValue("value"));
+ members.add(value != null ? value : Sequence.EMPTY_SEQUENCE);
+ }
+ return new ArrayType(context, members);
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/array/ArraySlice.java b/exist-core/src/main/java/org/exist/xquery/functions/array/ArraySlice.java
new file mode 100644
index 00000000000..e5037030e97
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/array/ArraySlice.java
@@ -0,0 +1,145 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.array;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implements array:slice (XQuery 4.0).
+ *
+ * Returns an array containing selected members of a supplied input array
+ * based on their position. Supports negative indexing and step values
+ * (Python-style slicing with 1-based indexing).
+ */
+public class ArraySlice extends BasicFunction {
+
+ public static final FunctionSignature[] signatures = {
+ new FunctionSignature(
+ new QName("slice", ArrayModule.NAMESPACE_URI, ArrayModule.PREFIX),
+ "Returns an array containing selected members based on position.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("array", Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "The input array")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "the sliced array")),
+ new FunctionSignature(
+ new QName("slice", ArrayModule.NAMESPACE_URI, ArrayModule.PREFIX),
+ "Returns an array containing selected members based on position.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("array", Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "The input array"),
+ new FunctionParameterSequenceType("start", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The start position")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "the sliced array")),
+ new FunctionSignature(
+ new QName("slice", ArrayModule.NAMESPACE_URI, ArrayModule.PREFIX),
+ "Returns an array containing selected members based on position.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("array", Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "The input array"),
+ new FunctionParameterSequenceType("start", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The start position"),
+ new FunctionParameterSequenceType("end", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The end position")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "the sliced array")),
+ new FunctionSignature(
+ new QName("slice", ArrayModule.NAMESPACE_URI, ArrayModule.PREFIX),
+ "Returns an array containing selected members based on position.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("array", Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "The input array"),
+ new FunctionParameterSequenceType("start", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The start position"),
+ new FunctionParameterSequenceType("end", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The end position"),
+ new FunctionParameterSequenceType("step", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The step value")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "the sliced array"))
+ };
+
+ public ArraySlice(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final ArrayType array = (ArrayType) args[0].itemAt(0);
+ final int count = array.getSize();
+
+ if (count == 0) {
+ return new ArrayType(this, context, new ArrayList<>());
+ }
+
+ // Resolve start
+ int s;
+ if (args.length < 2 || args[1].isEmpty() || ((IntegerValue) args[1].itemAt(0)).getLong() == 0) {
+ s = 1;
+ } else {
+ final long sv = ((IntegerValue) args[1].itemAt(0)).getLong();
+ s = (int) (sv < 0 ? count + sv + 1 : sv);
+ }
+
+ // Resolve end
+ int e;
+ if (args.length < 3 || args[2].isEmpty() || ((IntegerValue) args[2].itemAt(0)).getLong() == 0) {
+ e = count;
+ } else {
+ final long ev = ((IntegerValue) args[2].itemAt(0)).getLong();
+ e = (int) (ev < 0 ? count + ev + 1 : ev);
+ }
+
+ // Resolve step
+ int step;
+ if (args.length < 4 || args[3].isEmpty() || ((IntegerValue) args[3].itemAt(0)).getLong() == 0) {
+ step = (e >= s) ? 1 : -1;
+ } else {
+ step = (int) ((IntegerValue) args[3].itemAt(0)).getLong();
+ }
+
+ // Handle negative step: reverse array and recurse with negated positions
+ if (step < 0) {
+ final ArrayType reversed = array.reverse();
+ final Sequence[] newArgs = new Sequence[4];
+ newArgs[0] = reversed;
+ newArgs[1] = new IntegerValue(this, -s);
+ newArgs[2] = new IntegerValue(this, -e);
+ newArgs[3] = new IntegerValue(this, -step);
+ return eval(newArgs, contextSequence);
+ }
+
+ // Positive step: select members
+ final List result = new ArrayList<>();
+ for (int pos = s; pos <= e && pos <= count; pos += step) {
+ if (pos >= 1) {
+ result.add(array.get(pos - 1));
+ }
+ }
+ return new ArrayType(this, context, result);
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/array/ArraySortBy.java b/exist-core/src/main/java/org/exist/xquery/functions/array/ArraySortBy.java
new file mode 100644
index 00000000000..bf16e1d9f6a
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/array/ArraySortBy.java
@@ -0,0 +1,215 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.array;
+
+import com.ibm.icu.text.Collator;
+import org.exist.dom.QName;
+import org.exist.xquery.AnalyzeContextInfo;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.functions.fn.FunCompare;
+import org.exist.xquery.functions.fn.FunData;
+import org.exist.xquery.functions.map.AbstractMapType;
+import org.exist.xquery.value.AtomicValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.NumericValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceIterator;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+import org.exist.xquery.NamedFunctionReference;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implements array:sort-by (XQuery 4.0).
+ *
+ * Sorts a supplied array based on the value of sort keys supplied as
+ * record (map) specifications with optional key, collation, and order fields.
+ */
+public class ArraySortBy extends BasicFunction {
+
+ public static final FunctionSignature[] signatures = {
+ new FunctionSignature(
+ new QName("sort-by", ArrayModule.NAMESPACE_URI, ArrayModule.PREFIX),
+ "Sorts the array based on sort key specifications.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("array", Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "The input array"),
+ new FunctionParameterSequenceType("keys", Type.MAP_ITEM, Cardinality.ZERO_OR_MORE,
+ "Sort key records with optional key, collation, and order fields")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "the sorted array"))
+ };
+
+ private AnalyzeContextInfo cachedContextInfo = new AnalyzeContextInfo();
+
+ public ArraySortBy(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ cachedContextInfo = new AnalyzeContextInfo(contextInfo);
+ super.analyze(cachedContextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final ArrayType array = (ArrayType) args[0].itemAt(0);
+ final int size = array.getSize();
+ if (size <= 1) {
+ return array;
+ }
+
+ final Sequence keys = args[1];
+
+ // Parse sort key specifications
+ final List sortKeys = new ArrayList<>();
+ if (keys.isEmpty()) {
+ final SortKey defaultKey = new SortKey();
+ defaultKey.collator = context.getDefaultCollator();
+ sortKeys.add(defaultKey);
+ } else {
+ for (final SequenceIterator ki = keys.iterate(); ki.hasNext(); ) {
+ final AbstractMapType keyMap = (AbstractMapType) ki.nextItem();
+ sortKeys.add(parseSortKey(keyMap));
+ }
+ }
+
+ // Pre-compute sort keys for each member
+ final Sequence[][] keyValues = new Sequence[size][sortKeys.size()];
+ for (int idx = 0; idx < size; idx++) {
+ final Sequence member = array.get(idx);
+ for (int k = 0; k < sortKeys.size(); k++) {
+ final SortKey sk = sortKeys.get(k);
+ if (sk.keyFunction != null) {
+ keyValues[idx][k] = sk.keyFunction.evalFunction(null, null,
+ new Sequence[]{member});
+ } else {
+ // Default: atomize members
+ final ValueSequence atomized = new ValueSequence();
+ for (final SequenceIterator mi = member.iterate(); mi.hasNext(); ) {
+ atomized.add(mi.nextItem().atomize());
+ }
+ keyValues[idx][k] = atomized;
+ }
+ }
+ }
+
+ // Build index array for stable sort
+ final Integer[] indices = new Integer[size];
+ for (int i = 0; i < indices.length; i++) {
+ indices[i] = i;
+ }
+
+ try {
+ java.util.Arrays.sort(indices, (a, b) -> {
+ try {
+ for (int k = 0; k < sortKeys.size(); k++) {
+ final SortKey sk = sortKeys.get(k);
+ final int cmp = compareKeys(keyValues[a][k], keyValues[b][k], sk.collator);
+ if (cmp != 0) {
+ return sk.descending ? -cmp : cmp;
+ }
+ }
+ return 0;
+ } catch (final XPathException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ } catch (final RuntimeException e) {
+ if (e.getCause() instanceof XPathException) {
+ throw (XPathException) e.getCause();
+ }
+ throw e;
+ }
+
+ // Build result array
+ final List resultMembers = new ArrayList<>(size);
+ for (final int idx : indices) {
+ resultMembers.add(array.get(idx));
+ }
+ return new ArrayType(this, context, resultMembers);
+ }
+
+ private int compareKeys(final Sequence a, final Sequence b, final Collator collator) throws XPathException {
+ final boolean emptyA = a.isEmpty();
+ final boolean emptyB = b.isEmpty();
+ if (emptyA && emptyB) return 0;
+ if (emptyA) return -1;
+ if (emptyB) return 1;
+
+ final int len = Math.min(a.getItemCount(), b.getItemCount());
+ for (int i = 0; i < len; i++) {
+ final AtomicValue va = a.itemAt(i).atomize();
+ final AtomicValue vb = b.itemAt(i).atomize();
+ final int cmp = FunCompare.compare(va, vb, collator);
+ if (cmp != 0) return cmp;
+ }
+ return Integer.compare(a.getItemCount(), b.getItemCount());
+ }
+
+ private SortKey parseSortKey(final AbstractMapType map) throws XPathException {
+ final SortKey sk = new SortKey();
+
+ final Sequence keySeq = map.get(new StringValue(this, "key"));
+ if (keySeq != null && !keySeq.isEmpty()) {
+ final Item keyItem = keySeq.itemAt(0);
+ if (!(keyItem instanceof FunctionReference)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Expected function reference for 'key', got " + Type.getTypeName(keyItem.getType()));
+ }
+ sk.keyFunction = (FunctionReference) keyItem;
+ sk.keyFunction.analyze(cachedContextInfo);
+ }
+
+ final Sequence collSeq = map.get(new StringValue(this, "collation"));
+ if (collSeq != null && !collSeq.isEmpty()) {
+ sk.collator = context.getCollator(collSeq.getStringValue(), ErrorCodes.FOCH0002);
+ } else {
+ sk.collator = context.getDefaultCollator();
+ }
+
+ final Sequence orderSeq = map.get(new StringValue(this, "order"));
+ if (orderSeq != null && !orderSeq.isEmpty()) {
+ sk.descending = "descending".equals(orderSeq.getStringValue());
+ }
+
+ return sk;
+ }
+
+ private static class SortKey {
+ FunctionReference keyFunction;
+ Collator collator;
+ boolean descending;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/array/ArraySortWith.java b/exist-core/src/main/java/org/exist/xquery/functions/array/ArraySortWith.java
new file mode 100644
index 00000000000..06f48b5fd44
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/array/ArraySortWith.java
@@ -0,0 +1,144 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.array;
+
+import org.exist.dom.QName;
+import org.exist.xquery.AnalyzeContextInfo;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceIterator;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implements array:sort-with (XQuery 4.0).
+ *
+ * Sorts a supplied array according to the order induced by one or more
+ * supplied comparator functions. Sort is stable.
+ */
+public class ArraySortWith extends BasicFunction {
+
+ public static final FunctionSignature[] signatures = {
+ new FunctionSignature(
+ new QName("sort-with", ArrayModule.NAMESPACE_URI, ArrayModule.PREFIX),
+ "Sorts the array using the supplied comparator function(s).",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("array", Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "The input array"),
+ new FunctionParameterSequenceType("comparators", Type.FUNCTION, Cardinality.ONE_OR_MORE,
+ "One or more comparator functions (fn(item()*, item()*) as xs:integer)")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "the sorted array"))
+ };
+
+ private AnalyzeContextInfo cachedContextInfo = new AnalyzeContextInfo();
+
+ public ArraySortWith(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public void analyze(AnalyzeContextInfo contextInfo) throws XPathException {
+ cachedContextInfo = new AnalyzeContextInfo(contextInfo);
+ super.analyze(contextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final ArrayType array = (ArrayType) args[0].itemAt(0);
+ final int size = array.getSize();
+ if (size <= 1) {
+ return array;
+ }
+
+ // Collect comparator functions
+ final Sequence comparatorsSeq = args[1];
+ final List comparators = new ArrayList<>(comparatorsSeq.getItemCount());
+ for (final SequenceIterator it = comparatorsSeq.iterate(); it.hasNext(); ) {
+ final FunctionReference ref = (FunctionReference) it.nextItem();
+ ref.analyze(cachedContextInfo);
+ comparators.add(ref);
+ }
+
+ // Build list of (index, member) to sort
+ final List members = new ArrayList<>(size);
+ for (int i = 0; i < size; i++) {
+ members.add(new IndexedMember(i, array.get(i)));
+ }
+
+ // Stable sort using comparator chain
+ try {
+ members.sort((a, b) -> {
+ try {
+ for (final FunctionReference comp : comparators) {
+ final Sequence[] funcArgs = new Sequence[] { a.value, b.value };
+ final Sequence result = comp.evalFunction(null, null, funcArgs);
+ if (result.isEmpty()) {
+ continue;
+ }
+ final long cmp = ((IntegerValue) result.itemAt(0).convertTo(Type.INTEGER)).getLong();
+ if (cmp != 0) {
+ return cmp < 0 ? -1 : 1;
+ }
+ }
+ return 0;
+ } catch (final XPathException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ } catch (final RuntimeException e) {
+ if (e.getCause() instanceof XPathException) {
+ throw (XPathException) e.getCause();
+ }
+ throw e;
+ }
+
+ // Build result array
+ final List resultMembers = new ArrayList<>(size);
+ for (final IndexedMember m : members) {
+ resultMembers.add(m.value);
+ }
+
+ return new ArrayType(this, context, resultMembers);
+ }
+
+ private static class IndexedMember {
+ final int index;
+ final Sequence value;
+
+ IndexedMember(int index, Sequence value) {
+ this.index = index;
+ this.value = value;
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/array/ArraySplit.java b/exist-core/src/main/java/org/exist/xquery/functions/array/ArraySplit.java
new file mode 100644
index 00000000000..25d231d64d7
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/array/ArraySplit.java
@@ -0,0 +1,58 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.array;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.value.*;
+
+import java.util.Collections;
+
+/**
+ * array:split($array) — Split array into sequence of single-member arrays.
+ */
+public class ArraySplit extends BasicFunction {
+
+ public static final FunctionSignature[] signatures = {
+ new FunctionSignature(
+ new QName("split", ArrayModule.NAMESPACE_URI, ArrayModule.PREFIX),
+ "Splits an array into a sequence of single-member arrays.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("array", Type.ARRAY_ITEM, Cardinality.EXACTLY_ONE, "The array to split")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.ZERO_OR_MORE, "A sequence of single-member arrays"))
+ };
+
+ public ArraySplit(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final ArrayType array = (ArrayType) args[0].itemAt(0);
+ final ValueSequence result = new ValueSequence(array.getSize());
+ for (int i = 0; i < array.getSize(); i++) {
+ result.add(new ArrayType(context, Collections.singletonList(array.get(i))));
+ }
+ return result;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/CsvFunctions.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/CsvFunctions.java
new file mode 100644
index 00000000000..ea4a275ba56
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/CsvFunctions.java
@@ -0,0 +1,619 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.apache.commons.io.IOUtils;
+import org.exist.dom.QName;
+import org.exist.dom.memtree.MemTreeBuilder;
+import org.exist.security.PermissionDeniedException;
+import org.exist.source.FileSource;
+import org.exist.source.Source;
+import org.exist.source.SourceFactory;
+import org.exist.xquery.*;
+import org.exist.xquery.functions.array.ArrayType;
+import io.lacuna.bifurcan.IEntry;
+import org.exist.xquery.functions.map.AbstractMapType;
+import org.exist.xquery.functions.map.MapType;
+import org.exist.xquery.value.*;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringWriter;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implements XQuery 4.0 CSV functions:
+ * fn:csv-to-arrays, fn:parse-csv, fn:csv-to-xml, fn:csv-doc.
+ */
+public class CsvFunctions extends BasicFunction {
+
+ // XQ4 namespace for CSV XML output
+ private static final String CSV_NS = "http://www.w3.org/2005/xpath-functions";
+
+ // fn:csv-to-arrays signatures
+ public static final FunctionSignature[] FN_CSV_TO_ARRAYS = {
+ new FunctionSignature(
+ new QName("csv-to-arrays", Function.BUILTIN_FUNCTION_NS),
+ "Parses a string as CSV data and returns the result as a sequence of arrays.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("csv", Type.STRING, Cardinality.ZERO_OR_ONE, "The CSV string to parse")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.ZERO_OR_MORE, "A sequence of arrays, one per row")),
+ new FunctionSignature(
+ new QName("csv-to-arrays", Function.BUILTIN_FUNCTION_NS),
+ "Parses a string as CSV data and returns the result as a sequence of arrays, using the specified options.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("csv", Type.STRING, Cardinality.ZERO_OR_ONE, "The CSV string to parse"),
+ new FunctionParameterSequenceType("options", Type.MAP_ITEM, Cardinality.EXACTLY_ONE, "Parsing options")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.ZERO_OR_MORE, "A sequence of arrays, one per row"))
+ };
+
+ // fn:parse-csv signatures
+ public static final FunctionSignature[] FN_PARSE_CSV = {
+ new FunctionSignature(
+ new QName("parse-csv", Function.BUILTIN_FUNCTION_NS),
+ "Parses a string as CSV data and returns the result as a map.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("csv", Type.STRING, Cardinality.ZERO_OR_ONE, "The CSV string to parse")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.ZERO_OR_ONE, "A map with columns, column-index, rows, and get")),
+ new FunctionSignature(
+ new QName("parse-csv", Function.BUILTIN_FUNCTION_NS),
+ "Parses a string as CSV data and returns the result as a map, using the specified options.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("csv", Type.STRING, Cardinality.ZERO_OR_ONE, "The CSV string to parse"),
+ new FunctionParameterSequenceType("options", Type.MAP_ITEM, Cardinality.EXACTLY_ONE, "Parsing options")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.ZERO_OR_ONE, "A map with columns, column-index, rows, and get"))
+ };
+
+ // fn:csv-to-xml signatures
+ public static final FunctionSignature[] FN_CSV_TO_XML = {
+ new FunctionSignature(
+ new QName("csv-to-xml", Function.BUILTIN_FUNCTION_NS),
+ "Parses a string as CSV data and returns the result as an XML document.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("csv", Type.STRING, Cardinality.ZERO_OR_ONE, "The CSV string to parse")
+ },
+ new FunctionReturnSequenceType(Type.DOCUMENT, Cardinality.ZERO_OR_ONE, "An XML document representing the CSV data")),
+ new FunctionSignature(
+ new QName("csv-to-xml", Function.BUILTIN_FUNCTION_NS),
+ "Parses a string as CSV data and returns the result as an XML document, using the specified options.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("csv", Type.STRING, Cardinality.ZERO_OR_ONE, "The CSV string to parse"),
+ new FunctionParameterSequenceType("options", Type.MAP_ITEM, Cardinality.EXACTLY_ONE, "Parsing options")
+ },
+ new FunctionReturnSequenceType(Type.DOCUMENT, Cardinality.ZERO_OR_ONE, "An XML document representing the CSV data"))
+ };
+
+ // fn:csv-doc signatures
+ public static final FunctionSignature[] FN_CSV_DOC = {
+ new FunctionSignature(
+ new QName("csv-doc", Function.BUILTIN_FUNCTION_NS),
+ "Reads CSV data from the specified URI and returns the result as a map.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("uri", Type.STRING, Cardinality.ZERO_OR_ONE, "The URI of the CSV resource")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.ZERO_OR_ONE, "A map with columns, column-index, rows, and get")),
+ new FunctionSignature(
+ new QName("csv-doc", Function.BUILTIN_FUNCTION_NS),
+ "Reads CSV data from the specified URI and returns the result as a map, using the specified options.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("uri", Type.STRING, Cardinality.ZERO_OR_ONE, "The URI of the CSV resource"),
+ new FunctionParameterSequenceType("options", Type.MAP_ITEM, Cardinality.EXACTLY_ONE, "Parsing options")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.ZERO_OR_ONE, "A map with columns, column-index, rows, and get"))
+ };
+
+ public CsvFunctions(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (isCalledAs("csv-doc")) {
+ return evalCsvDoc(args);
+ }
+
+ // Empty sequence input returns empty
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final String csv = args[0].getStringValue();
+ final CsvParser.CsvOptions options = parseOptions(args);
+
+ if (isCalledAs("csv-to-arrays")) {
+ return evalCsvToArrays(csv, options);
+ } else if (isCalledAs("parse-csv")) {
+ return evalParseCsv(csv, options);
+ } else if (isCalledAs("csv-to-xml")) {
+ return evalCsvToXml(csv, options);
+ }
+ throw new XPathException(this, ErrorCodes.XPST0017, "Unknown CSV function: " + getSignature().getName().getLocalPart());
+ }
+
+ // ==================== fn:csv-to-arrays ====================
+
+ private Sequence evalCsvToArrays(final String csv, final CsvParser.CsvOptions options) throws XPathException {
+ options.validate(this);
+ final CsvParser parser = new CsvParser(options, this);
+ final ValueSequence result = new ValueSequence();
+
+ parser.parse(csv, new CsvParser.CsvConverter() {
+ @Override
+ public void header(final List fields) {
+ // Header row is also returned as an array in csv-to-arrays
+ // (per XQ4 spec: "If header is true, the first row is treated as a header
+ // but still appears in the output")
+ // Actually per spec: if header=true, the header row is NOT included
+ // in the result of csv-to-arrays.
+ }
+
+ @Override
+ public void record(final List fields) throws XPathException {
+ result.add(fieldsToArray(fields));
+ }
+
+ @Override
+ public void finish() {
+ }
+ });
+ return result;
+ }
+
+ // ==================== fn:parse-csv ====================
+
+ private Sequence evalParseCsv(final String csv, final CsvParser.CsvOptions options) throws XPathException {
+ options.validate(this);
+ final CsvParser parser = new CsvParser(options, this);
+ final List> allRows = new ArrayList<>();
+ final List[] headerHolder = new List[]{null};
+
+ parser.parse(csv, new CsvParser.CsvConverter() {
+ @Override
+ public void header(final List fields) {
+ headerHolder[0] = fields;
+ }
+
+ @Override
+ public void record(final List fields) {
+ allRows.add(fields);
+ }
+
+ @Override
+ public void finish() {
+ }
+ });
+
+ // Explicit header from options overrides parsed header
+ final List effectiveHeader = options.explicitHeader != null
+ ? options.explicitHeader : headerHolder[0];
+
+ return buildParseCsvResult(effectiveHeader, allRows, options);
+ }
+
+ private Sequence buildParseCsvResult(final List header, final List> rows,
+ final CsvParser.CsvOptions options) throws XPathException {
+ final MapType result = new MapType(this, context);
+
+ // "columns" - sequence of column names (empty sequence if no header)
+ final Sequence columns;
+ if (header != null) {
+ final ValueSequence colSeq = new ValueSequence(header.size());
+ for (final String h : header) {
+ colSeq.add(new StringValue(this, h));
+ }
+ columns = colSeq;
+ } else {
+ columns = Sequence.EMPTY_SEQUENCE;
+ }
+
+ // "column-index" - map from column name to 1-based position
+ // Empty names are excluded; duplicate names map to first occurrence
+ final MapType columnIndex = new MapType(this, context);
+ MapType colIdxResult = columnIndex;
+ if (header != null) {
+ final java.util.Set seen = new java.util.HashSet<>();
+ for (int i = 0; i < header.size(); i++) {
+ final String name = header.get(i);
+ if (!name.isEmpty() && seen.add(name)) {
+ colIdxResult = (MapType) colIdxResult.put(new StringValue(this, name),
+ new IntegerValue(this, i + 1));
+ }
+ }
+ }
+
+ // "rows" - sequence of arrays
+ final ValueSequence rowSeq = new ValueSequence(rows.size());
+ for (final List row : rows) {
+ rowSeq.add(fieldsToArray(row));
+ }
+
+ // Build the result map
+ MapType map = (MapType) result.put(new StringValue(this, "columns"), columns);
+ map = (MapType) map.put(new StringValue(this, "column-index"), colIdxResult);
+ map = (MapType) map.put(new StringValue(this, "rows"), rowSeq);
+
+ // "get" - accessor function: fn($row as xs:integer, $column as item()) as xs:string
+ // $column can be an integer (1-based) or a string (column name)
+ final UserDefinedFunction getFunc = new UserDefinedFunction(context,
+ new FunctionSignature(
+ new QName("get", Function.BUILTIN_FUNCTION_NS),
+ null,
+ new SequenceType[]{
+ new FunctionParameterSequenceType("row", Type.INTEGER, Cardinality.EXACTLY_ONE, "Row number (1-based)"),
+ new FunctionParameterSequenceType("column", Type.ITEM, Cardinality.EXACTLY_ONE, "Column number (1-based) or column name")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_ONE, "The field value")));
+ getFunc.addVariable("row");
+ getFunc.addVariable("column");
+ getFunc.setFunctionBody(new CsvGetExpression(context, rowSeq, header));
+ final FunctionCall getCall = new FunctionCall(context, getFunc);
+ getCall.setLocation(getLine(), getColumn());
+ final FunctionReference getFuncRef = new FunctionReference(this, getCall);
+ map = (MapType) map.put(new StringValue(this, "get"), getFuncRef);
+
+ return map;
+ }
+
+ // ==================== fn:csv-to-xml ====================
+
+ private Sequence evalCsvToXml(final String csv, final CsvParser.CsvOptions options) throws XPathException {
+ options.validate(this);
+ final CsvParser parser = new CsvParser(options, this);
+
+ final List[] headerHolder = new List[]{null};
+ final List> allRecords = new ArrayList<>();
+
+ parser.parse(csv, new CsvParser.CsvConverter() {
+ @Override
+ public void header(final List fields) {
+ headerHolder[0] = fields;
+ }
+
+ @Override
+ public void record(final List fields) {
+ allRecords.add(fields);
+ }
+
+ @Override
+ public void finish() {
+ }
+ });
+
+ // Explicit header from options overrides parsed header
+ final List effectiveHeader = options.explicitHeader != null
+ ? options.explicitHeader : headerHolder[0];
+
+ context.pushDocumentContext();
+ try {
+ final MemTreeBuilder builder = context.getDocumentBuilder();
+
+ builder.startElement(new QName("csv", CSV_NS), null);
+
+ // Write columns element only if headers are present
+ if (effectiveHeader != null) {
+ builder.startElement(new QName("columns", CSV_NS), null);
+ for (final String col : effectiveHeader) {
+ builder.startElement(new QName("column", CSV_NS), null);
+ builder.characters(col);
+ builder.endElement();
+ }
+ builder.endElement(); //
+ }
+
+ // Write rows
+ builder.startElement(new QName("rows", CSV_NS), null);
+ for (final List record : allRecords) {
+ builder.startElement(new QName("row", CSV_NS), null);
+ // A row with a single empty field is an empty row (no field elements)
+ final boolean isEmptyRow = record.size() == 1 && record.get(0).isEmpty();
+ if (!isEmptyRow) {
+ for (int f = 0; f < record.size(); f++) {
+ final String field = record.get(f);
+ builder.startElement(new QName("field", CSV_NS), null);
+ if (effectiveHeader != null && f < effectiveHeader.size()
+ && !effectiveHeader.get(f).isEmpty()) {
+ builder.addAttribute(new QName("column", null, null), effectiveHeader.get(f));
+ }
+ if (!field.isEmpty()) {
+ builder.characters(field);
+ }
+ builder.endElement();
+ }
+ }
+ builder.endElement(); //
+ }
+ builder.endElement(); //
+
+ builder.endElement(); //
+
+ return builder.getDocument();
+ } finally {
+ context.popDocumentContext();
+ }
+ }
+
+ // ==================== fn:csv-doc ====================
+
+ private Sequence evalCsvDoc(final Sequence[] args) throws XPathException {
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ final String uri = args[0].getStringValue();
+
+ // Read the CSV content from the URI (same approach as fn:unparsed-text)
+ final String csvContent;
+ try {
+ final URI parsedUri = new URI(uri);
+ if (parsedUri.getFragment() != null) {
+ throw new XPathException(this, ErrorCodes.FODC0005,
+ "URI may not contain a fragment identifier: " + uri);
+ }
+ final Source source = SourceFactory.getSource(context.getBroker(), "", parsedUri.toASCIIString(), false);
+ if (source == null) {
+ throw new XPathException(this, ErrorCodes.FODC0002,
+ "Could not find CSV resource: " + uri);
+ }
+ if (source instanceof FileSource && !context.getBroker().getCurrentSubject().hasDbaRole()) {
+ throw new PermissionDeniedException("non-dba user not allowed to read from file system");
+ }
+ final StringWriter output = new StringWriter();
+ try (final InputStream is = source.getInputStream()) {
+ IOUtils.copy(is, output, StandardCharsets.UTF_8);
+ }
+ csvContent = output.toString();
+ } catch (final IOException | PermissionDeniedException | URISyntaxException e) {
+ throw new XPathException(this, ErrorCodes.FODC0002,
+ "Error reading CSV resource: " + uri + " - " + e.getMessage());
+ }
+
+ final CsvParser.CsvOptions options = parseOptions(args);
+ return evalParseCsv(csvContent, options);
+ }
+
+ // ==================== Shared utilities ====================
+
+ private CsvParser.CsvOptions parseOptions(final Sequence[] args) throws XPathException {
+ final CsvParser.CsvOptions options = new CsvParser.CsvOptions();
+ if (args.length < 2 || args[1].isEmpty()) {
+ return options;
+ }
+
+ final AbstractMapType map = (AbstractMapType) args[1].itemAt(0);
+
+ // field-delimiter
+ final Sequence fdSeq = map.get(new StringValue(this, "field-delimiter"));
+ if (fdSeq != null && !fdSeq.isEmpty()) {
+ final String fd = fdSeq.getStringValue();
+ if (fd.isEmpty()) {
+ throw new XPathException(this, ErrorCodes.FOCV0002,
+ "field-delimiter must be a single character");
+ }
+ if (fd.codePointCount(0, fd.length()) != 1) {
+ throw new XPathException(this, ErrorCodes.FOCV0002,
+ "field-delimiter must be a single character, got: \"" + fd + "\"");
+ }
+ options.fieldDelimiter = fd.codePointAt(0);
+ }
+
+ // row-delimiter
+ final Sequence rdSeq = map.get(new StringValue(this, "row-delimiter"));
+ if (rdSeq != null && !rdSeq.isEmpty()) {
+ if (rdSeq.getItemCount() != 1) {
+ throw new XPathException(this, ErrorCodes.FOCV0002,
+ "row-delimiter must be a single string, got " + rdSeq.getItemCount() + " items");
+ }
+ final String rd = rdSeq.itemAt(0).getStringValue();
+ if (rd.isEmpty() || rd.codePointCount(0, rd.length()) != 1) {
+ throw new XPathException(this, ErrorCodes.FOCV0002,
+ "row-delimiter must be a single character");
+ }
+ options.rowDelimiter = rd.codePointAt(0);
+ }
+
+ // quote-character
+ final Sequence qcSeq = map.get(new StringValue(this, "quote-character"));
+ if (qcSeq != null && !qcSeq.isEmpty()) {
+ final String qc = qcSeq.getStringValue();
+ if (qc.isEmpty()) {
+ options.quoteChar = -1; // disable quoting
+ } else if (qc.codePointCount(0, qc.length()) != 1) {
+ throw new XPathException(this, ErrorCodes.FOCV0002,
+ "quote-character must be a single character or empty string");
+ } else {
+ options.quoteChar = qc.codePointAt(0);
+ }
+ }
+
+ // trim-whitespace
+ final Sequence twSeq = map.get(new StringValue(this, "trim-whitespace"));
+ if (twSeq != null && !twSeq.isEmpty()) {
+ options.trimWhitespace = twSeq.effectiveBooleanValue();
+ }
+
+ // header: boolean, "present", or sequence of explicit column names
+ final Sequence hdrSeq = map.get(new StringValue(this, "header"));
+ if (hdrSeq != null && !hdrSeq.isEmpty()) {
+ final Item hdrItem = hdrSeq.itemAt(0);
+ if (hdrItem.getType() == Type.BOOLEAN) {
+ options.hasHeader = hdrItem.toSequence().effectiveBooleanValue();
+ } else if (hdrSeq.getItemCount() == 1) {
+ final String hdrStr = hdrItem.getStringValue();
+ if ("true".equals(hdrStr) || "present".equals(hdrStr)) {
+ options.hasHeader = true;
+ } else if ("false".equals(hdrStr) || "absent".equals(hdrStr)) {
+ options.hasHeader = false;
+ } else {
+ // Single string → explicit column name
+ options.explicitHeader = new ArrayList<>();
+ options.explicitHeader.add(hdrStr);
+ options.hasHeader = false; // don't consume first data row
+ }
+ } else {
+ // Multiple items → sequence of explicit column names
+ options.explicitHeader = new ArrayList<>(hdrSeq.getItemCount());
+ for (int j = 0; j < hdrSeq.getItemCount(); j++) {
+ options.explicitHeader.add(hdrSeq.itemAt(j).getStringValue());
+ }
+ options.hasHeader = false; // don't consume first data row
+ }
+ }
+
+ // select-columns
+ final Sequence scSeq = map.get(new StringValue(this, "select-columns"));
+ if (scSeq != null && !scSeq.isEmpty()) {
+ final int count = scSeq.getItemCount();
+ options.selectColumns = new int[count];
+ for (int j = 0; j < count; j++) {
+ final int col = ((IntegerValue) scSeq.itemAt(j).convertTo(Type.INTEGER)).getInt();
+ if (col < 1) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "select-columns values must be positive integers, got: " + col);
+ }
+ options.selectColumns[j] = col;
+ }
+ }
+
+ // trim-rows
+ final Sequence trSeq = map.get(new StringValue(this, "trim-rows"));
+ if (trSeq != null && !trSeq.isEmpty()) {
+ options.trimRows = trSeq.effectiveBooleanValue();
+ }
+
+ // Validate no unknown option keys
+ final java.util.Set knownKeys = java.util.Set.of(
+ "field-delimiter", "row-delimiter", "quote-character",
+ "trim-whitespace", "header", "select-columns", "trim-rows");
+ for (final IEntry entry : map) {
+ final String key = entry.key().getStringValue();
+ if (!knownKeys.contains(key)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Unknown CSV option: '" + key + "'");
+ }
+ }
+
+ return options;
+ }
+
+ private ArrayType fieldsToArray(final List fields) throws XPathException {
+ // XQ4 spec: a row with a single empty field produces an empty array
+ if (fields.size() == 1 && fields.get(0).isEmpty()) {
+ return new ArrayType(this, context, new ArrayList<>());
+ }
+ final List items = new ArrayList<>(fields.size());
+ for (final String field : fields) {
+ items.add(new StringValue(this, field));
+ }
+ return new ArrayType(this, context, items);
+ }
+
+ /**
+ * Expression body for the "get" accessor function in fn:parse-csv results.
+ * Implements fn($row as xs:integer, $column as xs:integer) as xs:string.
+ * Both row and column are 1-based indexes.
+ */
+ private static class CsvGetExpression extends AbstractExpression {
+
+ private final ValueSequence rows;
+ private final List header;
+
+ public CsvGetExpression(final XQueryContext context, final ValueSequence rows, final List header) {
+ super(context);
+ this.rows = rows;
+ this.header = header;
+ }
+
+ @Override
+ public Sequence eval(final Sequence contextSequence, final Item contextItem) throws XPathException {
+ final Sequence rowIdxSeq = context.resolveVariable("row").getValue();
+ final Sequence colSeq = context.resolveVariable("column").getValue();
+
+ if (rowIdxSeq.isEmpty() || colSeq.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final int rowIdx = ((IntegerValue) rowIdxSeq.itemAt(0).convertTo(Type.INTEGER)).getInt();
+
+ if (rowIdx < 1 || rowIdx > rows.getItemCount()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ // Resolve column: integer index or string name
+ final Item colItem = colSeq.itemAt(0);
+ final int colIdx;
+ if (Type.subTypeOf(colItem.getType(), Type.INTEGER)) {
+ colIdx = ((IntegerValue) colItem.convertTo(Type.INTEGER)).getInt();
+ } else {
+ // String column name — look up in header
+ final String colName = colItem.getStringValue();
+ if (header == null) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ int found = -1;
+ for (int i = 0; i < header.size(); i++) {
+ if (header.get(i).equals(colName)) {
+ found = i + 1; // 1-based
+ break;
+ }
+ }
+ if (found == -1) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ colIdx = found;
+ }
+
+ final ArrayType row = (ArrayType) rows.itemAt(rowIdx - 1);
+ if (colIdx < 1 || colIdx > row.getSize()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ return row.get(colIdx - 1);
+ }
+
+ @Override
+ public int returnsType() {
+ return Type.STRING;
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ // no-op
+ }
+
+ @Override
+ public void dump(final org.exist.xquery.util.ExpressionDumper dumper) {
+ dumper.display("[csv-get]");
+ }
+
+ @Override
+ public String toString() {
+ return "[csv-get]";
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/CsvParser.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/CsvParser.java
new file mode 100644
index 00000000000..3b1524108bb
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/CsvParser.java
@@ -0,0 +1,338 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Expression;
+import org.exist.xquery.XPathException;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * State-machine CSV parser following the XQuery 4.0 specification.
+ * Parses CSV text into records (rows) of fields using SAX-like callbacks.
+ *
+ * Options supported (per XQ4 spec):
+ * - field-delimiter (default: comma)
+ * - row-delimiter (default: CRLF/LF/CR)
+ * - quote-character (default: double-quote; empty string disables quoting)
+ * - trim-whitespace (default: false)
+ * - header (default: false; true or "present" means first row is header)
+ * - select-columns (default: all)
+ * - trim-rows (default: false; removes trailing empty rows)
+ */
+public class CsvParser {
+
+ /**
+ * Callback interface for CSV parsing events.
+ */
+ public interface CsvConverter {
+ void header(List fields) throws XPathException;
+ void record(List fields) throws XPathException;
+ void finish() throws XPathException;
+ }
+
+ private final int fieldDelimiter;
+ private final int rowDelimiter;
+ private final int quoteChar;
+ private final boolean trimWhitespace;
+ private final boolean hasHeader;
+ private final int[] selectColumns;
+ private final boolean trimRows;
+ private final Expression expression;
+
+ public CsvParser(final CsvOptions options, final Expression expression) {
+ this.fieldDelimiter = options.fieldDelimiter;
+ this.rowDelimiter = options.rowDelimiter;
+ this.quoteChar = options.quoteChar;
+ this.trimWhitespace = options.trimWhitespace;
+ this.hasHeader = options.hasHeader;
+ this.selectColumns = options.selectColumns;
+ this.trimRows = options.trimRows;
+ this.expression = expression;
+ }
+
+ /**
+ * Parse CSV text, calling the converter for each record.
+ */
+ public void parse(final String input, final CsvConverter converter) throws XPathException {
+ final List> allRecords = new ArrayList<>();
+ List currentRecord = new ArrayList<>();
+ final StringBuilder field = new StringBuilder();
+
+ // State: FIELD_START, IN_UNQUOTED, IN_QUOTED, AFTER_QUOTED
+ int state = 0; // 0=field_start, 1=in_unquoted, 2=in_quoted, 3=after_quoted
+ int i = 0;
+ final int len = input.length();
+
+ while (i < len) {
+ final int cp = input.codePointAt(i);
+ final int cpLen = Character.charCount(cp);
+
+ switch (state) {
+ case 0: // FIELD_START — beginning of a new field
+ if (cp == quoteChar && quoteChar != -1) {
+ state = 2; // start quoted field
+ i += cpLen;
+ } else if (cp == fieldDelimiter) {
+ currentRecord.add(finishField(field));
+ field.setLength(0);
+ // remain in FIELD_START
+ i += cpLen;
+ } else if (isRowDelimiter(cp)) {
+ currentRecord.add(finishField(field));
+ field.setLength(0);
+ allRecords.add(currentRecord);
+ currentRecord = new ArrayList<>();
+ i += rowDelimiterLength(input, i, cp);
+ } else {
+ field.appendCodePoint(cp);
+ state = 1; // in unquoted field
+ i += cpLen;
+ }
+ break;
+
+ case 1: // IN_UNQUOTED — inside an unquoted field
+ if (cp == quoteChar && quoteChar != -1) {
+ // Quote in middle of unquoted field → error
+ throw new XPathException(expression, ErrorCodes.FOCV0001,
+ "Quote character found in middle of unquoted field");
+ } else if (cp == fieldDelimiter) {
+ currentRecord.add(finishField(field));
+ field.setLength(0);
+ state = 0;
+ i += cpLen;
+ } else if (isRowDelimiter(cp)) {
+ currentRecord.add(finishField(field));
+ field.setLength(0);
+ allRecords.add(currentRecord);
+ currentRecord = new ArrayList<>();
+ state = 0;
+ i += rowDelimiterLength(input, i, cp);
+ } else {
+ field.appendCodePoint(cp);
+ i += cpLen;
+ }
+ break;
+
+ case 2: // IN_QUOTED — inside a quoted field
+ if (cp == quoteChar) {
+ // Check for escaped quote (doubled)
+ if (i + cpLen < len && input.codePointAt(i + cpLen) == quoteChar) {
+ field.appendCodePoint(quoteChar);
+ i += cpLen * 2;
+ } else {
+ // End of quoted field
+ state = 3; // after closing quote
+ i += cpLen;
+ }
+ } else {
+ field.appendCodePoint(cp);
+ i += cpLen;
+ }
+ break;
+
+ case 3: // AFTER_QUOTED — just saw closing quote
+ if (cp == fieldDelimiter) {
+ currentRecord.add(finishField(field));
+ field.setLength(0);
+ state = 0;
+ i += cpLen;
+ } else if (isRowDelimiter(cp)) {
+ currentRecord.add(finishField(field));
+ field.setLength(0);
+ allRecords.add(currentRecord);
+ currentRecord = new ArrayList<>();
+ state = 0;
+ i += rowDelimiterLength(input, i, cp);
+ } else if (cp == ' ' || cp == '\t') {
+ // Whitespace after closing quote is allowed (ignored)
+ i += cpLen;
+ } else {
+ // Non-delimiter content after closing quote → error
+ throw new XPathException(expression, ErrorCodes.FOCV0001,
+ "Content after closing quote in CSV field");
+ }
+ break;
+ }
+ }
+
+ // Check for unterminated quotes
+ if (state == 2) {
+ throw new XPathException(expression, ErrorCodes.FOCV0001,
+ "Unterminated quoted field in CSV input");
+ }
+
+ // Handle last field/record (if input doesn't end with row delimiter).
+ // A trailing row delimiter does not create an additional empty record.
+ // With trim-whitespace, a trailing row delimiter followed by only whitespace
+ // also does not create an additional record.
+ if (!currentRecord.isEmpty() || state == 3) {
+ // We had field delimiters on this line or a quoted field — always add
+ currentRecord.add(finishField(field));
+ allRecords.add(currentRecord);
+ } else if (field.length() > 0) {
+ final String finished = finishField(field);
+ if (!finished.isEmpty()) {
+ currentRecord.add(finished);
+ allRecords.add(currentRecord);
+ }
+ }
+
+ // Trim trailing empty rows if requested
+ if (trimRows) {
+ while (!allRecords.isEmpty()) {
+ final List lastRow = allRecords.get(allRecords.size() - 1);
+ if (isEmptyRow(lastRow)) {
+ allRecords.remove(allRecords.size() - 1);
+ } else {
+ break;
+ }
+ }
+
+ // Normalize column count: all rows trimmed/padded to match first row (or header)
+ if (!allRecords.isEmpty()) {
+ final int columnCount = allRecords.get(0).size();
+ for (int r = 1; r < allRecords.size(); r++) {
+ final List row = allRecords.get(r);
+ if (row.size() > columnCount) {
+ allRecords.set(r, new ArrayList<>(row.subList(0, columnCount)));
+ } else {
+ while (row.size() < columnCount) {
+ row.add("");
+ }
+ }
+ }
+ }
+ }
+
+ // Process header and records
+ int startIdx = 0;
+ if (hasHeader && !allRecords.isEmpty()) {
+ // Headers are always trimmed (per XQ4 spec), regardless of trim-whitespace option
+ final List headerFields = allRecords.get(0);
+ final List trimmedHeader = new ArrayList<>(headerFields.size());
+ for (final String h : headerFields) {
+ trimmedHeader.add(h.trim());
+ }
+ converter.header(selectFields(trimmedHeader));
+ startIdx = 1;
+ }
+
+ for (int r = startIdx; r < allRecords.size(); r++) {
+ converter.record(selectFields(allRecords.get(r)));
+ }
+
+ converter.finish();
+ }
+
+ private String finishField(final StringBuilder field) {
+ if (trimWhitespace) {
+ return field.toString().trim();
+ }
+ return field.toString();
+ }
+
+ private boolean isRowDelimiter(final int cp) {
+ if (rowDelimiter == -1) {
+ // Auto-detect: CR, LF, or CRLF
+ return cp == '\n' || cp == '\r';
+ }
+ return cp == rowDelimiter;
+ }
+
+ private int rowDelimiterLength(final String input, final int pos, final int cp) {
+ if (rowDelimiter == -1) {
+ // Auto-detect: CRLF counts as one delimiter
+ if (cp == '\r' && pos + 1 < input.length() && input.charAt(pos + 1) == '\n') {
+ return 2;
+ }
+ return 1;
+ }
+ return Character.charCount(rowDelimiter);
+ }
+
+ private List selectFields(final List fields) {
+ if (selectColumns == null) {
+ return fields;
+ }
+ final List selected = new ArrayList<>(selectColumns.length);
+ for (final int col : selectColumns) {
+ if (col >= 1 && col <= fields.size()) {
+ selected.add(fields.get(col - 1));
+ } else {
+ selected.add("");
+ }
+ }
+ return selected;
+ }
+
+ private static boolean isEmptyRow(final List row) {
+ for (final String field : row) {
+ if (!field.isEmpty()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Parsed CSV options from an XQuery map.
+ */
+ public static class CsvOptions {
+ public int fieldDelimiter = ',';
+ public int rowDelimiter = -1; // -1 = auto-detect (CR/LF/CRLF)
+ public int quoteChar = '"';
+ public boolean trimWhitespace = false;
+ public boolean hasHeader = false;
+ public List explicitHeader = null; // explicit column names from options
+ public int[] selectColumns = null;
+ public boolean trimRows = false;
+
+ /**
+ * Validate options per the XQ4 spec.
+ */
+ public void validate(final Expression expression) throws XPathException {
+ // Field delimiter and quote character must be different
+ if (quoteChar != -1 && fieldDelimiter == quoteChar) {
+ throw new XPathException(expression, ErrorCodes.FOCV0003,
+ "Field delimiter and quote character must be different");
+ }
+ // Field delimiter and row delimiter must be different
+ if (rowDelimiter != -1 && fieldDelimiter == rowDelimiter) {
+ throw new XPathException(expression, ErrorCodes.FOCV0003,
+ "Field delimiter and row delimiter must be different");
+ }
+ // When using auto-detect row delimiters, field delimiter can't be CR or LF
+ if (rowDelimiter == -1 && (fieldDelimiter == '\n' || fieldDelimiter == '\r')) {
+ throw new XPathException(expression, ErrorCodes.FOCV0003,
+ "Field delimiter conflicts with auto-detected row delimiter (CR/LF)");
+ }
+ // Quote character and row delimiter must be different
+ if (quoteChar != -1 && rowDelimiter != -1 && quoteChar == rowDelimiter) {
+ throw new XPathException(expression, ErrorCodes.FOCV0003,
+ "Quote character and row delimiter must be different");
+ }
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/DeepEqualOptions.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/DeepEqualOptions.java
new file mode 100644
index 00000000000..17e1bcdcd57
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/DeepEqualOptions.java
@@ -0,0 +1,962 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import com.ibm.icu.text.Collator;
+import io.lacuna.bifurcan.IEntry;
+import org.exist.Namespaces;
+import org.exist.dom.memtree.NodeImpl;
+import org.exist.dom.memtree.ReferenceNode;
+import org.exist.xquery.Constants;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.InlineFunction;
+import org.exist.xquery.ValueComparison;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.functions.array.ArrayType;
+import org.exist.xquery.functions.map.AbstractMapType;
+import org.exist.xquery.value.*;
+
+import javax.annotation.Nullable;
+import java.text.Normalizer;
+import java.util.*;
+
+/**
+ * XQuery 4.0 deep-equal options and options-aware comparison engine.
+ *
+ * Holds the parsed option flags from the options map parameter and provides
+ * comparison methods that respect those options.
+ */
+public class DeepEqualOptions {
+
+ // Valid boolean option keys (no namespace)
+ private static final Set VALID_BOOLEAN_OPTIONS = Set.of(
+ "base-uri", "comments", "debug",
+ "id-property", "idrefs-property",
+ "in-scope-namespaces", "namespace-prefixes", "nilled-property",
+ "processing-instructions", "timezones", "type-annotations",
+ "type-variety", "typed-values"
+ );
+
+ // Valid string-valued option keys
+ private static final Set VALID_STRING_OPTIONS = Set.of(
+ "collation", "whitespace"
+ );
+
+ // Valid boolean-valued option keys (not in VALID_BOOLEAN_OPTIONS)
+ private static final Set VALID_ORDERED_OPTIONS = Set.of(
+ "ordered", "map-order"
+ );
+
+ // All valid string keys (no namespace)
+ private static final Set ALL_VALID_KEYS;
+ static {
+ final Set keys = new HashSet<>();
+ keys.addAll(VALID_BOOLEAN_OPTIONS);
+ keys.addAll(VALID_STRING_OPTIONS);
+ keys.addAll(VALID_ORDERED_OPTIONS);
+ ALL_VALID_KEYS = Collections.unmodifiableSet(keys);
+ }
+
+ // Option flags (defaults per XQ4 spec)
+ public final boolean comments; // default: false
+ public final boolean processingInstructions; // default: false
+ public final boolean ordered; // default: true
+ public final boolean namespacePrefixes; // default: false
+ public final boolean inScopeNamespaces; // default: false
+ public final boolean baseUri; // default: false
+ public final boolean idProperty; // default: false
+ public final boolean idrefsProperty; // default: false
+ public final boolean nilledProperty; // default: false
+ public final boolean timezones; // default: true
+ public final boolean typeAnnotations; // default: false
+ public final boolean typeVariety; // default: false
+ public final boolean typedValues; // default: true
+ public final boolean debug; // default: false
+ public final boolean mapOrder; // default: false
+ public final boolean unorderedElements; // from 'ordered' key on element comparison
+
+ public enum WhitespaceMode { PRESERVE, NORMALIZE, STRIP }
+ public final WhitespaceMode whitespace; // default: PRESERVE
+
+ @Nullable
+ public final Collator collator;
+
+ /** Default options (XQ3.1 compatible behavior). */
+ public static final DeepEqualOptions DEFAULTS = new DeepEqualOptions(
+ false, false, true, false, false, false,
+ false, false, false, true, false, false, true,
+ false, false, WhitespaceMode.PRESERVE, null
+ );
+
+ private DeepEqualOptions(
+ boolean comments, boolean processingInstructions, boolean ordered,
+ boolean namespacePrefixes, boolean inScopeNamespaces, boolean baseUri,
+ boolean idProperty, boolean idrefsProperty, boolean nilledProperty,
+ boolean timezones, boolean typeAnnotations, boolean typeVariety,
+ boolean typedValues, boolean debug, boolean mapOrder,
+ WhitespaceMode whitespace, @Nullable Collator collator) {
+ this.comments = comments;
+ this.processingInstructions = processingInstructions;
+ this.ordered = ordered;
+ this.namespacePrefixes = namespacePrefixes;
+ this.inScopeNamespaces = inScopeNamespaces;
+ this.baseUri = baseUri;
+ this.idProperty = idProperty;
+ this.idrefsProperty = idrefsProperty;
+ this.nilledProperty = nilledProperty;
+ this.timezones = timezones;
+ this.typeAnnotations = typeAnnotations;
+ this.typeVariety = typeVariety;
+ this.typedValues = typedValues;
+ this.debug = debug;
+ this.mapOrder = mapOrder;
+ this.unorderedElements = !ordered;
+ this.whitespace = whitespace;
+ this.collator = collator;
+ }
+
+ /**
+ * Parse an XQ4 options map into a DeepEqualOptions instance.
+ * Validates all option keys and values per the spec.
+ *
+ * @param options the options map
+ * @param context the XQuery context (for collation resolution)
+ * @return parsed options
+ * @throws XPathException XPTY0004 if any option key or value is invalid
+ */
+ public static DeepEqualOptions parse(final AbstractMapType options, final XQueryContext context) throws XPathException {
+ boolean comments = false;
+ boolean processingInstructions = false;
+ boolean ordered = true;
+ boolean namespacePrefixes = false;
+ boolean inScopeNamespaces = false;
+ boolean baseUri = false;
+ boolean idProperty = false;
+ boolean idrefsProperty = false;
+ boolean nilledProperty = false;
+ boolean timezones = true;
+ boolean typeAnnotations = false;
+ boolean typeVariety = false;
+ boolean typedValues = true;
+ boolean debug = false;
+ boolean mapOrder = false;
+ WhitespaceMode whitespace = WhitespaceMode.PRESERVE;
+ Collator collator = context.getDefaultCollator();
+
+ for (final IEntry entry : options) {
+ final AtomicValue key = entry.key();
+
+ // Keys that are QNames in a namespace are ignored (vendor extensions)
+ if (key.getType() == Type.QNAME) {
+ final QNameValue qnv = (QNameValue) key;
+ final String ns = qnv.getQName().getNamespaceURI();
+ if (ns != null && !ns.isEmpty()) {
+ continue; // Ignore vendor extension options
+ }
+ // QName in no namespace → error
+ throw new XPathException(ErrorCodes.XPTY0004,
+ "Option key in no namespace is not recognized: " + key.getStringValue());
+ }
+
+ final String keyStr = key.getStringValue();
+
+ // Validate that the key is known
+ if (!ALL_VALID_KEYS.contains(keyStr)) {
+ throw new XPathException(ErrorCodes.XPTY0004,
+ "Unknown deep-equal option: '" + keyStr + "'");
+ }
+
+ final Sequence value = entry.value();
+
+ if (VALID_BOOLEAN_OPTIONS.contains(keyStr)) {
+ final boolean boolVal = parseBooleanOption(keyStr, value);
+ switch (keyStr) {
+ case "comments" -> comments = boolVal;
+ case "processing-instructions" -> processingInstructions = boolVal;
+ case "namespace-prefixes" -> namespacePrefixes = boolVal;
+ case "in-scope-namespaces" -> inScopeNamespaces = boolVal;
+ case "base-uri" -> baseUri = boolVal;
+ case "id-property" -> idProperty = boolVal;
+ case "idrefs-property" -> idrefsProperty = boolVal;
+ case "nilled-property" -> nilledProperty = boolVal;
+ case "timezones" -> timezones = boolVal;
+ case "type-annotations" -> typeAnnotations = boolVal;
+ case "type-variety" -> typeVariety = boolVal;
+ case "typed-values" -> typedValues = boolVal;
+ case "debug" -> debug = boolVal;
+ }
+ } else if (VALID_ORDERED_OPTIONS.contains(keyStr)) {
+ final boolean boolVal = parseBooleanOption(keyStr, value);
+ switch (keyStr) {
+ case "ordered" -> ordered = boolVal;
+ case "map-order" -> mapOrder = boolVal;
+ }
+ } else if ("collation".equals(keyStr)) {
+ if (!value.isEmpty()) {
+ collator = context.getCollator(value.getStringValue());
+ }
+ } else if ("whitespace".equals(keyStr)) {
+ if (!value.isEmpty()) {
+ final String wsVal = value.getStringValue();
+ whitespace = switch (wsVal) {
+ case "preserve" -> WhitespaceMode.PRESERVE;
+ case "normalize" -> WhitespaceMode.NORMALIZE;
+ case "strip" -> WhitespaceMode.STRIP;
+ default -> throw new XPathException(ErrorCodes.XPTY0004,
+ "Invalid whitespace option value: '" + wsVal + "'");
+ };
+ }
+ }
+ }
+
+ return new DeepEqualOptions(
+ comments, processingInstructions, ordered,
+ namespacePrefixes, inScopeNamespaces, baseUri,
+ idProperty, idrefsProperty, nilledProperty,
+ timezones, typeAnnotations, typeVariety, typedValues,
+ debug, mapOrder, whitespace, collator
+ );
+ }
+
+ /**
+ * Parse a boolean option value using XQ4 option parameter conventions.
+ * Accepts: xs:boolean, xs:string ("true"/"false"/"0"/"1"),
+ * xs:integer (0/1), or nodes (effective boolean value).
+ */
+ private static boolean parseBooleanOption(final String key, final Sequence value) throws XPathException {
+ if (value.isEmpty()) {
+ return false;
+ }
+
+ final Item item = value.itemAt(0);
+
+ // If it's already a boolean, use it directly
+ if (item.getType() == Type.BOOLEAN) {
+ return ((BooleanValue) item).getValue();
+ }
+
+ // Try casting to xs:boolean — accepts "true"/"false"/"0"/"1" and numeric 0/1
+ try {
+ final AtomicValue boolVal = item.atomize().convertTo(Type.BOOLEAN);
+ return ((BooleanValue) boolVal).getValue();
+ } catch (final XPathException e) {
+ throw new XPathException(ErrorCodes.XPTY0004,
+ "Invalid value for boolean option '" + key + "': " + item.getStringValue());
+ }
+ }
+
+ // ========================================================================
+ // Options-aware deep comparison engine
+ // ========================================================================
+
+ /**
+ * Deep-compare two sequences with options.
+ */
+ public int deepCompareSeq(final Sequence sequence1, final Sequence sequence2) {
+ if (sequence1 == sequence2) {
+ return Constants.EQUAL;
+ }
+
+ if (!ordered) {
+ return deepCompareSeqUnordered(sequence1, sequence2);
+ }
+
+ final int count1 = sequence1.getItemCount();
+ final int count2 = sequence2.getItemCount();
+ if (count1 != count2) {
+ return count1 < count2 ? Constants.INFERIOR : Constants.SUPERIOR;
+ }
+
+ for (int i = 0; i < count1; i++) {
+ final int cmp = deepCompare(sequence1.itemAt(i), sequence2.itemAt(i));
+ if (cmp != Constants.EQUAL) {
+ return cmp;
+ }
+ }
+ return Constants.EQUAL;
+ }
+
+ /**
+ * Unordered sequence comparison: every item in seq1 must match some
+ * item in seq2 (and vice versa, by equal counts of matches).
+ */
+ private int deepCompareSeqUnordered(final Sequence sequence1, final Sequence sequence2) {
+ final int count1 = sequence1.getItemCount();
+ final int count2 = sequence2.getItemCount();
+ if (count1 != count2) {
+ return count1 < count2 ? Constants.INFERIOR : Constants.SUPERIOR;
+ }
+
+ // For each item in seq1, find a matching item in seq2
+ final boolean[] matched = new boolean[count2];
+ for (int i = 0; i < count1; i++) {
+ final Item item1 = sequence1.itemAt(i);
+ boolean found = false;
+ for (int j = 0; j < count2; j++) {
+ if (!matched[j] && deepCompare(item1, sequence2.itemAt(j)) == Constants.EQUAL) {
+ matched[j] = true;
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ return Constants.INFERIOR;
+ }
+ }
+ return Constants.EQUAL;
+ }
+
+ /**
+ * Deep-compare two items with options.
+ */
+ public int deepCompare(final Item item1, final Item item2) {
+ if (item1 == item2) {
+ return Constants.EQUAL;
+ }
+
+ try {
+ // Array comparison
+ if (item1.getType() == Type.ARRAY_ITEM || item2.getType() == Type.ARRAY_ITEM) {
+ if (item1.getType() != item2.getType()) {
+ return Constants.INFERIOR;
+ }
+ final ArrayType array1 = (ArrayType) item1;
+ final ArrayType array2 = (ArrayType) item2;
+ if (array1.getSize() != array2.getSize()) {
+ return array1.getSize() < array2.getSize() ? Constants.INFERIOR : Constants.SUPERIOR;
+ }
+ for (int i = 0; i < array1.getSize(); i++) {
+ final int cmp = deepCompareSeq(array1.get(i), array2.get(i));
+ if (cmp != Constants.EQUAL) {
+ return cmp;
+ }
+ }
+ return Constants.EQUAL;
+ }
+
+ // Map comparison
+ if (item1.getType() == Type.MAP_ITEM || item2.getType() == Type.MAP_ITEM) {
+ if (item1.getType() != item2.getType()) {
+ return Constants.INFERIOR;
+ }
+ return compareMaps((AbstractMapType) item1, (AbstractMapType) item2);
+ }
+
+ // Function items: identity comparison via function-identity semantics
+ if (Type.subTypeOf(item1.getType(), Type.FUNCTION) || Type.subTypeOf(item2.getType(), Type.FUNCTION)) {
+ if (!Type.subTypeOf(item1.getType(), Type.FUNCTION) || !Type.subTypeOf(item2.getType(), Type.FUNCTION)) {
+ return Constants.INFERIOR;
+ }
+ return compareFunctionItems(item1, item2);
+ }
+
+ // Atomic values
+ final boolean item1IsAtomic = Type.subTypeOf(item1.getType(), Type.ANY_ATOMIC_TYPE);
+ final boolean item2IsAtomic = Type.subTypeOf(item2.getType(), Type.ANY_ATOMIC_TYPE);
+ if (item1IsAtomic || item2IsAtomic) {
+ if (!item1IsAtomic || !item2IsAtomic) {
+ return item1IsAtomic ? Constants.INFERIOR : Constants.SUPERIOR;
+ }
+ return compareAtomics((AtomicValue) item1, (AtomicValue) item2);
+ }
+
+ // Node comparison
+ if (item1.getType() != item2.getType()) {
+ return Constants.INFERIOR;
+ }
+
+ final NodeValue nva = (NodeValue) item1;
+ final NodeValue nvb = (NodeValue) item2;
+ if (nva == nvb) {
+ return Constants.EQUAL;
+ }
+
+ switch (item1.getType()) {
+ case Type.DOCUMENT:
+ return compareContents(
+ nva instanceof org.w3c.dom.Node n1 ? n1 : ((org.exist.dom.persistent.NodeProxy) nva).getOwnerDocument(),
+ nvb instanceof org.w3c.dom.Node n2 ? n2 : ((org.exist.dom.persistent.NodeProxy) nvb).getOwnerDocument());
+
+ case Type.ELEMENT:
+ return compareElements(nva.getNode(), nvb.getNode());
+
+ case Type.ATTRIBUTE:
+ final int attrNameCmp = compareNames(nva.getNode(), nvb.getNode());
+ if (attrNameCmp != Constants.EQUAL) {
+ return attrNameCmp;
+ }
+ // whitespace:normalize applies to attribute values, but strip does NOT
+ return safeCompare(
+ maybeNormalizeWSAttr(nva.getNode().getNodeValue()),
+ maybeNormalizeWSAttr(nvb.getNode().getNodeValue()),
+ collator);
+
+ case Type.PROCESSING_INSTRUCTION:
+ return comparePIs(nva, nvb);
+
+ case Type.NAMESPACE:
+ final int nsNameCmp = safeCompare(nva.getNode().getNodeName(), nvb.getNode().getNodeName(), null);
+ if (nsNameCmp != Constants.EQUAL) {
+ return nsNameCmp;
+ }
+ return safeCompare(nva.getStringValue(), nvb.getStringValue(), collator);
+
+ case Type.TEXT:
+ return safeCompare(
+ maybeNormalizeWS(nva.getStringValue()),
+ maybeNormalizeWS(nvb.getStringValue()),
+ collator);
+
+ case Type.COMMENT:
+ // Apply whitespace normalization to comment content if whitespace option is set
+ return safeCompare(
+ maybeNormalizeWS(nva.getStringValue()),
+ maybeNormalizeWS(nvb.getStringValue()),
+ collator);
+
+ default:
+ return Constants.INFERIOR;
+ }
+ } catch (final XPathException e) {
+ return Constants.INFERIOR;
+ }
+ }
+
+ /**
+ * Compare function items using function-identity semantics (XQ4).
+ * Named functions with same name and arity are equal.
+ * Anonymous functions use reference identity.
+ */
+ private static int compareFunctionItems(final Item item1, final Item item2) {
+ if (item1 == item2) {
+ return Constants.EQUAL;
+ }
+ if (item1 instanceof FunctionReference ref1 && item2 instanceof FunctionReference ref2) {
+ final FunctionSignature sig1 = ref1.getSignature();
+ final FunctionSignature sig2 = ref2.getSignature();
+ final org.exist.dom.QName name1 = sig1.getName();
+ final org.exist.dom.QName name2 = sig2.getName();
+ // Both must be named functions (not inline/anonymous)
+ if (name1 != null && name2 != null
+ && name1 != InlineFunction.INLINE_FUNCTION_QNAME
+ && name2 != InlineFunction.INLINE_FUNCTION_QNAME) {
+ if (name1.equals(name2) && sig1.getArgumentCount() == sig2.getArgumentCount()) {
+ return Constants.EQUAL;
+ }
+ }
+ }
+ return Constants.INFERIOR;
+ }
+
+ private int compareMaps(final AbstractMapType map1, final AbstractMapType map2) {
+ if (map1.size() != map2.size()) {
+ return map1.size() < map2.size() ? Constants.INFERIOR : Constants.SUPERIOR;
+ }
+
+ for (final IEntry entry1 : map1) {
+ if (!map2.contains(entry1.key())) {
+ return Constants.SUPERIOR;
+ }
+ final int cmp = deepCompareSeq(entry1.value(), map2.get(entry1.key()));
+ if (cmp != Constants.EQUAL) {
+ return cmp;
+ }
+ }
+ return Constants.EQUAL;
+ }
+
+ private int compareAtomics(final AtomicValue av, final AtomicValue bv) {
+ try {
+ // Whitespace normalization for string-like atomics
+ if (whitespace != WhitespaceMode.PRESERVE) {
+ if (isStringLike(av) && isStringLike(bv)) {
+ final String a = applyWhitespace(av.getStringValue());
+ final String b = applyWhitespace(bv.getStringValue());
+ if (collator != null) {
+ return collator.compare(a, b);
+ }
+ return a.compareTo(b);
+ }
+ }
+
+ if (Type.subTypeOfUnion(av.getType(), Type.NUMERIC) &&
+ Type.subTypeOfUnion(bv.getType(), Type.NUMERIC)) {
+ if (((NumericValue) av).isNaN() && ((NumericValue) bv).isNaN()) {
+ return Constants.EQUAL;
+ }
+ }
+ return ValueComparison.compareAtomic(collator, av, bv);
+ } catch (final XPathException e) {
+ return Constants.INFERIOR;
+ }
+ }
+
+ private static boolean isStringLike(final AtomicValue v) {
+ return Type.subTypeOf(v.getType(), Type.STRING) ||
+ v.getType() == Type.UNTYPED_ATOMIC ||
+ v.getType() == Type.ANY_URI;
+ }
+
+ private int compareElements(final org.w3c.dom.Node a, final org.w3c.dom.Node b) {
+ int cmp = compareNames(a, b);
+ if (cmp != Constants.EQUAL) {
+ return cmp;
+ }
+
+ // Compare namespace prefixes if option is set
+ if (namespacePrefixes) {
+ cmp = safeCompare(a.getPrefix(), b.getPrefix(), null);
+ if (cmp != Constants.EQUAL) {
+ return cmp;
+ }
+ }
+
+ cmp = compareAttributes(a, b);
+ if (cmp != Constants.EQUAL) {
+ return cmp;
+ }
+
+ if (unorderedElements) {
+ return compareContentsUnordered(a, b);
+ }
+
+ return compareContents(a, b);
+ }
+
+ private int comparePIs(final NodeValue nva, final NodeValue nvb) throws XPathException {
+ final int nameCmp = safeCompare(nva.getNode().getNodeName(), nvb.getNode().getNodeName(), null);
+ if (nameCmp != Constants.EQUAL) {
+ return nameCmp;
+ }
+ // Apply whitespace normalization to PI data content
+ return safeCompare(
+ maybeNormalizeWS(nva.getStringValue()),
+ maybeNormalizeWS(nvb.getStringValue()),
+ collator);
+ }
+
+ private int compareContents(final org.w3c.dom.Node a, final org.w3c.dom.Node b) {
+ final List childrenA = getSignificantChildren(a);
+ final List childrenB = getSignificantChildren(b);
+
+ // Merge adjacent text nodes
+ final List mergedA = mergeAdjacentTextNodes(childrenA);
+ final List mergedB = mergeAdjacentTextNodes(childrenB);
+
+ if (mergedA.size() != mergedB.size()) {
+ return mergedA.size() < mergedB.size() ? Constants.INFERIOR : Constants.SUPERIOR;
+ }
+
+ for (int i = 0; i < mergedA.size(); i++) {
+ final Object itemA = mergedA.get(i);
+ final Object itemB = mergedB.get(i);
+
+ if (itemA instanceof String sa && itemB instanceof String sb) {
+ // Text may already be normalized/stripped by addMergedText; apply WS normalization if PRESERVE mode
+ final String normA = whitespace == WhitespaceMode.PRESERVE ? sa : maybeNormalizeWS(sa);
+ final String normB = whitespace == WhitespaceMode.PRESERVE ? sb : maybeNormalizeWS(sb);
+ final int cmp = safeCompare(normA, normB, collator);
+ if (cmp != Constants.EQUAL) {
+ return cmp;
+ }
+ } else if (itemA instanceof org.w3c.dom.Node na && itemB instanceof org.w3c.dom.Node nb) {
+ final int typeA = getEffectiveNodeType(na);
+ final int typeB = getEffectiveNodeType(nb);
+ if (typeA != typeB) {
+ return Constants.INFERIOR;
+ }
+ final int cmp;
+ switch (typeA) {
+ case org.w3c.dom.Node.ELEMENT_NODE:
+ cmp = compareElements(na, nb);
+ break;
+ case org.w3c.dom.Node.COMMENT_NODE:
+ cmp = safeCompare(maybeNormalizeWS(na.getNodeValue()),
+ maybeNormalizeWS(nb.getNodeValue()), collator);
+ break;
+ case org.w3c.dom.Node.PROCESSING_INSTRUCTION_NODE:
+ final int piNameCmp = safeCompare(na.getNodeName(), nb.getNodeName(), null);
+ cmp = piNameCmp != Constants.EQUAL ? piNameCmp :
+ safeCompare(maybeNormalizeWS(na.getNodeValue()),
+ maybeNormalizeWS(nb.getNodeValue()), collator);
+ break;
+ default:
+ cmp = Constants.INFERIOR;
+ }
+ if (cmp != Constants.EQUAL) {
+ return cmp;
+ }
+ } else {
+ // Mismatched types (text vs node)
+ return Constants.INFERIOR;
+ }
+ }
+ return Constants.EQUAL;
+ }
+
+ /**
+ * Unordered element comparison: child elements are compared as multisets.
+ */
+ private int compareContentsUnordered(final org.w3c.dom.Node a, final org.w3c.dom.Node b) {
+ final List childrenA = getSignificantChildren(a);
+ final List childrenB = getSignificantChildren(b);
+
+ // Separate text content and element/other nodes
+ final StringBuilder textA = new StringBuilder();
+ final List elementsA = new ArrayList<>();
+ for (final org.w3c.dom.Node n : childrenA) {
+ final int type = getEffectiveNodeType(n);
+ if (type == org.w3c.dom.Node.TEXT_NODE) {
+ textA.append(getNodeValue(n));
+ } else {
+ elementsA.add(n);
+ }
+ }
+
+ final StringBuilder textB = new StringBuilder();
+ final List elementsB = new ArrayList<>();
+ for (final org.w3c.dom.Node n : childrenB) {
+ final int type = getEffectiveNodeType(n);
+ if (type == org.w3c.dom.Node.TEXT_NODE) {
+ textB.append(getNodeValue(n));
+ } else {
+ elementsB.add(n);
+ }
+ }
+
+ // Compare concatenated text content
+ final int textCmp = safeCompare(
+ maybeNormalizeWS(textA.toString()),
+ maybeNormalizeWS(textB.toString()),
+ collator);
+ if (textCmp != Constants.EQUAL) {
+ return textCmp;
+ }
+
+ // Compare elements as multisets
+ if (elementsA.size() != elementsB.size()) {
+ return elementsA.size() < elementsB.size() ? Constants.INFERIOR : Constants.SUPERIOR;
+ }
+
+ final boolean[] matched = new boolean[elementsB.size()];
+ for (final org.w3c.dom.Node na : elementsA) {
+ boolean found = false;
+ for (int j = 0; j < elementsB.size(); j++) {
+ if (!matched[j]) {
+ final int typeA = getEffectiveNodeType(na);
+ final int typeB = getEffectiveNodeType(elementsB.get(j));
+ if (typeA == typeB) {
+ int cmp;
+ if (typeA == org.w3c.dom.Node.ELEMENT_NODE) {
+ cmp = compareElements(na, elementsB.get(j));
+ } else if (typeA == org.w3c.dom.Node.COMMENT_NODE) {
+ cmp = safeCompare(na.getNodeValue(), elementsB.get(j).getNodeValue(), collator);
+ } else if (typeA == org.w3c.dom.Node.PROCESSING_INSTRUCTION_NODE) {
+ cmp = safeCompare(na.getNodeName(), elementsB.get(j).getNodeName(), null);
+ if (cmp == Constants.EQUAL) {
+ cmp = safeCompare(na.getNodeValue(), elementsB.get(j).getNodeValue(), collator);
+ }
+ } else {
+ cmp = Constants.INFERIOR;
+ }
+ if (cmp == Constants.EQUAL) {
+ matched[j] = true;
+ found = true;
+ break;
+ }
+ }
+ }
+ }
+ if (!found) {
+ return Constants.INFERIOR;
+ }
+ }
+ return Constants.EQUAL;
+ }
+
+ /**
+ * Get child nodes that are significant for deep-equal comparison,
+ * based on the current options.
+ */
+ private List getSignificantChildren(final org.w3c.dom.Node parent) {
+ final List result = new ArrayList<>();
+ final boolean preserveWS = isXmlSpacePreserve(parent);
+ org.w3c.dom.Node child = parent.getFirstChild();
+ while (child != null) {
+ final int type = getEffectiveNodeType(child);
+ switch (type) {
+ case org.w3c.dom.Node.ELEMENT_NODE:
+ result.add(child);
+ break;
+ case org.w3c.dom.Node.TEXT_NODE:
+ if (whitespace == WhitespaceMode.STRIP) {
+ // Strip whitespace-only text nodes (deep-equal strip option
+ // overrides xml:space="preserve" per XQ4 spec)
+ final String value = getNodeValue(child);
+ if (value != null && !value.trim().isEmpty()) {
+ result.add(child);
+ }
+ } else {
+ result.add(child);
+ }
+ break;
+ case org.w3c.dom.Node.COMMENT_NODE:
+ if (comments) {
+ result.add(child);
+ }
+ break;
+ case org.w3c.dom.Node.PROCESSING_INSTRUCTION_NODE:
+ if (processingInstructions) {
+ result.add(child);
+ }
+ break;
+ }
+ child = child.getNextSibling();
+ }
+ return result;
+ }
+
+ /**
+ * Merge adjacent text nodes into single String entries.
+ * Non-text nodes are kept as-is. This handles the case where
+ * comments/PIs split text differently in two trees.
+ */
+ private List mergeAdjacentTextNodes(final List nodes) {
+ final List result = new ArrayList<>();
+ StringBuilder currentText = null;
+
+ for (final org.w3c.dom.Node node : nodes) {
+ final int type = getEffectiveNodeType(node);
+ if (type == org.w3c.dom.Node.TEXT_NODE) {
+ if (currentText == null) {
+ currentText = new StringBuilder();
+ }
+ currentText.append(getNodeValue(node));
+ } else {
+ if (currentText != null) {
+ addMergedText(result, currentText.toString());
+ currentText = null;
+ }
+ result.add(node);
+ }
+ }
+ if (currentText != null) {
+ addMergedText(result, currentText.toString());
+ }
+ return result;
+ }
+
+ /**
+ * Add merged text to the result list, applying whitespace rules.
+ * In STRIP mode, whitespace-only text is dropped.
+ * In NORMALIZE mode, text that normalizes to empty is dropped.
+ */
+ private void addMergedText(final List result, final String text) {
+ if (whitespace == WhitespaceMode.STRIP) {
+ if (!text.trim().isEmpty()) {
+ result.add(text);
+ }
+ } else if (whitespace == WhitespaceMode.NORMALIZE) {
+ final String normalized = normalizeWhitespace(text);
+ if (!normalized.isEmpty()) {
+ result.add(normalized);
+ }
+ } else {
+ result.add(text);
+ }
+ }
+
+ private int compareAttributes(final org.w3c.dom.Node a, final org.w3c.dom.Node b) {
+ final org.w3c.dom.NamedNodeMap nnma = a.getAttributes();
+ final org.w3c.dom.NamedNodeMap nnmb = b.getAttributes();
+
+ final int aCount = getAttrCount(nnma);
+ final int bCount = getAttrCount(nnmb);
+
+ if (aCount != bCount) {
+ return aCount < bCount ? Constants.INFERIOR : Constants.SUPERIOR;
+ }
+
+ for (int i = 0; i < nnma.getLength(); i++) {
+ final org.w3c.dom.Node ta = nnma.item(i);
+ final String nsA = ta.getNamespaceURI();
+ if (nsA != null && Namespaces.XMLNS_NS.equals(nsA)) {
+ continue;
+ }
+ final org.w3c.dom.Node tb = ta.getLocalName() == null ?
+ nnmb.getNamedItem(ta.getNodeName()) :
+ nnmb.getNamedItemNS(ta.getNamespaceURI(), ta.getLocalName());
+ if (tb == null) {
+ return Constants.SUPERIOR;
+ }
+ final int cmp = safeCompare(
+ maybeNormalizeWSAttr(ta.getNodeValue()),
+ maybeNormalizeWSAttr(tb.getNodeValue()),
+ collator);
+ if (cmp != Constants.EQUAL) {
+ return cmp;
+ }
+ }
+ return Constants.EQUAL;
+ }
+
+ // ========================================================================
+ // Utility methods
+ // ========================================================================
+
+ private String maybeNormalizeWS(@Nullable final String s) {
+ if (s == null || whitespace == WhitespaceMode.PRESERVE) {
+ return s;
+ }
+ // Both NORMALIZE and STRIP normalize text content
+ return normalizeWhitespace(s);
+ }
+
+ /**
+ * Normalize whitespace for attribute values: only NORMALIZE mode applies,
+ * STRIP mode does NOT affect attribute values.
+ */
+ private String maybeNormalizeWSAttr(@Nullable final String s) {
+ if (s == null || whitespace != WhitespaceMode.NORMALIZE) {
+ return s;
+ }
+ return normalizeWhitespace(s);
+ }
+
+ private static String normalizeWhitespace(final String s) {
+ return s.strip().replaceAll("\\s+", " ");
+ }
+
+ private String applyWhitespace(final String s) {
+ if (whitespace == WhitespaceMode.NORMALIZE) {
+ return normalizeWhitespace(s);
+ }
+ if (whitespace == WhitespaceMode.STRIP) {
+ return normalizeWhitespace(s);
+ }
+ return s;
+ }
+
+ private static int getAttrCount(final org.w3c.dom.NamedNodeMap nnm) {
+ int count = 0;
+ for (int i = 0; i < nnm.getLength(); i++) {
+ final org.w3c.dom.Node n = nnm.item(i);
+ final String ns = n.getNamespaceURI();
+ if (ns == null || !Namespaces.XMLNS_NS.equals(ns)) {
+ ++count;
+ }
+ }
+ return count;
+ }
+
+ private static int compareNames(final org.w3c.dom.Node a, final org.w3c.dom.Node b) {
+ if (a.getLocalName() != null || b.getLocalName() != null) {
+ final int nsComparison = safeCompare(a.getNamespaceURI(), b.getNamespaceURI(), null);
+ if (nsComparison != Constants.EQUAL) {
+ return nsComparison;
+ }
+ return safeCompare(a.getLocalName(), b.getLocalName(), null);
+ }
+ return safeCompare(a.getNodeName(), b.getNodeName(), null);
+ }
+
+ private static int safeCompare(@Nullable final String a, @Nullable final String b, @Nullable final Collator collator) {
+ if (a == b) {
+ return Constants.EQUAL;
+ }
+ if (a == null) {
+ return Constants.INFERIOR;
+ }
+ if (b == null) {
+ return Constants.SUPERIOR;
+ }
+ if (collator != null) {
+ return collator.compare(a, b);
+ }
+ return a.compareTo(b);
+ }
+
+ private static String getNodeValue(final org.w3c.dom.Node n) {
+ if (n.getNodeType() == NodeImpl.REFERENCE_NODE) {
+ return ((ReferenceNode) n).getReference().getNodeValue();
+ }
+ return n.getNodeValue();
+ }
+
+ private static int getEffectiveNodeType(final org.w3c.dom.Node n) {
+ int nodeType = n.getNodeType();
+ if (nodeType == NodeImpl.REFERENCE_NODE) {
+ nodeType = ((ReferenceNode) n).getReference().getNode().getNodeType();
+ }
+ return nodeType;
+ }
+
+ /**
+ * Check if the given node or any ancestor has xml:space="preserve".
+ * Uses NamedNodeMap lookup for broader DOM compatibility.
+ */
+ private static boolean isXmlSpacePreserve(final org.w3c.dom.Node node) {
+ org.w3c.dom.Node current = node;
+ while (current != null && current.getNodeType() == org.w3c.dom.Node.ELEMENT_NODE) {
+ if (current instanceof org.w3c.dom.Element elem) {
+ // Use Element.getAttributeNS for persistent DOM and other implementations
+ final String xmlSpace = elem.getAttributeNS(
+ "http://www.w3.org/XML/1998/namespace", "space");
+ if ("preserve".equals(xmlSpace)) {
+ return true;
+ }
+ if ("default".equals(xmlSpace)) {
+ return false;
+ }
+ }
+ // Also check via NamedNodeMap for broader compatibility
+ final org.w3c.dom.NamedNodeMap attrs = current.getAttributes();
+ if (attrs != null) {
+ org.w3c.dom.Node xmlSpace = attrs.getNamedItemNS(
+ "http://www.w3.org/XML/1998/namespace", "space");
+ if (xmlSpace == null) {
+ xmlSpace = attrs.getNamedItem("xml:space");
+ }
+ if (xmlSpace != null) {
+ final String val = xmlSpace.getNodeValue();
+ if ("preserve".equals(val)) {
+ return true;
+ }
+ if ("default".equals(val)) {
+ return false;
+ }
+ }
+ }
+ current = current.getParentNode();
+ }
+ return false;
+ }
+
+ /**
+ * Deep equality using these options.
+ */
+ public boolean deepEqualsSeq(final Sequence sequence1, final Sequence sequence2) {
+ return deepCompareSeq(sequence1, sequence2) == Constants.EQUAL;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnAllEqualDifferent.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnAllEqualDifferent.java
new file mode 100644
index 00000000000..c8825265991
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnAllEqualDifferent.java
@@ -0,0 +1,165 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import com.ibm.icu.text.Collator;
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.AtomicValue;
+import org.exist.xquery.value.BooleanValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.NumericValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceIterator;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+/**
+ * Implements fn:all-equal and fn:all-different (XQuery 4.0).
+ */
+public class FnAllEqualDifferent extends BasicFunction {
+
+ public static final FunctionSignature[] FN_ALL_EQUAL = {
+ new FunctionSignature(
+ new QName("all-equal", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if all items in the supplied sequence are equal.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("values", Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_MORE, "The values to compare")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if all values are equal")),
+ new FunctionSignature(
+ new QName("all-equal", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if all items in the supplied sequence are equal, using the specified collation.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("values", Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_MORE, "The values to compare"),
+ new FunctionParameterSequenceType("collation", Type.STRING, Cardinality.ZERO_OR_ONE, "The collation URI")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if all values are equal"))
+ };
+
+ public static final FunctionSignature[] FN_ALL_DIFFERENT = {
+ new FunctionSignature(
+ new QName("all-different", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if no two items in the supplied sequence are equal.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("values", Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_MORE, "The values to compare")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if all values are different")),
+ new FunctionSignature(
+ new QName("all-different", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if no two items in the supplied sequence are equal, using the specified collation.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("values", Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_MORE, "The values to compare"),
+ new FunctionParameterSequenceType("collation", Type.STRING, Cardinality.ZERO_OR_ONE, "The collation URI")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if all values are different"))
+ };
+
+ public FnAllEqualDifferent(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence values = args[0];
+ if (values.getItemCount() <= 1) {
+ return BooleanValue.TRUE;
+ }
+
+ final Collator collator = getCollator(args);
+
+ // Collect all atomized values
+ final java.util.List items = new java.util.ArrayList<>(values.getItemCount());
+ for (final SequenceIterator i = values.iterate(); i.hasNext(); ) {
+ items.add(i.nextItem().atomize());
+ }
+
+ if (isCalledAs("all-equal")) {
+ return allEqual(items, collator);
+ } else {
+ return allDifferent(items, collator);
+ }
+ }
+
+ private Sequence allEqual(final java.util.List items, final Collator collator) throws XPathException {
+ // all-equal iff count(distinct-values) <= 1, using contextual equality
+ final AtomicValue first = items.get(0);
+ for (int i = 1; i < items.size(); i++) {
+ if (!contextuallyEqual(first, items.get(i), collator)) {
+ return BooleanValue.FALSE;
+ }
+ }
+ return BooleanValue.TRUE;
+ }
+
+ private Sequence allDifferent(final java.util.List items, final Collator collator) throws XPathException {
+ // all-different iff count(distinct-values) == count
+ for (int i = 0; i < items.size(); i++) {
+ for (int j = i + 1; j < items.size(); j++) {
+ if (contextuallyEqual(items.get(i), items.get(j), collator)) {
+ return BooleanValue.FALSE;
+ }
+ }
+ }
+ return BooleanValue.TRUE;
+ }
+
+ /**
+ * XQ4 contextual equality: two values are contextually equal if fn:compare returns 0.
+ * NaN is treated as equal to NaN. Errors in comparison mean values are unequal.
+ */
+ static boolean contextuallyEqual(final AtomicValue v1, final AtomicValue v2, final Collator collator) {
+ try {
+ // NaN handling: NaN equals NaN
+ if (v1 instanceof NumericValue && v2 instanceof NumericValue) {
+ final boolean nan1 = ((NumericValue) v1).isNaN();
+ final boolean nan2 = ((NumericValue) v2).isNaN();
+ if (nan1 && nan2) {
+ return true;
+ }
+ if (nan1 || nan2) {
+ return false;
+ }
+ }
+ return FunCompare.compare(v1, v2, collator) == 0;
+ } catch (final Exception e) {
+ // Errors in comparison mean values are unequal
+ return false;
+ }
+ }
+
+ private Collator getCollator(final Sequence[] args) throws XPathException {
+ if (args.length > 1 && !args[1].isEmpty()) {
+ final String collationURI = args[1].getStringValue();
+ return context.getCollator(collationURI, ErrorCodes.FOCH0002);
+ }
+ return context.getDefaultCollator();
+ }
+
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnAtomicEqual.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnAtomicEqual.java
new file mode 100644
index 00000000000..0836fb00e66
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnAtomicEqual.java
@@ -0,0 +1,214 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.AbstractDateTimeValue;
+import org.exist.xquery.value.AtomicValue;
+import org.exist.xquery.value.BinaryValue;
+import org.exist.xquery.value.BooleanValue;
+import org.exist.xquery.value.DoubleValue;
+import org.exist.xquery.value.FloatValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.NumericValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+/**
+ * Implements fn:atomic-equal (XQuery 4.0).
+ *
+ * Compares two atomic values for equality. Unlike eq, this function:
+ * - Never raises a dynamic error (returns false for incomparable types)
+ * - NaN equals NaN
+ * - Does not depend on static or dynamic context
+ */
+public class FnAtomicEqual extends BasicFunction {
+
+ public static final FunctionSignature FN_ATOMIC_EQUAL = new FunctionSignature(
+ new QName("atomic-equal", Function.BUILTIN_FUNCTION_NS),
+ "Compares two atomic values for equality. NaN equals NaN, and incomparable types return false.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value1", Type.ANY_ATOMIC_TYPE, Cardinality.EXACTLY_ONE, "The first value"),
+ new FunctionParameterSequenceType("value2", Type.ANY_ATOMIC_TYPE, Cardinality.EXACTLY_ONE, "The second value")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if the values are equal"));
+
+ public FnAtomicEqual(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final AtomicValue v1 = args[0].itemAt(0).atomize();
+ final AtomicValue v2 = args[1].itemAt(0).atomize();
+
+ // Handle NaN: NaN equals NaN (across float/double)
+ if (isNaN(v1) && isNaN(v2)) {
+ return BooleanValue.TRUE;
+ }
+ if (isNaN(v1) || isNaN(v2)) {
+ return BooleanValue.FALSE;
+ }
+
+ // Handle Infinity: float INF equals double INF (and -INF)
+ if (isInfinite(v1) && isInfinite(v2)) {
+ return BooleanValue.valueOf(toDouble(v1) == toDouble(v2));
+ }
+
+ try {
+ final int t1 = v1.getType();
+ final int t2 = v2.getType();
+
+ // String-like types: string, untypedAtomic, anyURI all compare equal
+ if (isStringLike(t1) && isStringLike(t2)) {
+ return BooleanValue.valueOf(v1.getStringValue().equals(v2.getStringValue()));
+ }
+
+ // Numeric: compare by mathematical value regardless of type
+ // Per XQ4 spec: "Two numeric values are equal if their mathematical values are equal"
+ if (v1 instanceof NumericValue && v2 instanceof NumericValue) {
+ return BooleanValue.valueOf(numericEqual((NumericValue) v1, (NumericValue) v2));
+ }
+
+ // Binary types: hexBinary and base64Binary compare equal by content
+ if (isBinaryType(t1) && isBinaryType(t2)) {
+ if (v1 instanceof BinaryValue && v2 instanceof BinaryValue) {
+ return BooleanValue.valueOf(v1.compareTo(null, v2) == 0);
+ }
+ return BooleanValue.FALSE;
+ }
+
+ // Boolean
+ if (t1 == Type.BOOLEAN && t2 == Type.BOOLEAN) {
+ return BooleanValue.valueOf(v1.effectiveBooleanValue() == v2.effectiveBooleanValue());
+ }
+
+ // Date/time: values with timezone never equal values without timezone
+ if (v1 instanceof AbstractDateTimeValue && v2 instanceof AbstractDateTimeValue) {
+ final AbstractDateTimeValue dt1 = (AbstractDateTimeValue) v1;
+ final AbstractDateTimeValue dt2 = (AbstractDateTimeValue) v2;
+ if (dt1.hasTimezone() != dt2.hasTimezone()) {
+ return BooleanValue.FALSE;
+ }
+ }
+
+ // Different base types are never equal
+ if (t1 != t2 && !Type.subTypeOf(t1, t2) && !Type.subTypeOf(t2, t1)) {
+ return BooleanValue.FALSE;
+ }
+
+ // Same type — compare by value
+ final int cmp = v1.compareTo(null, v2);
+ return BooleanValue.valueOf(cmp == 0);
+ } catch (final XPathException | RuntimeException e) {
+ // Incomparable types or indeterminate ordering — return false per spec
+ return BooleanValue.FALSE;
+ }
+ }
+
+ private static boolean isNaN(final AtomicValue v) {
+ if (v instanceof DoubleValue) {
+ return Double.isNaN(((DoubleValue) v).getDouble());
+ }
+ if (v instanceof FloatValue) {
+ return Float.isNaN(((FloatValue) v).getValue());
+ }
+ return false;
+ }
+
+ private static boolean isInfinite(final AtomicValue v) {
+ if (v instanceof DoubleValue) {
+ return Double.isInfinite(((DoubleValue) v).getDouble());
+ }
+ if (v instanceof FloatValue) {
+ return Float.isInfinite(((FloatValue) v).getValue());
+ }
+ return false;
+ }
+
+ private static double toDouble(final AtomicValue v) {
+ if (v instanceof DoubleValue) {
+ return ((DoubleValue) v).getDouble();
+ }
+ if (v instanceof FloatValue) {
+ return ((FloatValue) v).getValue();
+ }
+ return 0;
+ }
+
+ static boolean numericEqual(final NumericValue v1, final NumericValue v2) throws XPathException {
+ // Both floating-point: use double comparison (handles 0.0 == -0.0)
+ if ((v1 instanceof DoubleValue || v1 instanceof FloatValue)
+ && (v2 instanceof DoubleValue || v2 instanceof FloatValue)) {
+ return v1.getDouble() == v2.getDouble();
+ }
+ // Mixed floating-point and exact: convert to BigDecimal for exact mathematical comparison
+ // This handles cases like atomic-equal(16777218, xs:double("16777218"))
+ final java.math.BigDecimal bd1 = numericToBigDecimal(v1);
+ final java.math.BigDecimal bd2 = numericToBigDecimal(v2);
+ return bd1.compareTo(bd2) == 0;
+ }
+
+ private static java.math.BigDecimal numericToBigDecimal(final NumericValue v) throws XPathException {
+ if (v instanceof DoubleValue) {
+ // Use new BigDecimal(double) for exact binary representation,
+ // not valueOf() which rounds via Double.toString()
+ return new java.math.BigDecimal(((DoubleValue) v).getDouble());
+ }
+ if (v instanceof FloatValue) {
+ return new java.math.BigDecimal(((FloatValue) v).getValue());
+ }
+ // Integer and decimal types: parse from string for exact representation
+ return new java.math.BigDecimal(v.getStringValue());
+ }
+
+ private static int primitiveNumericType(final int type) {
+ if (Type.subTypeOf(type, Type.INTEGER)) {
+ return Type.INTEGER;
+ }
+ if (Type.subTypeOf(type, Type.DECIMAL)) {
+ return Type.DECIMAL;
+ }
+ if (type == Type.FLOAT) {
+ return Type.FLOAT;
+ }
+ return Type.DOUBLE;
+ }
+
+ private static boolean isStringLike(final int type) {
+ return Type.subTypeOf(type, Type.STRING)
+ || type == Type.UNTYPED_ATOMIC
+ || Type.subTypeOf(type, Type.ANY_URI);
+ }
+
+ private static boolean isBinaryType(final int type) {
+ return type == Type.HEX_BINARY || type == Type.BASE64_BINARY;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnBuildUri.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnBuildUri.java
new file mode 100644
index 00000000000..bcd257a3869
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnBuildUri.java
@@ -0,0 +1,335 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import java.io.UnsupportedEncodingException;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.functions.map.MapType;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceIterator;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+
+/**
+ * Implements fn:build-uri (XQuery 4.0).
+ *
+ * Constructs a URI from the parts provided in a map.
+ */
+public class FnBuildUri extends BasicFunction {
+
+ private static final Set NON_HIERARCHICAL_SCHEMES = new HashSet<>(Arrays.asList(
+ "mailto", "news", "urn", "tel", "tag", "jar", "data", "javascript", "cid", "mid"
+ ));
+
+ public static final FunctionSignature[] FN_BUILD_URI = {
+ new FunctionSignature(
+ new QName("build-uri", Function.BUILTIN_FUNCTION_NS),
+ "Constructs a URI from the parts provided.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("parts", Type.MAP_ITEM,
+ Cardinality.EXACTLY_ONE, "Map of URI components")
+ },
+ new FunctionReturnSequenceType(Type.STRING,
+ Cardinality.EXACTLY_ONE, "The constructed URI")),
+ new FunctionSignature(
+ new QName("build-uri", Function.BUILTIN_FUNCTION_NS),
+ "Constructs a URI from the parts provided.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("parts", Type.MAP_ITEM,
+ Cardinality.EXACTLY_ONE, "Map of URI components"),
+ new FunctionParameterSequenceType("options", Type.MAP_ITEM,
+ Cardinality.ZERO_OR_ONE, "Options map")
+ },
+ new FunctionReturnSequenceType(Type.STRING,
+ Cardinality.EXACTLY_ONE, "The constructed URI"))
+ };
+
+ public FnBuildUri(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final MapType parts = (MapType) args[0].itemAt(0);
+
+ // Parse options
+ boolean allowDeprecated = false;
+ boolean omitDefaultPorts = false;
+ boolean uncPath = false;
+ if (args.length > 1 && !args[1].isEmpty()) {
+ final MapType options = (MapType) args[1].itemAt(0);
+ allowDeprecated = getBooleanOption(options, "allow-deprecated-features", false);
+ omitDefaultPorts = getBooleanOption(options, "omit-default-ports", false);
+ uncPath = getBooleanOption(options, "unc-path", false);
+ }
+
+ final StringBuilder uri = new StringBuilder();
+
+ // Get scheme
+ final String scheme = getStringValue(parts, "scheme");
+
+ // Determine if hierarchical
+ boolean hierarchical = true;
+ final Sequence hierSeq = parts.get(new StringValue(this, "hierarchical"));
+ if (hierSeq != null && !hierSeq.isEmpty()) {
+ hierarchical = hierSeq.effectiveBooleanValue();
+ } else if (scheme != null) {
+ hierarchical = !NON_HIERARCHICAL_SCHEMES.contains(scheme.toLowerCase());
+ }
+
+ // Add scheme
+ if (scheme != null) {
+ uri.append(scheme);
+ if (!hierarchical) {
+ uri.append(':');
+ } else if ("file".equalsIgnoreCase(scheme) && uncPath) {
+ uri.append(":////");
+ } else {
+ uri.append("://");
+ }
+ }
+
+ // Build authority from components or use authority directly
+ final String userinfo = getStringValue(parts, "userinfo");
+ final String host = getStringValue(parts, "host");
+ final Sequence portSeq = parts.get(new StringValue(this, "port"));
+ Integer port = null;
+ if (portSeq != null && !portSeq.isEmpty()) {
+ port = ((Number) portSeq.itemAt(0).toJavaObject(Long.class)).intValue();
+ }
+
+ // Handle deprecated password in userinfo
+ String effectiveUserinfo = userinfo;
+ if (!allowDeprecated && effectiveUserinfo != null && effectiveUserinfo.contains(":")) {
+ final String password = effectiveUserinfo.substring(effectiveUserinfo.indexOf(':') + 1);
+ if (!password.isEmpty()) {
+ effectiveUserinfo = null;
+ }
+ }
+
+ // Omit default ports
+ if (omitDefaultPorts && port != null && scheme != null) {
+ if (isDefaultPort(scheme.toLowerCase(), port)) {
+ port = null;
+ }
+ }
+
+ if (effectiveUserinfo != null || host != null || port != null) {
+ if (scheme == null) {
+ uri.append("//");
+ }
+ if (effectiveUserinfo != null) {
+ uri.append(effectiveUserinfo).append('@');
+ }
+ if (host != null) {
+ uri.append(host);
+ }
+ if (port != null) {
+ uri.append(':').append(port);
+ }
+ } else {
+ final String authority = getStringValue(parts, "authority");
+ if (authority != null) {
+ if (scheme == null) {
+ uri.append("//");
+ }
+ uri.append(authority);
+ }
+ }
+
+ // Build path from path-segments or use path directly
+ final Sequence pathSegments = parts.get(new StringValue(this, "path-segments"));
+ if (pathSegments != null && !pathSegments.isEmpty()) {
+ final StringBuilder pathBuilder = new StringBuilder();
+ boolean first = true;
+ for (final SequenceIterator i = pathSegments.iterate(); i.hasNext(); ) {
+ if (!first) {
+ pathBuilder.append('/');
+ }
+ first = false;
+ final String segment = i.nextItem().getStringValue();
+ if (hierarchical) {
+ pathBuilder.append(encodePathSegment(segment));
+ } else {
+ pathBuilder.append(segment);
+ }
+ }
+ uri.append(pathBuilder);
+ } else {
+ final String path = getStringValue(parts, "path");
+ if (path != null) {
+ uri.append(path);
+ }
+ }
+
+ // Build query from query-parameters or use query directly
+ final Sequence queryParamsSeq = parts.get(new StringValue(this, "query-parameters"));
+ if (queryParamsSeq != null && !queryParamsSeq.isEmpty() && queryParamsSeq.itemAt(0) instanceof MapType) {
+ final MapType queryParams = (MapType) queryParamsSeq.itemAt(0);
+ final StringBuilder queryBuilder = new StringBuilder();
+ boolean first = true;
+ for (final SequenceIterator ki = queryParams.keys().iterate(); ki.hasNext(); ) {
+ final StringValue key = (StringValue) ki.nextItem();
+ final Sequence values = queryParams.get(key);
+ for (final SequenceIterator vi = values.iterate(); vi.hasNext(); ) {
+ if (!first) {
+ queryBuilder.append('&');
+ }
+ first = false;
+ final String keyStr = key.getStringValue();
+ final String valStr = vi.nextItem().getStringValue();
+ if (keyStr.isEmpty()) {
+ queryBuilder.append(encodeQueryComponent(valStr));
+ } else {
+ queryBuilder.append(encodeQueryComponent(keyStr))
+ .append('=')
+ .append(encodeQueryComponent(valStr));
+ }
+ }
+ }
+ if (queryBuilder.length() > 0) {
+ uri.append('?').append(queryBuilder);
+ }
+ } else {
+ final String query = getStringValue(parts, "query");
+ if (query != null) {
+ uri.append('?').append(query);
+ }
+ }
+
+ // Fragment
+ final String fragment = getStringValue(parts, "fragment");
+ if (fragment != null) {
+ uri.append('#').append(encodeFragment(fragment));
+ }
+
+ return new StringValue(this, uri.toString());
+ }
+
+ private String getStringValue(final MapType map, final String key) throws XPathException {
+ final Sequence val = map.get(new StringValue(this, key));
+ if (val != null && !val.isEmpty()) {
+ return val.getStringValue();
+ }
+ return null;
+ }
+
+ private boolean getBooleanOption(final MapType options, final String key,
+ final boolean defaultValue) throws XPathException {
+ final Sequence val = options.get(new StringValue(this, key));
+ if (val != null && !val.isEmpty()) {
+ return val.effectiveBooleanValue();
+ }
+ return defaultValue;
+ }
+
+ private static boolean isDefaultPort(final String scheme, final int port) {
+ switch (scheme) {
+ case "http": return port == 80;
+ case "https": return port == 443;
+ case "ftp": return port == 21;
+ case "ssh": return port == 22;
+ default: return false;
+ }
+ }
+
+ // Encode path segment: control chars + space % / ? # + [ ]
+ private static String encodePathSegment(final String s) {
+ if (s == null || s.isEmpty()) {
+ return s;
+ }
+ final StringBuilder sb = new StringBuilder(s.length());
+ for (int i = 0; i < s.length(); i++) {
+ final char c = s.charAt(i);
+ if (c < 0x20 || c == ' ' || c == '%' || c == '/' || c == '?'
+ || c == '#' || c == '+' || c == '[' || c == ']') {
+ appendPercentEncoded(sb, c);
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ // Encode query component: control chars + space % = & # + [ ]
+ private static String encodeQueryComponent(final String s) {
+ if (s == null || s.isEmpty()) {
+ return s;
+ }
+ final StringBuilder sb = new StringBuilder(s.length());
+ for (int i = 0; i < s.length(); i++) {
+ final char c = s.charAt(i);
+ if (c < 0x20 || c == ' ' || c == '%' || c == '=' || c == '&'
+ || c == '#' || c == '+' || c == '[' || c == ']') {
+ appendPercentEncoded(sb, c);
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ // Encode fragment: control chars + space % # + [ ]
+ private static String encodeFragment(final String s) {
+ if (s == null || s.isEmpty()) {
+ return s;
+ }
+ final StringBuilder sb = new StringBuilder(s.length());
+ for (int i = 0; i < s.length(); i++) {
+ final char c = s.charAt(i);
+ if (c < 0x20 || c == ' ' || c == '%' || c == '#' || c == '+' || c == '[' || c == ']') {
+ appendPercentEncoded(sb, c);
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ private static void appendPercentEncoded(final StringBuilder sb, final char c) {
+ if (c < 0x80) {
+ sb.append('%').append(String.format("%02X", (int) c));
+ } else {
+ try {
+ final byte[] bytes = String.valueOf(c).getBytes("UTF-8");
+ for (final byte b : bytes) {
+ sb.append('%').append(String.format("%02X", b & 0xFF));
+ }
+ } catch (final UnsupportedEncodingException e) {
+ sb.append(c);
+ }
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnChar.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnChar.java
new file mode 100644
index 00000000000..fb97fd1a0dc
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnChar.java
@@ -0,0 +1,218 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Function;
+import org.exist.xquery.value.NumericValue;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+
+/**
+ * Implements fn:char (XQuery 4.0).
+ *
+ * Returns a string containing a single character identified by its codepoint
+ * or by an HTML5 character reference name.
+ */
+public class FnChar extends BasicFunction {
+
+ private static final ErrorCodes.ErrorCode FOCH0005 = new ErrorCodes.ErrorCode(
+ "FOCH0005", "Unknown character name");
+
+ public static final FunctionSignature FN_CHAR = new FunctionSignature(
+ new QName("char", Function.BUILTIN_FUNCTION_NS),
+ "Returns a string containing a single character identified by codepoint or character name.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.ANY_ATOMIC_TYPE, Cardinality.EXACTLY_ONE,
+ "A codepoint (integer) or character name (string)")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the character"));
+
+ private static volatile Map htmlEntities;
+
+ public FnChar(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final var item = args[0].itemAt(0);
+ final int type = item.getType();
+
+ if (Type.subTypeOf(type, Type.INTEGER)) {
+ // Codepoint
+ final long codepoint = ((IntegerValue) item).getLong();
+ if (codepoint < 1 || codepoint > 0x10FFFF) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Codepoint " + codepoint + " is not in the valid range 1 to 1114111");
+ }
+ // Check for XML-illegal characters (surrogates, etc.)
+ if (!isXmlChar((int) codepoint)) {
+ throw new XPathException(this, FOCH0005,
+ "Codepoint " + codepoint + " is not a valid XML character");
+ }
+ return new StringValue(this, new String(Character.toChars((int) codepoint)));
+ } else if (Type.subTypeOf(type, Type.DOUBLE) || Type.subTypeOf(type, Type.FLOAT)
+ || Type.subTypeOf(type, Type.DECIMAL)) {
+ // Numeric but not integer — try to convert
+ final NumericValue num = (NumericValue) item;
+ if (num.hasFractionalPart()) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Codepoint must be an integer, got " + Type.getTypeName(type));
+ }
+ final long codepoint = num.getLong();
+ if (codepoint < 1 || codepoint > 0x10FFFF) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Codepoint " + codepoint + " is not in the valid range 1 to 1114111");
+ }
+ if (!isXmlChar((int) codepoint)) {
+ throw new XPathException(this, FOCH0005,
+ "Codepoint " + codepoint + " is not a valid XML character");
+ }
+ return new StringValue(this, new String(Character.toChars((int) codepoint)));
+ } else {
+ // Character name lookup
+ final String name = item.getStringValue();
+
+ // Handle backslash escapes
+ switch (name) {
+ case "\\n": return new StringValue(this, "\n");
+ case "\\r": return new StringValue(this, "\r");
+ case "\\t": return new StringValue(this, "\t");
+ }
+
+ // Try HTML5 named character reference first (case-sensitive per spec)
+ final Map entities = getHtmlEntities();
+ String resolved = entities.get(name);
+ if (resolved != null) {
+ return new StringValue(this, resolved);
+ }
+
+ // Try Unicode character name
+ try {
+ final int cp = Character.codePointOf(name.replace(" ", "_").replace("-", "_").toUpperCase());
+ if (isXmlChar(cp)) {
+ return new StringValue(this, new String(Character.toChars(cp)));
+ }
+ } catch (final IllegalArgumentException e) {
+ // Not a Unicode name either
+ }
+
+ throw new XPathException(this, FOCH0005,
+ "Unknown character name: " + name);
+ }
+ }
+
+ private static boolean isXmlChar(final int cp) {
+ return cp == 0x9 || cp == 0xA || cp == 0xD
+ || (cp >= 0x20 && cp <= 0xD7FF)
+ || (cp >= 0xE000 && cp <= 0xFFFD)
+ || (cp >= 0x10000 && cp <= 0x10FFFF);
+ }
+
+ private static Map getHtmlEntities() {
+ if (htmlEntities == null) {
+ synchronized (FnChar.class) {
+ if (htmlEntities == null) {
+ htmlEntities = loadHtmlEntities();
+ }
+ }
+ }
+ return htmlEntities;
+ }
+
+ private static Map loadHtmlEntities() {
+ final Map map = new HashMap<>(2500);
+
+ // Load from bundled resource file
+ final InputStream is = FnChar.class.getResourceAsStream("html5-entities.properties");
+ if (is != null) {
+ try (final BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ line = line.trim();
+ if (line.isEmpty() || line.startsWith("#")) {
+ continue;
+ }
+ final int eq = line.indexOf('=');
+ if (eq > 0) {
+ final String entityName = line.substring(0, eq);
+ final String codepoints = line.substring(eq + 1);
+ map.put(entityName, decodeCodepoints(codepoints));
+ }
+ }
+ } catch (final IOException e) {
+ // Fall through with partial map
+ }
+ }
+
+ // Add a few critical aliases if the file wasn't found
+ if (map.isEmpty()) {
+ addCommonEntities(map);
+ }
+
+ return map;
+ }
+
+ private static String decodeCodepoints(final String spec) {
+ // Format: "U+XXXX" or "U+XXXX,U+YYYY"
+ final StringBuilder sb = new StringBuilder();
+ for (final String part : spec.split(",")) {
+ final String trimmed = part.trim();
+ if (trimmed.startsWith("U+") || trimmed.startsWith("u+")) {
+ final int cp = Integer.parseInt(trimmed.substring(2), 16);
+ sb.appendCodePoint(cp);
+ }
+ }
+ return sb.toString();
+ }
+
+ private static void addCommonEntities(final Map map) {
+ map.put("amp", "&");
+ map.put("lt", "<");
+ map.put("gt", ">");
+ map.put("quot", "\"");
+ map.put("apos", "'");
+ map.put("nbsp", "\u00A0");
+ map.put("tab", "\t");
+ map.put("newline", "\n");
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnCharacters.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnCharacters.java
new file mode 100644
index 00000000000..a45f63ca623
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnCharacters.java
@@ -0,0 +1,77 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+/**
+ * Implements fn:characters (XQuery 4.0).
+ *
+ * Splits the supplied string into a sequence of single-character strings.
+ */
+public class FnCharacters extends BasicFunction {
+
+ public static final FunctionSignature FN_CHARACTERS = new FunctionSignature(
+ new QName("characters", Function.BUILTIN_FUNCTION_NS),
+ "Splits the supplied string into a sequence of single-character strings.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.STRING, Cardinality.ZERO_OR_ONE, "The string to split")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "a sequence of single-character strings"));
+
+ public FnCharacters(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ final String str = args[0].getStringValue();
+ if (str.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ final ValueSequence result = new ValueSequence(str.length());
+ // Use codepoint iteration to handle surrogate pairs correctly
+ int i = 0;
+ while (i < str.length()) {
+ final int codepoint = str.codePointAt(i);
+ result.add(new StringValue(this, new String(Character.toChars(codepoint))));
+ i += Character.charCount(codepoint);
+ }
+ return result;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnCivilTimezone.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnCivilTimezone.java
new file mode 100644
index 00000000000..bace87f5755
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnCivilTimezone.java
@@ -0,0 +1,152 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.AbstractDateTimeValue;
+import org.exist.xquery.value.DayTimeDurationValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+import javax.xml.datatype.XMLGregorianCalendar;
+import javax.xml.datatype.DatatypeConstants;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.zone.ZoneRulesException;
+
+/**
+ * Implements XQuery 4.0 fn:civil-timezone.
+ *
+ * fn:civil-timezone($value as xs:dateTime, $place as xs:string?) as xs:dayTimeDuration
+ *
+ * Returns the civil timezone offset for a given dateTime at a given IANA timezone location,
+ * accounting for daylight savings time transitions.
+ */
+public class FnCivilTimezone extends BasicFunction {
+
+ private static final ErrorCodes.ErrorCode FODT0004 = new ErrorCodes.ErrorCode("FODT0004",
+ "No timezone data available");
+
+ public static final FunctionSignature[] FN_CIVIL_TIMEZONE = {
+ new FunctionSignature(
+ new QName("civil-timezone", Function.BUILTIN_FUNCTION_NS),
+ "Returns the civil timezone offset for a dateTime at a place.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.DATE_TIME, Cardinality.EXACTLY_ONE, "The dateTime to look up"),
+ new FunctionParameterSequenceType("place", Type.STRING, Cardinality.ZERO_OR_ONE, "IANA timezone name (e.g. 'America/New_York')")
+ },
+ new FunctionReturnSequenceType(Type.DAY_TIME_DURATION, Cardinality.EXACTLY_ONE, "the civil timezone offset")),
+ new FunctionSignature(
+ new QName("civil-timezone", Function.BUILTIN_FUNCTION_NS),
+ "Returns the civil timezone offset for a dateTime using the default place.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.DATE_TIME, Cardinality.EXACTLY_ONE, "The dateTime to look up")
+ },
+ new FunctionReturnSequenceType(Type.DAY_TIME_DURATION, Cardinality.EXACTLY_ONE, "the civil timezone offset"))
+ };
+
+ public FnCivilTimezone(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final AbstractDateTimeValue dtv = (AbstractDateTimeValue) args[0].itemAt(0);
+ final XMLGregorianCalendar cal = (XMLGregorianCalendar) dtv.calendar.clone();
+
+ // Determine the IANA zone
+ final ZoneId zone;
+ if (args.length > 1 && !args[1].isEmpty()) {
+ final String place = args[1].getStringValue();
+ try {
+ zone = ZoneId.of(place);
+ } catch (final java.time.DateTimeException e) {
+ throw new XPathException(this, FODT0004,
+ "Unknown timezone: " + place);
+ }
+ } else {
+ // Use system default timezone as the "default place"
+ zone = ZoneId.systemDefault();
+ }
+
+ // Convert the dateTime to a LocalDateTime (ignoring any timezone on the value)
+ final int year = cal.getYear();
+ final int month = cal.getMonth();
+ final int day = cal.getDay();
+ final int hour = cal.getHour() == DatatypeConstants.FIELD_UNDEFINED ? 0 : cal.getHour();
+ final int minute = cal.getMinute() == DatatypeConstants.FIELD_UNDEFINED ? 0 : cal.getMinute();
+ final int second = cal.getSecond() == DatatypeConstants.FIELD_UNDEFINED ? 0 : cal.getSecond();
+
+ final LocalDateTime ldt = LocalDateTime.of(year, month, day, hour, minute, second);
+
+ // Get the offset at that local date-time in the given zone
+ final ZonedDateTime zdt = ldt.atZone(zone);
+ final ZoneOffset offset = zdt.getOffset();
+ final int totalSeconds = offset.getTotalSeconds();
+
+ // Convert to xs:dayTimeDuration
+ final String dur = secondsToDayTimeDuration(totalSeconds);
+ return new DayTimeDurationValue(this, dur);
+ }
+
+ private static String secondsToDayTimeDuration(final int totalSeconds) {
+ final boolean negative = totalSeconds < 0;
+ int abs = Math.abs(totalSeconds);
+ final int hours = abs / 3600;
+ abs %= 3600;
+ final int minutes = abs / 60;
+ final int seconds = abs % 60;
+
+ final StringBuilder sb = new StringBuilder();
+ if (negative) {
+ sb.append('-');
+ }
+ sb.append("PT");
+ if (hours > 0) {
+ sb.append(hours).append('H');
+ }
+ if (minutes > 0) {
+ sb.append(minutes).append('M');
+ }
+ if (seconds > 0) {
+ sb.append(seconds).append('S');
+ }
+ // If all zero, output PT0S
+ if (hours == 0 && minutes == 0 && seconds == 0) {
+ sb.append("0S");
+ }
+ return sb.toString();
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnCollation.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnCollation.java
new file mode 100644
index 00000000000..fd8fed95284
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnCollation.java
@@ -0,0 +1,94 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.value.*;
+
+/**
+ * fn:collation() — Returns the default collation URI.
+ * fn:collation-available($uri) — Returns true if the collation is supported.
+ */
+public class FnCollation extends BasicFunction {
+
+ public static final FunctionSignature[] FN_COLLATION = {
+ new FunctionSignature(
+ new QName("collation", Function.BUILTIN_FUNCTION_NS),
+ "Returns the URI of the default collation.",
+ null,
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE,
+ "The default collation URI")),
+ new FunctionSignature(
+ new QName("collation", Function.BUILTIN_FUNCTION_NS),
+ "Returns the collation URI if supported, empty sequence otherwise.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("uri", Type.STRING,
+ Cardinality.EXACTLY_ONE, "The collation URI to check")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_ONE,
+ "The collation URI if supported"))
+ };
+
+ public static final FunctionSignature FN_COLLATION_AVAILABLE = new FunctionSignature(
+ new QName("collation-available", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if the specified collation is supported.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("uri", Type.STRING,
+ Cardinality.EXACTLY_ONE, "The collation URI")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE,
+ "true if the collation is supported"));
+
+ public FnCollation(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (isCalledAs("collation")) {
+ if (getArgumentCount() == 1) {
+ // 1-arg: check if the named collation is supported
+ final String uri = args[0].getStringValue();
+ try {
+ context.getCollator(uri);
+ return new StringValue(this, uri);
+ } catch (final XPathException e) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ }
+ // 0-arg: return default collation
+ final String defaultCollation = context.getDefaultCollation();
+ return new StringValue(this, defaultCollation != null ? defaultCollation
+ : org.exist.util.Collations.UNICODE_CODEPOINT_COLLATION_URI);
+ } else {
+ // collation-available
+ final String uri = args[0].getStringValue();
+ try {
+ context.getCollator(uri);
+ return BooleanValue.TRUE;
+ } catch (final XPathException e) {
+ return BooleanValue.FALSE;
+ }
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDateTimeParts.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDateTimeParts.java
new file mode 100644
index 00000000000..b9445ce1fd0
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDateTimeParts.java
@@ -0,0 +1,176 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.functions.map.MapType;
+import org.exist.xquery.value.*;
+
+import javax.xml.datatype.DatatypeConstants;
+import java.math.BigDecimal;
+
+/**
+ * fn:build-dateTime($date, $time) — Combine xs:date + xs:time into xs:dateTime.
+ * fn:parts-of-dateTime($dateTime) — Decompose xs:dateTime into a map of components.
+ *
+ * The map returned by parts-of-dateTime has keys: year, month, day, hour, minute,
+ * seconds (as xs:decimal including fractional), timezone (as xs:dayTimeDuration).
+ * When the Parser branch merges, these maps will be compatible with record type checking.
+ */
+public class FnDateTimeParts extends BasicFunction {
+
+ public static final FunctionSignature FN_BUILD_DATETIME = new FunctionSignature(
+ new QName("build-dateTime", Function.BUILTIN_FUNCTION_NS),
+ "Combines an xs:date and an xs:time into an xs:dateTime.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("date", Type.DATE,
+ Cardinality.EXACTLY_ONE, "The date component"),
+ new FunctionParameterSequenceType("time", Type.TIME,
+ Cardinality.EXACTLY_ONE, "The time component")
+ },
+ new FunctionReturnSequenceType(Type.DATE_TIME, Cardinality.EXACTLY_ONE,
+ "The combined xs:dateTime"));
+
+ public static final FunctionSignature FN_PARTS_OF_DATETIME = new FunctionSignature(
+ new QName("parts-of-dateTime", Function.BUILTIN_FUNCTION_NS),
+ "Decomposes an xs:dateTime into a map of its components.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("dateTime", Type.DATE_TIME,
+ Cardinality.ZERO_OR_ONE, "The dateTime to decompose")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.ZERO_OR_ONE,
+ "A map with keys: year, month, day, hour, minute, seconds, timezone"));
+
+ public FnDateTimeParts(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (isCalledAs("build-dateTime")) {
+ return buildDateTime(args);
+ } else {
+ return partsOfDateTime(args);
+ }
+ }
+
+ private Sequence buildDateTime(final Sequence[] args) throws XPathException {
+ final DateValue date = (DateValue) args[0].itemAt(0);
+ final TimeValue time = (TimeValue) args[1].itemAt(0);
+
+ final int year = date.getPart(AbstractDateTimeValue.YEAR);
+ final int month = date.getPart(AbstractDateTimeValue.MONTH);
+ final int day = date.getPart(AbstractDateTimeValue.DAY);
+ final int hour = time.getPart(AbstractDateTimeValue.HOUR);
+ final int minute = time.getPart(AbstractDateTimeValue.MINUTE);
+ final int second = time.getPart(AbstractDateTimeValue.SECOND);
+ final int millis = time.getPart(AbstractDateTimeValue.MILLISECOND);
+
+ // Timezone: both must agree or one must be absent
+ final Sequence dateTz = date.getTimezone();
+ final Sequence timeTz = time.getTimezone();
+
+ String tzSuffix = "";
+ if (!dateTz.isEmpty() && !timeTz.isEmpty()) {
+ // Both have timezones — they must be equal
+ final String dateTzStr = dateTz.getStringValue();
+ final String timeTzStr = timeTz.getStringValue();
+ if (!dateTzStr.equals(timeTzStr)) {
+ throw new XPathException(this, ErrorCodes.FORG0008,
+ "Date and time timezone offsets do not match");
+ }
+ tzSuffix = formatTimezoneOffset(date);
+ } else if (!dateTz.isEmpty()) {
+ tzSuffix = formatTimezoneOffset(date);
+ } else if (!timeTz.isEmpty()) {
+ tzSuffix = formatTimezoneOffset(time);
+ }
+
+ // Build the lexical representation
+ final String fracSeconds = millis > 0 ? "." + String.format("%03d", millis) : "";
+ final String lexical = String.format("%04d-%02d-%02dT%02d:%02d:%02d%s%s",
+ year, month, day, hour, minute, second, fracSeconds, tzSuffix);
+
+ return new DateTimeValue(this, lexical);
+ }
+
+ private String formatTimezoneOffset(final AbstractDateTimeValue dt) throws XPathException {
+ final Sequence tz = dt.getTimezone();
+ if (tz.isEmpty()) {
+ return "";
+ }
+ final DayTimeDurationValue dtv = (DayTimeDurationValue) tz;
+ final int totalMinutes = (int) (dtv.getValueInMilliseconds() / 60000L);
+ if (totalMinutes == 0) {
+ return "Z";
+ }
+ final int hours = totalMinutes / 60;
+ final int mins = Math.abs(totalMinutes % 60);
+ return String.format("%+03d:%02d", hours, mins);
+ }
+
+ private Sequence partsOfDateTime(final Sequence[] args) throws XPathException {
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final DateTimeValue dt = (DateTimeValue) args[0].itemAt(0);
+ final MapType result = new MapType(this, context);
+
+ // year as xs:integer
+ result.add(new StringValue("year"),
+ new IntegerValue(this, dt.getPart(AbstractDateTimeValue.YEAR)));
+
+ // month as xs:integer
+ result.add(new StringValue("month"),
+ new IntegerValue(this, dt.getPart(AbstractDateTimeValue.MONTH)));
+
+ // day as xs:integer
+ result.add(new StringValue("day"),
+ new IntegerValue(this, dt.getPart(AbstractDateTimeValue.DAY)));
+
+ // hour as xs:integer
+ result.add(new StringValue("hour"),
+ new IntegerValue(this, dt.getPart(AbstractDateTimeValue.HOUR)));
+
+ // minute as xs:integer
+ result.add(new StringValue("minute"),
+ new IntegerValue(this, dt.getPart(AbstractDateTimeValue.MINUTE)));
+
+ // seconds as xs:decimal (including fractional part)
+ final int sec = dt.getPart(AbstractDateTimeValue.SECOND);
+ final int millis = dt.getPart(AbstractDateTimeValue.MILLISECOND);
+ final BigDecimal seconds = BigDecimal.valueOf(sec)
+ .add(BigDecimal.valueOf(millis, 3));
+ result.add(new StringValue("seconds"),
+ new DecimalValue(this, seconds));
+
+ // timezone as xs:dayTimeDuration (or absent)
+ final Sequence tz = dt.getTimezone();
+ if (!tz.isEmpty()) {
+ result.add(new StringValue("timezone"), tz);
+ }
+
+ return result;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDecodeFromUri.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDecodeFromUri.java
new file mode 100644
index 00000000000..b94f7bc58c1
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDecodeFromUri.java
@@ -0,0 +1,183 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CoderResult;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Implements XQuery 4.0 fn:decode-from-uri.
+ *
+ * Decodes a URI-encoded string. Replaces '+' with space.
+ * Invalid/incomplete percent sequences are replaced with U+FFFD.
+ * Resulting octets are decoded as UTF-8; invalid UTF-8 is replaced with U+FFFD.
+ * XML-invalid codepoints are replaced with U+FFFD.
+ */
+public class FnDecodeFromUri extends BasicFunction {
+
+ private static final char REPLACEMENT = '\uFFFD';
+
+ public static final FunctionSignature FN_DECODE_FROM_URI = new FunctionSignature(
+ new QName("decode-from-uri", Function.BUILTIN_FUNCTION_NS),
+ "Decodes a URI-encoded string.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.STRING, Cardinality.ZERO_OR_ONE, "The URI-encoded string to decode")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the decoded string"));
+
+ public FnDecodeFromUri(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (args[0].isEmpty()) {
+ return new StringValue(this, "");
+ }
+
+ final String input = args[0].getStringValue();
+
+ // Phase 1: decode percent-encoding and '+' to bytes, collecting raw bytes
+ final ByteArrayOutputStream bytes = new ByteArrayOutputStream(input.length());
+ final StringBuilder result = new StringBuilder(input.length());
+
+ int i = 0;
+ while (i < input.length()) {
+ final char c = input.charAt(i);
+ if (c == '+') {
+ // Flush any accumulated bytes first
+ flushBytes(bytes, result);
+ result.append(' ');
+ i++;
+ } else if (c == '%') {
+ // Try to read percent-encoded byte
+ if (i + 2 < input.length() && isAscii(input.charAt(i + 1))) {
+ // Two chars follow and first is ASCII — treat as percent triplet
+ final int hi = hexDigit(input.charAt(i + 1));
+ final int lo = hexDigit(input.charAt(i + 2));
+ if (hi >= 0 && lo >= 0) {
+ bytes.write((hi << 4) | lo);
+ i += 3;
+ } else {
+ // Invalid hex pair: consume all 3 chars, produce one replacement
+ flushBytes(bytes, result);
+ result.append(REPLACEMENT);
+ i += 3;
+ }
+ } else if (i + 1 < input.length()) {
+ // First char after % is non-ASCII, or only 1 char follows
+ // Consume % + next char, produce replacement
+ flushBytes(bytes, result);
+ result.append(REPLACEMENT);
+ i += 2;
+ } else {
+ // % at end of string
+ flushBytes(bytes, result);
+ result.append(REPLACEMENT);
+ i++;
+ }
+ } else {
+ flushBytes(bytes, result);
+ result.append(c);
+ i++;
+ }
+ }
+ flushBytes(bytes, result);
+
+ // Phase 2: replace XML-invalid codepoints (handle surrogate pairs for supplementary chars)
+ final StringBuilder cleaned = new StringBuilder(result.length());
+ for (int j = 0; j < result.length(); j++) {
+ final char ch = result.charAt(j);
+ if (Character.isHighSurrogate(ch) && j + 1 < result.length()
+ && Character.isLowSurrogate(result.charAt(j + 1))) {
+ // Valid surrogate pair = supplementary character (valid in XML 1.0 4th+ edition)
+ cleaned.append(ch);
+ cleaned.append(result.charAt(++j));
+ } else if (isXmlValid(ch)) {
+ cleaned.append(ch);
+ } else {
+ cleaned.append(REPLACEMENT);
+ }
+ }
+
+ return new StringValue(this, cleaned.toString());
+ }
+
+ /**
+ * Flush accumulated bytes as UTF-8, replacing invalid sequences with U+FFFD.
+ */
+ private void flushBytes(final ByteArrayOutputStream bytes, final StringBuilder result) {
+ if (bytes.size() == 0) {
+ return;
+ }
+ final byte[] data = bytes.toByteArray();
+ bytes.reset();
+
+ final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPLACE)
+ .onUnmappableCharacter(CodingErrorAction.REPLACE)
+ .replaceWith("\uFFFD");
+
+ final ByteBuffer bb = ByteBuffer.wrap(data);
+ final CharBuffer cb = CharBuffer.allocate(data.length * 2);
+ decoder.decode(bb, cb, true);
+ decoder.flush(cb);
+ cb.flip();
+ result.append(cb);
+ }
+
+ private static int hexDigit(final char c) {
+ if (c >= '0' && c <= '9') return c - '0';
+ if (c >= 'a' && c <= 'f') return c - 'a' + 10;
+ if (c >= 'A' && c <= 'F') return c - 'A' + 10;
+ return -1;
+ }
+
+ private static boolean isAscii(final char c) {
+ return c <= 0x7F;
+ }
+
+ private static boolean isXmlValid(final char c) {
+ return c == 0x9 || c == 0xA || c == 0xD ||
+ (c >= 0x20 && c <= 0xD7FF) ||
+ (c >= 0xE000 && c <= 0xFFFD);
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDeepEqualOptions.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDeepEqualOptions.java
new file mode 100644
index 00000000000..05973da16c1
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDeepEqualOptions.java
@@ -0,0 +1,84 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import com.ibm.icu.text.Collator;
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.functions.map.AbstractMapType;
+import org.exist.xquery.value.*;
+
+/**
+ * Implements XQuery 4.0 fn:deep-equal with options parameter (string or map).
+ *
+ * Accepts either a collation URI string (XQ3.1 compatible) or an options
+ * map (XQ4.0) as the 3rd parameter. When an options map is provided,
+ * validates all option keys/values and uses the options-aware comparison
+ * engine in {@link DeepEqualOptions}.
+ */
+public class FnDeepEqualOptions extends BasicFunction {
+
+ public static final FunctionSignature FN_DEEP_EQUAL_OPTIONS = new FunctionSignature(
+ new QName("deep-equal", Function.BUILTIN_FUNCTION_NS),
+ "Returns true() iff every item in $items-1 is deep-equal to the item " +
+ "at the same position in $items-2, using the specified options or collation. " +
+ "If both $items-1 and $items-2 are the empty sequence, returns true().",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("items-1", Type.ITEM,
+ Cardinality.ZERO_OR_MORE, "The first item sequence"),
+ new FunctionParameterSequenceType("items-2", Type.ITEM,
+ Cardinality.ZERO_OR_MORE, "The second item sequence"),
+ new FunctionParameterSequenceType("options", Type.ITEM,
+ Cardinality.ZERO_OR_ONE, "Collation URI string or options map")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE,
+ "true() if the sequences are deep-equal, false() otherwise"));
+
+ public FnDeepEqualOptions(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence items1 = args[0];
+ final Sequence items2 = args[1];
+
+ // Parse 3rd parameter: either string (collation) or map (options)
+ if (args.length > 2 && !args[2].isEmpty()) {
+ final Item optionsItem = args[2].itemAt(0);
+ if (optionsItem instanceof AbstractMapType) {
+ // XQ4: options map — parse, validate, and use options-aware comparison
+ final DeepEqualOptions options = DeepEqualOptions.parse(
+ (AbstractMapType) optionsItem, context);
+ return BooleanValue.valueOf(options.deepEqualsSeq(items1, items2));
+ } else {
+ // XQ3.1 compat: string collation URI
+ final Collator collator = context.getCollator(optionsItem.getStringValue());
+ return BooleanValue.valueOf(FunDeepEqual.deepEqualsSeq(items1, items2, collator));
+ }
+ }
+
+ // No 3rd parameter — use default comparison
+ final Collator collator = context.getDefaultCollator();
+ return BooleanValue.valueOf(FunDeepEqual.deepEqualsSeq(items1, items2, collator));
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDistinctOrderedNodes.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDistinctOrderedNodes.java
new file mode 100644
index 00000000000..e8f6f151094
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDistinctOrderedNodes.java
@@ -0,0 +1,71 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+/**
+ * Implements XQuery 4.0 fn:distinct-ordered-nodes.
+ *
+ * Returns nodes in document order with duplicates removed, equivalent to
+ * the "/" operator's node deduplication behavior.
+ */
+public class FnDistinctOrderedNodes extends BasicFunction {
+
+ public static final FunctionSignature FN_DISTINCT_ORDERED_NODES = new FunctionSignature(
+ new QName("distinct-ordered-nodes", Function.BUILTIN_FUNCTION_NS),
+ "Returns nodes in document order with duplicates removed.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("nodes", Type.NODE, Cardinality.ZERO_OR_MORE, "The nodes to deduplicate and order")
+ },
+ new FunctionReturnSequenceType(Type.NODE, Cardinality.ZERO_OR_MORE, "the deduplicated nodes in document order"));
+
+ public FnDistinctOrderedNodes(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence nodes = args[0];
+ if (nodes.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ // ValueSequence with noDups=true handles both document ordering and deduplication
+ final ValueSequence result = new ValueSequence(true);
+ result.addAll(nodes);
+ result.removeDuplicates();
+ return result;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDivideDecimals.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDivideDecimals.java
new file mode 100644
index 00000000000..0ebd7c732f0
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDivideDecimals.java
@@ -0,0 +1,119 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.DecimalValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+
+import org.exist.xquery.functions.map.MapType;
+
+import org.exist.xquery.value.AtomicValue;
+import org.exist.xquery.value.Item;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+/**
+ * Implements XQuery 4.0 fn:divide-decimals.
+ *
+ * fn:divide-decimals($value, $divisor, $precision?) returns a record with
+ * quotient and remainder fields.
+ */
+public class FnDivideDecimals extends BasicFunction {
+
+ public static final FunctionSignature[] FN_DIVIDE_DECIMALS = {
+ new FunctionSignature(
+ new QName("divide-decimals", Function.BUILTIN_FUNCTION_NS),
+ "Divides one decimal by another to specified precision, returning quotient and remainder.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.DECIMAL, Cardinality.EXACTLY_ONE, "The dividend"),
+ new FunctionParameterSequenceType("divisor", Type.DECIMAL, Cardinality.EXACTLY_ONE, "The divisor"),
+ new FunctionParameterSequenceType("precision", Type.INTEGER, Cardinality.ZERO_OR_ONE, "Decimal precision (default: 0)")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.EXACTLY_ONE, "record with quotient and remainder")),
+ new FunctionSignature(
+ new QName("divide-decimals", Function.BUILTIN_FUNCTION_NS),
+ "Divides one decimal by another returning integer quotient and remainder.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.DECIMAL, Cardinality.EXACTLY_ONE, "The dividend"),
+ new FunctionParameterSequenceType("divisor", Type.DECIMAL, Cardinality.EXACTLY_ONE, "The divisor")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.EXACTLY_ONE, "record with quotient and remainder"))
+ };
+
+ public FnDivideDecimals(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final BigDecimal value = toBigDecimal(args[0].itemAt(0));
+ final BigDecimal divisor = toBigDecimal(args[1].itemAt(0));
+
+ if (divisor.compareTo(BigDecimal.ZERO) == 0) {
+ throw new XPathException(this, ErrorCodes.FOAR0001, "Division by zero");
+ }
+
+ int precision = 0;
+ if (args.length > 2 && !args[2].isEmpty()) {
+ precision = (int) ((IntegerValue) args[2].itemAt(0)).getLong();
+ }
+
+ // Quotient: truncate toward zero to given precision
+ final BigDecimal quotient = value.divide(divisor, precision, RoundingMode.DOWN);
+ final BigDecimal remainder = value.subtract(quotient.multiply(divisor));
+
+ // Build result record (map)
+ final MapType result = new MapType(this, context);
+ result.add(new StringValue(this, "quotient"), new DecimalValue(this, quotient));
+ result.add(new StringValue(this, "remainder"), new DecimalValue(this, remainder));
+
+ return result;
+ }
+
+ private BigDecimal toBigDecimal(final Item item) throws XPathException {
+ final AtomicValue av = item.atomize();
+ if (av instanceof DecimalValue) {
+ return ((DecimalValue) av).getValue();
+ }
+ // xs:integer is a subtype of xs:decimal — use string to avoid long truncation
+ if (av instanceof IntegerValue) {
+ return new BigDecimal(av.getStringValue());
+ }
+ // Fallback: convert to decimal
+ return ((DecimalValue) av.convertTo(Type.DECIMAL)).getValue();
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDuplicateValues.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDuplicateValues.java
new file mode 100644
index 00000000000..356b53b6826
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnDuplicateValues.java
@@ -0,0 +1,126 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import com.ibm.icu.text.Collator;
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.AtomicValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceIterator;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+/**
+ * Implements fn:duplicate-values (XQuery 4.0).
+ *
+ * Returns the values that appear more than once in the input sequence.
+ */
+public class FnDuplicateValues extends BasicFunction {
+
+ public static final FunctionSignature[] FN_DUPLICATE_VALUES = {
+ new FunctionSignature(
+ new QName("duplicate-values", Function.BUILTIN_FUNCTION_NS),
+ "Returns those values that appear more than once in the input sequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("values", Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_MORE, "The input values")
+ },
+ new FunctionReturnSequenceType(Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_MORE, "the duplicate values")),
+ new FunctionSignature(
+ new QName("duplicate-values", Function.BUILTIN_FUNCTION_NS),
+ "Returns those values that appear more than once in the input sequence, using the specified collation.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("values", Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_MORE, "The input values"),
+ new FunctionParameterSequenceType("collation", Type.STRING, Cardinality.ZERO_OR_ONE, "The collation URI")
+ },
+ new FunctionReturnSequenceType(Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_MORE, "the duplicate values"))
+ };
+
+ public FnDuplicateValues(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence values = args[0];
+ if (values.getItemCount() <= 1) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final Collator collator = getCollator(args);
+
+ // Use contextual equality (fn:compare = 0) per XQ4 spec
+ final java.util.List seen = new java.util.ArrayList<>();
+ final java.util.List reported = new java.util.ArrayList<>();
+ final ValueSequence result = new ValueSequence();
+
+ for (final SequenceIterator i = values.iterate(); i.hasNext(); ) {
+ final Item item = i.nextItem();
+ final AtomicValue value = item.atomize();
+
+ boolean isDuplicate = false;
+ for (final AtomicValue prev : seen) {
+ if (FnAllEqualDifferent.contextuallyEqual(prev, value, collator)) {
+ isDuplicate = true;
+ break;
+ }
+ }
+
+ if (isDuplicate) {
+ // Check if we already reported this value
+ boolean alreadyReported = false;
+ for (final AtomicValue rep : reported) {
+ if (FnAllEqualDifferent.contextuallyEqual(rep, value, collator)) {
+ alreadyReported = true;
+ break;
+ }
+ }
+ if (!alreadyReported) {
+ result.add(value);
+ reported.add(value);
+ }
+ } else {
+ seen.add(value);
+ }
+ }
+ return result;
+ }
+
+ private Collator getCollator(final Sequence[] args) throws XPathException {
+ if (args.length > 1 && !args[1].isEmpty()) {
+ final String collationURI = args[1].getStringValue();
+ return context.getCollator(collationURI, ErrorCodes.FOCH0002);
+ }
+ return context.getDefaultCollator();
+ }
+
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnElementToMap.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnElementToMap.java
new file mode 100644
index 00000000000..5635586ee7a
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnElementToMap.java
@@ -0,0 +1,458 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.functions.array.ArrayType;
+import org.exist.xquery.functions.map.MapType;
+import org.exist.xquery.value.*;
+import org.w3c.dom.*;
+
+import javax.xml.XMLConstants;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Implements XQuery 4.0 fn:element-to-map.
+ *
+ * Converts an element node to a map representation following the XQ4 spec rules
+ * for different content models (empty, simple, record, list, sequence, mixed).
+ */
+public class FnElementToMap extends BasicFunction {
+
+ public static final FunctionSignature[] FN_ELEMENT_TO_MAP = {
+ new FunctionSignature(
+ new QName("element-to-map", Function.BUILTIN_FUNCTION_NS),
+ "Converts an element to a map representation.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("element", Type.ELEMENT, Cardinality.ZERO_OR_ONE, "The element to convert")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.ZERO_OR_ONE, "The map representation")),
+ new FunctionSignature(
+ new QName("element-to-map", Function.BUILTIN_FUNCTION_NS),
+ "Converts an element to a map representation with options.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("element", Type.ELEMENT, Cardinality.ZERO_OR_ONE, "The element to convert"),
+ new FunctionParameterSequenceType("options", Type.MAP_ITEM, Cardinality.ZERO_OR_ONE, "Options map")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.ZERO_OR_ONE, "The map representation"))
+ };
+
+ private static final String DEFAULT_ATTR_MARKER = "@";
+ private static final String DEFAULT_CONTENT_KEY = "#content";
+ private static final String DEFAULT_COMMENT_KEY = "#comment";
+ private static final String DEFAULT_NAME_FORMAT = "eqname";
+
+ public FnElementToMap(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final Node node = ((NodeValue) args[0].itemAt(0)).getNode();
+ if (node.getNodeType() != Node.ELEMENT_NODE) {
+ throw new XPathException(this, ErrorCodes.XPTY0004, "Expected element node");
+ }
+
+ // Parse options
+ String nameFormat = DEFAULT_NAME_FORMAT;
+ String attrMarker = DEFAULT_ATTR_MARKER;
+ String contentKey = DEFAULT_CONTENT_KEY;
+ String commentKey = DEFAULT_COMMENT_KEY;
+
+ if (args.length > 1 && !args[1].isEmpty()) {
+ final MapType options = (MapType) args[1].itemAt(0);
+ final Sequence nfSeq = options.get(new StringValue(this, "name-format"));
+ if (nfSeq != null && !nfSeq.isEmpty()) {
+ nameFormat = nfSeq.getStringValue();
+ }
+ final Sequence amSeq = options.get(new StringValue(this, "attribute-marker"));
+ if (amSeq != null && !amSeq.isEmpty()) {
+ attrMarker = amSeq.getStringValue();
+ }
+ final Sequence ckSeq = options.get(new StringValue(this, "content-key"));
+ if (ckSeq != null && !ckSeq.isEmpty()) {
+ contentKey = ckSeq.getStringValue();
+ }
+ final Sequence cmSeq = options.get(new StringValue(this, "comment-key"));
+ if (cmSeq != null && !cmSeq.isEmpty()) {
+ commentKey = cmSeq.getStringValue();
+ }
+ }
+
+ final Options opts = new Options(nameFormat, attrMarker, contentKey, commentKey);
+ return convertElement((Element) node, opts);
+ }
+
+ private MapType convertElement(final Element elem, final Options opts) throws XPathException {
+ final String elemName = formatName(elem, opts);
+ final Sequence value = convertContent(elem, opts);
+
+ MapType result = new MapType(this, context);
+ result = (MapType) result.put(new StringValue(this, elemName), value);
+ return result;
+ }
+
+ private Sequence convertContent(final Element elem, final Options opts) throws XPathException {
+ // Collect attributes (excluding xmlns and xsi:type)
+ final Map attrs = new LinkedHashMap<>();
+ final NamedNodeMap attrNodes = elem.getAttributes();
+ if (attrNodes != null) {
+ for (int i = 0; i < attrNodes.getLength(); i++) {
+ final Attr attr = (Attr) attrNodes.item(i);
+ final String attrName = attr.getName();
+ // Skip namespace declarations and xsi:type
+ if (attrName.startsWith("xmlns") && (attrName.length() == 5 || attrName.charAt(5) == ':')) {
+ continue;
+ }
+ if ("xsi:type".equals(attrName)) {
+ continue;
+ }
+ if (attrName.equals("xsi:nil")) {
+ continue;
+ }
+ final String key = opts.attrMarker + formatAttrName(attr, opts);
+ attrs.put(key, attr.getValue());
+ }
+ }
+
+ // Check for xsi:nil
+ final String nilAttr = elem.getAttributeNS(XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI, "nil");
+ if ("true".equals(nilAttr) || "1".equals(nilAttr)) {
+ if (attrs.isEmpty()) {
+ // Return fn:null() as QName
+ return new QNameValue(this, context, new QName("null", Function.BUILTIN_FUNCTION_NS, "fn"));
+ } else {
+ MapType attrMap = new MapType(this, context);
+ for (final Map.Entry a : attrs.entrySet()) {
+ attrMap = (MapType) attrMap.put(new StringValue(this, a.getKey()), new StringValue(this, a.getValue()));
+ }
+ attrMap = (MapType) attrMap.put(
+ new StringValue(this, opts.contentKey),
+ new QNameValue(this, context, new QName("null", Function.BUILTIN_FUNCTION_NS, "fn")));
+ return attrMap;
+ }
+ }
+
+ // Collect child nodes (elements, text, comments, PIs)
+ final List children = new ArrayList<>();
+ final NodeList childNodes = elem.getChildNodes();
+ for (int i = 0; i < childNodes.getLength(); i++) {
+ final Node child = childNodes.item(i);
+ switch (child.getNodeType()) {
+ case Node.ELEMENT_NODE:
+ case Node.TEXT_NODE:
+ case Node.CDATA_SECTION_NODE:
+ case Node.COMMENT_NODE:
+ children.add(child);
+ break;
+ default:
+ break;
+ }
+ }
+
+ // Classify content model
+ final boolean hasElements = children.stream().anyMatch(n -> n.getNodeType() == Node.ELEMENT_NODE);
+ final boolean hasTextContent = children.stream().anyMatch(n ->
+ (n.getNodeType() == Node.TEXT_NODE || n.getNodeType() == Node.CDATA_SECTION_NODE)
+ && !n.getTextContent().trim().isEmpty());
+ final boolean hasComments = children.stream().anyMatch(n -> n.getNodeType() == Node.COMMENT_NODE);
+
+ // Empty element
+ if (children.isEmpty() || (!hasElements && !hasTextContent && !hasComments)) {
+ if (attrs.isEmpty()) {
+ return new StringValue(this, "");
+ } else {
+ // Empty-plus: attributes only, no #content key
+ MapType attrMap = new MapType(this, context);
+ for (final Map.Entry a : attrs.entrySet()) {
+ attrMap = (MapType) attrMap.put(new StringValue(this, a.getKey()), new StringValue(this, a.getValue()));
+ }
+ return attrMap;
+ }
+ }
+
+ // Simple text content (no child elements)
+ if (!hasElements && !hasComments) {
+ final String textContent = getTextContent(children);
+ if (attrs.isEmpty()) {
+ return new StringValue(this, textContent);
+ } else {
+ return buildAttrMap(attrs, new StringValue(this, textContent), opts);
+ }
+ }
+
+ // Mixed content (has both text and element children)
+ if (hasTextContent && hasElements) {
+ return buildMixedContent(children, attrs, opts);
+ }
+
+ // Element-only content — determine layout
+ final List childElements = new ArrayList<>();
+ for (final Node child : children) {
+ if (child.getNodeType() == Node.ELEMENT_NODE) {
+ childElements.add((Element) child);
+ }
+ }
+
+ // Check for comments interleaved with elements
+ if (hasComments && !hasElements) {
+ return buildMixedContent(children, attrs, opts);
+ }
+
+ // Check if all children have the same name (list pattern)
+ final boolean allSameName = childElements.size() > 1 &&
+ childElements.stream().allMatch(e ->
+ formatName(e, opts).equals(formatName(childElements.get(0), opts)));
+
+ // Check if all children have unique names (record pattern)
+ final Map> groupedByName = new LinkedHashMap<>();
+ for (final Element child : childElements) {
+ groupedByName.computeIfAbsent(formatName(child, opts), k -> new ArrayList<>()).add(child);
+ }
+ final boolean allUnique = groupedByName.values().stream().allMatch(l -> l.size() == 1);
+
+ if (allSameName) {
+ // List layout: array of child values
+ return buildListContent(childElements, attrs, opts);
+ } else if (allUnique) {
+ // Record layout: map of child name → value
+ return buildRecordContent(childElements, attrs, children, opts);
+ } else {
+ // Sequence layout: array of child maps
+ return buildSequenceContent(children, attrs, opts);
+ }
+ }
+
+ private Sequence buildAttrMap(final Map attrs, final Sequence contentValue, final Options opts) throws XPathException {
+ MapType attrMap = new MapType(this, context);
+ for (final Map.Entry a : attrs.entrySet()) {
+ attrMap = (MapType) attrMap.put(new StringValue(this, a.getKey()), new StringValue(this, a.getValue()));
+ }
+ attrMap = (MapType) attrMap.put(new StringValue(this, opts.contentKey), contentValue);
+ return attrMap;
+ }
+
+ private Sequence buildListContent(final List children, final Map attrs, final Options opts) throws XPathException {
+ // Array of child content values
+ final List items = new ArrayList<>();
+ for (final Element child : children) {
+ items.add(convertContent(child, opts));
+ }
+ final ArrayType array = new ArrayType(this, context, items);
+
+ if (attrs.isEmpty()) {
+ return array;
+ } else {
+ MapType attrMap = new MapType(this, context);
+ for (final Map.Entry a : attrs.entrySet()) {
+ attrMap = (MapType) attrMap.put(new StringValue(this, a.getKey()), new StringValue(this, a.getValue()));
+ }
+ attrMap = (MapType) attrMap.put(new StringValue(this, opts.contentKey), array);
+ return attrMap;
+ }
+ }
+
+ private Sequence buildRecordContent(final List childElements, final Map attrs,
+ final List allChildren, final Options opts) throws XPathException {
+ MapType recordMap = new MapType(this, context);
+
+ // Add attributes first
+ for (final Map.Entry a : attrs.entrySet()) {
+ recordMap = (MapType) recordMap.put(new StringValue(this, a.getKey()), new StringValue(this, a.getValue()));
+ }
+
+ // Add comments if present
+ for (final Node child : allChildren) {
+ if (child.getNodeType() == Node.COMMENT_NODE) {
+ recordMap = (MapType) recordMap.put(
+ new StringValue(this, opts.commentKey),
+ new StringValue(this, child.getTextContent()));
+ }
+ }
+
+ // Add child elements
+ for (final Element child : childElements) {
+ final String childName = formatName(child, opts);
+ final Sequence childValue = convertContent(child, opts);
+ recordMap = (MapType) recordMap.put(new StringValue(this, childName), childValue);
+ }
+
+ return recordMap;
+ }
+
+ private Sequence buildSequenceContent(final List children, final Map attrs, final Options opts) throws XPathException {
+ // Build array of child maps/values
+ final List items = new ArrayList<>();
+ for (final Node child : children) {
+ if (child.getNodeType() == Node.ELEMENT_NODE) {
+ items.add(convertElement((Element) child, opts));
+ } else if (child.getNodeType() == Node.TEXT_NODE || child.getNodeType() == Node.CDATA_SECTION_NODE) {
+ final String text = child.getTextContent();
+ if (!text.trim().isEmpty()) {
+ items.add(new StringValue(this, text));
+ }
+ } else if (child.getNodeType() == Node.COMMENT_NODE) {
+ MapType commentMap = new MapType(this, context);
+ commentMap = (MapType) commentMap.put(
+ new StringValue(this, opts.commentKey),
+ new StringValue(this, child.getTextContent()));
+ items.add(commentMap);
+ }
+ }
+ final ArrayType array = new ArrayType(this, context, items);
+
+ if (attrs.isEmpty()) {
+ return array;
+ } else {
+ MapType attrMap = new MapType(this, context);
+ for (final Map.Entry a : attrs.entrySet()) {
+ attrMap = (MapType) attrMap.put(new StringValue(this, a.getKey()), new StringValue(this, a.getValue()));
+ }
+ attrMap = (MapType) attrMap.put(new StringValue(this, opts.contentKey), array);
+ return attrMap;
+ }
+ }
+
+ private Sequence buildMixedContent(final List children, final Map attrs, final Options opts) throws XPathException {
+ final List items = new ArrayList<>();
+ for (final Node child : children) {
+ switch (child.getNodeType()) {
+ case Node.ELEMENT_NODE:
+ items.add(convertElement((Element) child, opts));
+ break;
+ case Node.TEXT_NODE:
+ case Node.CDATA_SECTION_NODE:
+ final String text = child.getTextContent();
+ if (!text.isEmpty()) {
+ items.add(new StringValue(this, text));
+ }
+ break;
+ case Node.COMMENT_NODE:
+ MapType commentMap = new MapType(this, context);
+ commentMap = (MapType) commentMap.put(
+ new StringValue(this, opts.commentKey),
+ new StringValue(this, child.getTextContent()));
+ items.add(commentMap);
+ break;
+ default:
+ break;
+ }
+ }
+ final ArrayType array = new ArrayType(this, context, items);
+
+ if (attrs.isEmpty()) {
+ return array;
+ } else {
+ MapType attrMap = new MapType(this, context);
+ for (final Map.Entry a : attrs.entrySet()) {
+ attrMap = (MapType) attrMap.put(new StringValue(this, a.getKey()), new StringValue(this, a.getValue()));
+ }
+ attrMap = (MapType) attrMap.put(new StringValue(this, opts.contentKey), array);
+ return attrMap;
+ }
+ }
+
+ private String formatName(final Element elem, final Options opts) {
+ final String ns = elem.getNamespaceURI();
+ final String local = elem.getLocalName() != null ? elem.getLocalName() : elem.getTagName();
+
+ switch (opts.nameFormat) {
+ case "eqname":
+ if (ns != null && !ns.isEmpty()) {
+ return "Q{" + ns + "}" + local;
+ }
+ return local;
+ case "lexical":
+ final String prefix = elem.getPrefix();
+ if (prefix != null && !prefix.isEmpty()) {
+ return prefix + ":" + local;
+ }
+ return local;
+ case "local":
+ return local;
+ default:
+ // Default to eqname
+ if (ns != null && !ns.isEmpty()) {
+ return "Q{" + ns + "}" + local;
+ }
+ return local;
+ }
+ }
+
+ private String formatAttrName(final Attr attr, final Options opts) {
+ final String ns = attr.getNamespaceURI();
+ final String local = attr.getLocalName() != null ? attr.getLocalName() : attr.getName();
+
+ switch (opts.nameFormat) {
+ case "eqname":
+ if (ns != null && !ns.isEmpty()) {
+ return "Q{" + ns + "}" + local;
+ }
+ return local;
+ case "lexical":
+ final String prefix = attr.getPrefix();
+ if (prefix != null && !prefix.isEmpty()) {
+ return prefix + ":" + local;
+ }
+ return local;
+ case "local":
+ return local;
+ default:
+ if (ns != null && !ns.isEmpty()) {
+ return "Q{" + ns + "}" + local;
+ }
+ return local;
+ }
+ }
+
+ private static String getTextContent(final List children) {
+ final StringBuilder sb = new StringBuilder();
+ for (final Node child : children) {
+ if (child.getNodeType() == Node.TEXT_NODE || child.getNodeType() == Node.CDATA_SECTION_NODE) {
+ sb.append(child.getTextContent());
+ }
+ }
+ return sb.toString();
+ }
+
+ private static class Options {
+ final String nameFormat;
+ final String attrMarker;
+ final String contentKey;
+ final String commentKey;
+
+ Options(final String nameFormat, final String attrMarker, final String contentKey, final String commentKey) {
+ this.nameFormat = nameFormat;
+ this.attrMarker = attrMarker;
+ this.contentKey = contentKey;
+ this.commentKey = commentKey;
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnElementToMapPlan.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnElementToMapPlan.java
new file mode 100644
index 00000000000..57afc1dc54e
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnElementToMapPlan.java
@@ -0,0 +1,263 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.functions.map.MapType;
+import org.exist.xquery.value.*;
+import org.w3c.dom.*;
+
+import java.util.*;
+
+/**
+ * fn:element-to-map-plan($input as node()*) as map(*)
+ *
+ * Analyzes the structure of input elements and returns a plan map
+ * describing the layout of each element type encountered.
+ *
+ * Layout values: empty, empty-plus, simple, simple-plus, list, list-plus,
+ * record, mixed.
+ */
+public class FnElementToMapPlan extends BasicFunction {
+
+ public static final FunctionSignature FN_ELEMENT_TO_MAP_PLAN = new FunctionSignature(
+ new QName("element-to-map-plan", Function.BUILTIN_FUNCTION_NS),
+ "Analyzes the structure of input elements and returns a plan map.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.NODE,
+ Cardinality.ZERO_OR_MORE, "The input nodes to analyze")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.EXACTLY_ONE,
+ "A map describing the element layouts"));
+
+ public FnElementToMapPlan(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (args[0].isEmpty()) {
+ return new MapType(this, context);
+ }
+
+ final MapType plan = new MapType(this, context);
+ final Set processed = new HashSet<>();
+
+ // Analyze each input node
+ for (final SequenceIterator iter = args[0].iterate(); iter.hasNext(); ) {
+ final Item item = iter.nextItem();
+ if (item.getType() == Type.DOCUMENT) {
+ // For document nodes, analyze the document element
+ final Node docNode = ((NodeValue) item).getNode();
+ analyzeNode(docNode, plan, processed);
+ } else if (Type.subTypeOf(item.getType(), Type.ELEMENT)) {
+ final Node elemNode = ((NodeValue) item).getNode();
+ analyzeElement(elemNode, plan, processed);
+ }
+ }
+
+ return plan;
+ }
+
+ private void analyzeNode(final Node node, final MapType plan, final Set processed) throws XPathException {
+ final NodeList children = node.getChildNodes();
+ for (int i = 0; i < children.getLength(); i++) {
+ final Node child = children.item(i);
+ if (child.getNodeType() == Node.ELEMENT_NODE) {
+ analyzeElement(child, plan, processed);
+ }
+ }
+ }
+
+ private void analyzeElement(final Node elem, final MapType plan, final Set processed) throws XPathException {
+ final String elemKey = getElementKey(elem);
+ if (processed.contains(elemKey)) {
+ return; // Already analyzed this element type
+ }
+ processed.add(elemKey);
+
+ final MapType layoutMap = new MapType(this, context);
+
+ // Determine layout
+ final boolean hasAttributes = hasSignificantAttributes(elem);
+ final List childElements = getChildElements(elem);
+ final boolean hasTextContent = hasSignificantTextContent(elem);
+
+ if (childElements.isEmpty() && !hasTextContent) {
+ // Empty element
+ layoutMap.add(new StringValue("layout"),
+ new StringValue(hasAttributes ? "empty-plus" : "empty"));
+ } else if (childElements.isEmpty() && hasTextContent) {
+ // Simple content (text only)
+ final String type = detectContentType(elem);
+ layoutMap.add(new StringValue("layout"),
+ new StringValue(hasAttributes ? "simple-plus" : "simple"));
+ if (type != null) {
+ layoutMap.add(new StringValue("type"), new StringValue(type));
+ }
+ } else if (!hasTextContent && allChildrenSameName(childElements)) {
+ // List of same-named elements
+ final String childName = getElementKey(childElements.get(0));
+ layoutMap.add(new StringValue("layout"),
+ new StringValue(hasAttributes ? "list-plus" : "list"));
+ layoutMap.add(new StringValue("child"), new StringValue(childName));
+ } else if (hasTextContent || hasMixedContent(elem)) {
+ // Mixed content
+ layoutMap.add(new StringValue("layout"), new StringValue("mixed"));
+ } else {
+ // Record (distinct child element names)
+ layoutMap.add(new StringValue("layout"), new StringValue("record"));
+ }
+
+ plan.add(new StringValue(elemKey), layoutMap);
+
+ // Analyze attribute types
+ if (hasAttributes) {
+ final NamedNodeMap attrs = elem.getAttributes();
+ for (int i = 0; i < attrs.getLength(); i++) {
+ final Node attr = attrs.item(i);
+ final String attrName = attr.getLocalName() != null ? attr.getLocalName() : attr.getNodeName();
+ final String ns = attr.getNamespaceURI();
+ // Skip xmlns declarations
+ if ("http://www.w3.org/2000/xmlns/".equals(ns) || attrName.startsWith("xmlns")) {
+ continue;
+ }
+ final String attrKey = "@" + (ns != null && !ns.isEmpty() ?
+ "Q{" + ns + "}" + attrName : attrName);
+ if (!processed.contains(attrKey)) {
+ processed.add(attrKey);
+ final MapType attrMap = new MapType(this, context);
+ final String type = detectValueType(attr.getNodeValue());
+ if (type != null) {
+ attrMap.add(new StringValue("type"), new StringValue(type));
+ }
+ plan.add(new StringValue(attrKey), attrMap);
+ }
+ }
+ }
+
+ // Recursively analyze child elements
+ for (final Node child : childElements) {
+ analyzeElement(child, plan, processed);
+ }
+ }
+
+ private String getElementKey(final Node elem) {
+ final String ns = elem.getNamespaceURI();
+ final String local = elem.getLocalName() != null ? elem.getLocalName() : elem.getNodeName();
+ if (ns != null && !ns.isEmpty()) {
+ return "Q{" + ns + "}" + local;
+ }
+ return local;
+ }
+
+ private boolean hasSignificantAttributes(final Node elem) {
+ final NamedNodeMap attrs = elem.getAttributes();
+ if (attrs == null) return false;
+ for (int i = 0; i < attrs.getLength(); i++) {
+ final Node attr = attrs.item(i);
+ final String name = attr.getNodeName();
+ if (!name.startsWith("xmlns")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private List getChildElements(final Node elem) {
+ final List result = new ArrayList<>();
+ final NodeList children = elem.getChildNodes();
+ for (int i = 0; i < children.getLength(); i++) {
+ if (children.item(i).getNodeType() == Node.ELEMENT_NODE) {
+ result.add(children.item(i));
+ }
+ }
+ return result;
+ }
+
+ private boolean hasSignificantTextContent(final Node elem) {
+ final NodeList children = elem.getChildNodes();
+ for (int i = 0; i < children.getLength(); i++) {
+ final Node child = children.item(i);
+ if (child.getNodeType() == Node.TEXT_NODE) {
+ final String text = child.getNodeValue();
+ if (text != null && !text.trim().isEmpty()) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean hasMixedContent(final Node elem) {
+ boolean hasElements = false;
+ boolean hasText = false;
+ final NodeList children = elem.getChildNodes();
+ for (int i = 0; i < children.getLength(); i++) {
+ final Node child = children.item(i);
+ if (child.getNodeType() == Node.ELEMENT_NODE) {
+ hasElements = true;
+ } else if (child.getNodeType() == Node.TEXT_NODE) {
+ if (child.getNodeValue() != null && !child.getNodeValue().trim().isEmpty()) {
+ hasText = true;
+ }
+ }
+ }
+ return hasElements && hasText;
+ }
+
+ private boolean allChildrenSameName(final List children) {
+ if (children.isEmpty()) return false;
+ final String firstName = getElementKey(children.get(0));
+ for (int i = 1; i < children.size(); i++) {
+ if (!firstName.equals(getElementKey(children.get(i)))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private String detectContentType(final Node elem) {
+ final String text = elem.getTextContent();
+ if (text == null || text.trim().isEmpty()) {
+ return null;
+ }
+ return detectValueType(text.trim());
+ }
+
+ private String detectValueType(final String value) {
+ if (value == null || value.isEmpty()) {
+ return null;
+ }
+ try {
+ Double.parseDouble(value);
+ return "numeric";
+ } catch (final NumberFormatException e) {
+ // Not numeric
+ }
+ if ("true".equals(value) || "false".equals(value)) {
+ return "boolean";
+ }
+ return null; // default: string (not annotated)
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnEverySome.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnEverySome.java
new file mode 100644
index 00000000000..ee18e143012
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnEverySome.java
@@ -0,0 +1,177 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.AnalyzeContextInfo;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.BooleanValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceIterator;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+/**
+ * Implements XQuery 4.0 fn:every and fn:some.
+ */
+public class FnEverySome extends BasicFunction {
+
+ public static final FunctionSignature[] FN_EVERY = {
+ new FunctionSignature(
+ new QName("every", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if every item in the input sequence matches the supplied predicate.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("predicate", Type.FUNCTION, Cardinality.ZERO_OR_ONE, "The predicate function (defaults to fn:boolean#1)")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if all items match")),
+ new FunctionSignature(
+ new QName("every", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if every item in the input sequence has an effective boolean value of true.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if all items are truthy"))
+ };
+
+ public static final FunctionSignature[] FN_SOME = {
+ new FunctionSignature(
+ new QName("some", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if at least one item in the input sequence matches the supplied predicate.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("predicate", Type.FUNCTION, Cardinality.ZERO_OR_ONE, "The predicate function (defaults to fn:boolean#1)")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if any item matches")),
+ new FunctionSignature(
+ new QName("some", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if at least one item in the input sequence has an effective boolean value of true.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if any item is truthy"))
+ };
+
+ private AnalyzeContextInfo cachedContextInfo;
+
+ public FnEverySome(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ cachedContextInfo = new AnalyzeContextInfo(contextInfo);
+ super.analyze(cachedContextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence input = args[0];
+ final boolean isEvery = isCalledAs("every");
+
+ // 1-arg overload: use effective boolean value
+ if (args.length == 1) {
+ return evalWithEBV(input, isEvery);
+ }
+
+ // 2-arg overload: use predicate function (empty predicate = use EBV)
+ if (args[1].isEmpty()) {
+ return evalWithEBV(input, isEvery);
+ }
+
+ if (input.isEmpty()) {
+ return BooleanValue.valueOf(isEvery);
+ }
+
+ try (final FunctionReference ref = (FunctionReference) args[1].itemAt(0)) {
+ ref.analyze(cachedContextInfo);
+ final int arity = ref.getSignature().getArgumentCount();
+
+ // Validate arity: predicate must accept 0, 1, or 2 arguments
+ if (arity > 2) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Predicate function must accept 0, 1, or 2 arguments, but has arity " + arity);
+ }
+
+ int pos = 1;
+ for (final SequenceIterator i = input.iterate(); i.hasNext(); pos++) {
+ final Item item = i.nextItem();
+ final Sequence r = callPredicate(ref, item, pos, arity);
+ // XQ4: predicate must return xs:boolean (xs:untypedAtomic is coercible)
+ if (!r.isEmpty()) {
+ final int rType = r.itemAt(0).getType();
+ if (rType != Type.BOOLEAN && rType != Type.UNTYPED_ATOMIC) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Predicate function must return xs:boolean, but returned "
+ + Type.getTypeName(rType));
+ }
+ }
+ final boolean matches = !r.isEmpty() && r.effectiveBooleanValue();
+ if (isEvery && !matches) {
+ return BooleanValue.FALSE;
+ }
+ if (!isEvery && matches) {
+ return BooleanValue.TRUE;
+ }
+ }
+ return BooleanValue.valueOf(isEvery);
+ }
+ }
+
+ private Sequence evalWithEBV(final Sequence input, final boolean isEvery) throws XPathException {
+ if (input.isEmpty()) {
+ return BooleanValue.valueOf(isEvery);
+ }
+ for (final SequenceIterator i = input.iterate(); i.hasNext(); ) {
+ final Item item = i.nextItem();
+ final boolean ebv = item.toSequence().effectiveBooleanValue();
+ if (isEvery && !ebv) {
+ return BooleanValue.FALSE;
+ }
+ if (!isEvery && ebv) {
+ return BooleanValue.TRUE;
+ }
+ }
+ return BooleanValue.valueOf(isEvery);
+ }
+
+ private Sequence callPredicate(final FunctionReference ref, final Item item, final int pos, final int arity) throws XPathException {
+ if (arity == 0) {
+ return ref.evalFunction(null, null, new Sequence[0]);
+ } else if (arity == 1) {
+ return ref.evalFunction(null, null, new Sequence[]{item.toSequence()});
+ } else {
+ return ref.evalFunction(null, null, new Sequence[]{item.toSequence(), new IntegerValue(this, pos)});
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnExpandedQName.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnExpandedQName.java
new file mode 100644
index 00000000000..7dc2190314a
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnExpandedQName.java
@@ -0,0 +1,74 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.QNameValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+
+/**
+ * Implements fn:expanded-QName (XQuery 4.0).
+ *
+ * Returns a string in Q{uri}local format for a QName value.
+ */
+public class FnExpandedQName extends BasicFunction {
+
+ public static final FunctionSignature FN_EXPANDED_QNAME = new FunctionSignature(
+ new QName("expanded-QName", Function.BUILTIN_FUNCTION_NS),
+ "Returns the expanded QName in Q{uri}local notation.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.QNAME, Cardinality.ZERO_OR_ONE,
+ "The QName value")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_ONE,
+ "the expanded QName string in Q{uri}local format"));
+
+ public FnExpandedQName(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final QNameValue qnameVal = (QNameValue) args[0].itemAt(0);
+ final QName qname = qnameVal.getQName();
+
+ final String ns = qname.getNamespaceURI() != null ? qname.getNamespaceURI() : "";
+ final String local = qname.getLocalPart();
+
+ return new StringValue(this, "Q{" + ns + "}" + local);
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatDates.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatDates.java
index 2ade21d3117..c39b28f29a6 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatDates.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatDates.java
@@ -21,12 +21,17 @@
*/
package org.exist.xquery.functions.fn;
+import com.ibm.icu.text.MessageFormat;
+import com.ibm.icu.text.RuleBasedNumberFormat;
+import org.apache.commons.lang3.StringUtils;
import org.exist.dom.QName;
import org.exist.xquery.*;
import org.exist.xquery.util.NumberFormatter;
import org.exist.xquery.value.*;
+import java.util.ArrayList;
import java.util.Calendar;
+import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.TimeZone;
@@ -152,6 +157,7 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
final String picture = args[1].getStringValue();
final String language;
final Optional place;
+ String calendar = null;
if (getArgumentCount() == 5) {
if (args[2].hasOne()) {
language = args[2].getStringValue();
@@ -159,6 +165,10 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
language = context.getDefaultLanguage();
}
+ if (args[3].hasOne()) {
+ calendar = args[3].getStringValue();
+ }
+
if(args[4].hasOne()) {
place = Optional.of(args[4].getStringValue());
} else {
@@ -169,6 +179,32 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
place = Optional.empty();
}
+ // Validate calendar parameter
+ if (calendar != null) {
+ if (calendar.startsWith(":")) {
+ throw new XPathException(this, ErrorCodes.FOFD1340,
+ "Invalid calendar name: " + calendar);
+ }
+ if (calendar.startsWith("Q{}")) {
+ final String localPart = calendar.substring(3);
+ if (localPart.isEmpty() || !Character.isLetter(localPart.charAt(0))) {
+ throw new XPathException(this, ErrorCodes.FOFD1340,
+ "Invalid calendar name: " + calendar);
+ }
+ if (!isKnownCalendar(localPart)) {
+ throw new XPathException(this, ErrorCodes.FOFD1340,
+ "Unknown calendar: " + calendar);
+ }
+ } else if (calendar.startsWith("Q{") && calendar.contains("}")) {
+ // EQName with non-empty namespace: accept with fallback
+ } else if (calendar.contains(":")) {
+ // Prefixed QName: accept with fallback
+ } else if (!isKnownCalendar(calendar)) {
+ throw new XPathException(this, ErrorCodes.FOFD1340,
+ "Unknown calendar: " + calendar);
+ }
+ }
+
return new StringValue(this, formatDate(picture, value, language, place));
}
@@ -214,6 +250,8 @@ private String formatDate(String pic, AbstractDateTimeValue dt, final String lan
private void formatComponent(String component, AbstractDateTimeValue dt, final String language,
final Optional place, final boolean tzHMZNPictureHint, final StringBuilder sb)
throws XPathException {
+ // Per spec, whitespace within a variable marker is insignificant
+ component = component.replaceAll("\\s+", "");
final Matcher matcher = componentPattern.matcher(component);
if (!matcher.matches()) {
throw new XPathException(this, ErrorCodes.FOFD1340, "Unrecognized date/time component: " + component);
@@ -349,8 +387,8 @@ private void formatComponent(String component, AbstractDateTimeValue dt, final S
break;
case 'f':
if (allowTime) {
- final int fraction = dt.getPart(AbstractDateTimeValue.MILLISECOND);
- formatNumber(specifier, picture, width, fraction, language, sb);
+ final int millis = dt.getPart(AbstractDateTimeValue.MILLISECOND);
+ formatFractionalSeconds(millis, picture, width, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350,
"format-date does not support a fractional seconds component");
@@ -384,85 +422,255 @@ private void formatComponent(String component, AbstractDateTimeValue dt, final S
sb.append(formatTimeZone(picture,
dtv.getPart(DurationValue.HOUR), minute, cal.getTimeZone(), language, place));
+ } else if ("Z".equals(picture)) {
+ // Military timezone: J = local time (no timezone specified)
+ sb.append("J");
}
break;
+ case 'E':
+ if (allowDate) {
+ final int year = dt.getPart(AbstractDateTimeValue.YEAR);
+ sb.append(year >= 0 ? "AD" : "BC");
+ } else {
+ throw new XPathException(this, ErrorCodes.FOFD1350,
+ "format-time does not support an era component");
+ }
+ break;
+ case 'C':
+ sb.append("AD");
+ break;
default:
throw new XPathException(this, ErrorCodes.FOFD1340, "Unrecognized date/time component: " + component);
}
}
- private String formatTimeZone(final String timezonePicture, final int hour, final int minute,
+ private String formatTimeZone(String timezonePicture, final int hour, final int minute,
final TimeZone timeZone, final String language, final Optional place) {
- final Locale locale = new Locale(language);
+ // Military timezone letter
+ if ("Z".equals(timezonePicture)) {
+ return formatMilitaryTimeZone(hour, minute);
+ }
- final String format;
- switch(timezonePicture) {
- case "0":
- if(minute != 0) {
- format = "%+d:%02d";
+ // Named timezone
+ if ("N".equals(timezonePicture)) {
+ final Locale locale = new Locale(language);
+ final TimeZone tz = place.map(TimeZone::getTimeZone).orElse(timeZone);
+ return tz.getDisplayName(timeZone.useDaylightTime(), TimeZone.SHORT, locale);
+ }
+
+ // Check for 't' modifier (use "Z" for UTC)
+ final boolean useZForUTC = timezonePicture.endsWith("t");
+ if (useZForUTC) {
+ timezonePicture = timezonePicture.substring(0, timezonePicture.length() - 1);
+ }
+ if (useZForUTC && hour == 0 && minute == 0) {
+ return "Z";
+ }
+
+ // Parse the picture: find digit family, separator, hour/minute digit counts
+ int zero = '0';
+ boolean zeroFound = false;
+ int hourDigits = 0;
+ int minuteDigits = 0;
+ String separator = null;
+
+ for (int i = 0; i < timezonePicture.length(); i++) {
+ final int ch = timezonePicture.codePointAt(i);
+ final int family = net.sf.saxon.expr.number.Alphanumeric.getDigitFamily(ch);
+ if (family >= 0) {
+ if (!zeroFound) { zero = family; zeroFound = true; }
+ if (separator == null) { hourDigits++; } else { minuteDigits++; }
+ } else if (ch == '#') {
+ if (separator == null) { hourDigits++; } else { minuteDigits++; }
+ } else if (separator == null && hourDigits > 0) {
+ separator = new String(Character.toChars(ch));
+ }
+ if (Character.isSupplementaryCodePoint(ch)) { i++; }
+ }
+
+ final int absHour = Math.abs(hour);
+ final String sign = (hour < 0) ? "-" : "+";
+ final StringBuilder result = new StringBuilder(sign);
+
+ if (separator != null && minuteDigits > 0) {
+ result.append(padWithDigitFamily(absHour, hourDigits, zero));
+ result.append(separator);
+ result.append(padWithDigitFamily(minute, minuteDigits, zero));
+ } else if (hourDigits >= 3) {
+ result.append(padWithDigitFamily(absHour * 100 + minute, hourDigits, zero));
+ } else {
+ result.append(padWithDigitFamily(absHour, hourDigits, zero));
+ if (minute != 0) {
+ result.append(":");
+ result.append(padWithDigitFamily(minute, 2, zero));
+ }
+ }
+
+ return result.toString();
+ }
+
+ private static String padWithDigitFamily(int value, int minDigits, int zero) {
+ String s = Integer.toString(value);
+ while (s.length() < minDigits) { s = "0" + s; }
+ if (zero != '0') {
+ final StringBuilder converted = new StringBuilder();
+ for (int i = 0; i < s.length(); i++) {
+ final char ch = s.charAt(i);
+ if (ch >= '0' && ch <= '9') {
+ converted.appendCodePoint(zero + (ch - '0'));
} else {
- format = "%+d";
+ converted.append(ch);
}
- break;
+ }
+ return converted.toString();
+ }
+ return s;
+ }
- case "0000":
- format = "%+03d%02d";
- break;
+ // Military timezone: Z(0), A-I(+1 to +9), K-M(+10 to +12), N-Y(-1 to -12)
+ // J is reserved for local time (no timezone) and is NOT in this array
+ private final static char[] MILITARY_TZ_CHARS = {'Z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'K', 'L',
+ 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y' };
- case "0:00":
- format = "%+d:%02d";
- break;
+ private String formatMilitaryTimeZone(final int hour, final int minute) {
+ if (minute == 0 && hour >= -12 && hour <= 12) {
+ final int offset = (hour < 0) ? 12 + (hour * -1) : hour;
+ return String.valueOf(MILITARY_TZ_CHARS[offset]);
+ } else {
+ return String.format("%+03d:%02d", hour, minute);
+ }
+ }
+
+ /**
+ * Format fractional seconds as left-aligned digits.
+ * Unlike regular integer formatting, fractional seconds treat the value
+ * as a fraction (0.456) where digits are extracted left-to-right.
+ */
+ private void formatFractionalSeconds(int millis, String picture, String width,
+ StringBuilder sb) throws XPathException {
+ // Build the fractional digit string, left-aligned, padded to 3 digits
+ String fracDigits = String.format("%03d", millis);
+
+ // Count actual digit positions in picture (ignoring separators and modifiers)
+ int picMin = 0;
+ int picMax = 0;
+ for (int i = 0; i < picture.length(); i++) {
+ final char ch = picture.charAt(i);
+ if ((ch == 'o' || ch == 'c') && i == picture.length() - 1) { break; }
+ final int family = net.sf.saxon.expr.number.Alphanumeric.getDigitFamily(ch);
+ if (family >= 0) {
+ picMin++;
+ picMax++;
+ } else if (ch == '#') {
+ picMax++;
+ }
+ }
+
+ int min = picMin;
+ // A multi-digit picture constrains max precision; single-digit is unbounded
+ final boolean pictureSetsMax = (picMax > 1);
+ int max = pictureSetsMax ? picMax : Integer.MAX_VALUE;
- case "00:00t":
- if(hour == 0 && minute == 0) {
- format = "Z";
+ // Width specifier
+ final int[] widths = getWidths(width);
+ if (widths != null) {
+ if (widths[0] > 0) { min = Math.max(picMin, widths[0]); }
+ if (widths[1] > 0) {
+ if (pictureSetsMax) {
+ max = Math.max(picMax, widths[1]);
} else {
- format = "%+03d:%02d";
+ max = widths[1];
}
- break;
+ }
+ }
+ if (max < min) { max = min; }
- case "N":
- final TimeZone tz = place.map(TimeZone::getTimeZone).orElse(timeZone);
- return tz.getDisplayName(timeZone.useDaylightTime(), TimeZone.SHORT, locale);
+ // Pad to min with trailing zeros
+ while (fracDigits.length() < min) {
+ fracDigits += "0";
+ }
- case "Z":
- return formatMilitaryTimeZone(hour, minute);
+ // Truncate to max precision
+ if (fracDigits.length() > max) {
+ fracDigits = fracDigits.substring(0, max);
+ }
- case "00:00":
- default:
- format = "%+03d:%02d";
+ // Remove trailing zeros beyond min (variable-width output)
+ while (fracDigits.length() > min && fracDigits.endsWith("0")) {
+ fracDigits = fracDigits.substring(0, fracDigits.length() - 1);
+ }
+
+ // Apply digit family from picture (e.g., Arabic-Indic digits)
+ final int digitSign = getFirstDigitInPicture(picture);
+ if (digitSign >= 0) {
+ final int zero = net.sf.saxon.expr.number.Alphanumeric.getDigitFamily(digitSign);
+ if (zero != '0') {
+ final StringBuilder converted = new StringBuilder();
+ for (int i = 0; i < fracDigits.length(); i++) {
+ final char ch = fracDigits.charAt(i);
+ if (ch >= '0' && ch <= '9') {
+ converted.append((char)(zero + (ch - '0')));
+ } else {
+ converted.append(ch);
+ }
+ }
+ fracDigits = converted.toString();
+ }
+ }
+
+ // Insert grouping separators from picture if present
+ if (hasGroupingSeparators(picture)) {
+ fracDigits = applyGroupingSeparators(fracDigits, picture);
}
- return String.format(locale, format, hour, minute);
+ sb.append(fracDigits);
}
- private final static char[] MILITARY_TZ_CHARS = {'Z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L',
- 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y' };
+ private static int getFirstDigitInPicture(String picture) {
+ for (int i = 0; i < picture.length(); i++) {
+ final char ch = picture.charAt(i);
+ if (ch != '#' && ch != 'o' && ch != 'c') {
+ final int family = net.sf.saxon.expr.number.Alphanumeric.getDigitFamily(ch);
+ if (family >= 0) {
+ return ch;
+ }
+ }
+ }
+ return -1;
+ }
- /**
- * Military time zone
- *
- * Z = +00:00, A = +01:00, B = +02:00, ..., M = +12:00, N = -01:00, O = -02:00, ... Y = -12:00.
- *
- * The letter J (meaning local time) is used in the case of a value that does not specify a timezone
- * offset.
- *
- * Timezone offsets that have no representation in this system (for example Indian Standard Time, +05:30)
- * are output as if the format 01:01 had been requested.
- */
- private String formatMilitaryTimeZone(final int hour, final int minute) {
- if(minute == 0 && hour > -12 && hour < 12) {
- final int offset;
- if(hour < 0) {
- offset = 13 + (hour * -1);
+ private static boolean hasGroupingSeparators(String picture) {
+ for (int i = 0; i < picture.length(); i++) {
+ final char ch = picture.charAt(i);
+ if ((ch == 'o' || ch == 'c') && i == picture.length() - 1) { break; }
+ final int family = net.sf.saxon.expr.number.Alphanumeric.getDigitFamily(ch);
+ if (family < 0 && ch != '#') {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String applyGroupingSeparators(String digits, String picture) {
+ final StringBuilder result = new StringBuilder();
+ int digitIdx = 0;
+ for (int i = 0; i < picture.length() && digitIdx < digits.length(); i++) {
+ final char ch = picture.charAt(i);
+ if ((ch == 'o' || ch == 'c') && i == picture.length() - 1) { break; }
+ final int family = net.sf.saxon.expr.number.Alphanumeric.getDigitFamily(ch);
+ if (family >= 0 || ch == '#') {
+ result.append(digits.charAt(digitIdx));
+ digitIdx++;
} else {
- offset = hour;
+ result.append(ch);
}
- return String.valueOf(MILITARY_TZ_CHARS[offset]);
- } else {
- return String.format("%+03d:%02d", hour, minute);
}
+ while (digitIdx < digits.length()) {
+ result.append(digits.charAt(digitIdx));
+ digitIdx++;
+ }
+ return result.toString();
}
private String getDefaultFormat(char specifier) {
@@ -512,6 +720,80 @@ private void formatNumber(char specifier, String picture, String width, int num,
return;
}
+ // Word formatting: W (uppercase), w (lowercase), Ww (title case)
+ // With optional ordinal modifier: Wo, wo, Wwo
+ final String basePicture = picture.endsWith("o") ? picture.substring(0, picture.length() - 1) : picture;
+ final boolean ordinalWords = picture.endsWith("o") && (basePicture.equals("W") || basePicture.equals("w") || basePicture.equals("Ww"));
+ if ("W".equals(basePicture) || "w".equals(basePicture) || "Ww".equals(basePicture)) {
+ final Locale locale = new Locale(language);
+ final String spelloutRule = ordinalWords ? "%spellout-ordinal" : "%spellout-cardinal";
+
+ // Check if the rule exists, fall back to cardinal if ordinal not available
+ final RuleBasedNumberFormat rbnf = new RuleBasedNumberFormat(locale, RuleBasedNumberFormat.SPELLOUT);
+ String ruleToUse = spelloutRule;
+ boolean ruleFound = false;
+ for (final String ruleName : rbnf.getRuleSetNames()) {
+ if (ruleName.equals(ruleToUse)) {
+ ruleFound = true;
+ break;
+ }
+ }
+ if (!ruleFound) {
+ ruleToUse = "%spellout-cardinal";
+ }
+
+ final MessageFormat fmt = new MessageFormat("{0,spellout," + ruleToUse + "}", locale);
+ String word = fmt.format(new Object[]{num});
+
+ if ("W".equals(basePicture)) {
+ word = word.toUpperCase(locale);
+ } else if ("Ww".equals(basePicture)) {
+ // Title case: capitalize each word
+ final String[] parts = word.split("((?<=[ -])|(?=[ -]))");
+ final StringBuilder titled = new StringBuilder();
+ for (final String part : parts) {
+ titled.append(StringUtils.capitalize(part));
+ }
+ word = titled.toString();
+ }
+ // "w" is already lowercase from ICU4J
+
+ sb.append(word);
+ return;
+ }
+
+ // Roman numeral formatting: I (uppercase), i (lowercase)
+ if ("I".equals(picture) || "i".equals(picture)) {
+ String roman = toRoman(Math.abs(num));
+ if ("i".equals(picture)) {
+ roman = roman.toLowerCase();
+ }
+ sb.append(roman);
+ return;
+ }
+
+ // Handle grouping separators in numeric pictures (e.g., [Y9;999], [Y9,999,*])
+ if (hasGroupingSeparators(picture)) {
+ sb.append(formatWithGroupingSeparators(num, picture));
+ return;
+ }
+
+ // Validate optional digit placement: # must precede mandatory digits, not follow
+ boolean seenMandatory = false;
+ for (int i = 0; i < picture.length(); i++) {
+ final char ch = picture.charAt(i);
+ if ((ch == 'o' || ch == 'c') && i == picture.length() - 1) { break; }
+ if (ch == '#') {
+ if (seenMandatory) {
+ throw new XPathException(this, ErrorCodes.FOFD1340,
+ "Optional digit '#' must not appear after mandatory digits in: " + picture);
+ }
+ } else {
+ final int family = net.sf.saxon.expr.number.Alphanumeric.getDigitFamily(ch);
+ if (family >= 0) { seenMandatory = true; }
+ }
+ }
+
// determine min and max width
int min = NumberFormatter.getMinDigits(picture);
int max = NumberFormatter.getMaxDigits(picture);
@@ -531,6 +813,83 @@ private void formatNumber(char specifier, String picture, String width, int num,
}
}
+ private static final int[] ROMAN_VALUES = {1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1};
+ private static final String[] ROMAN_SYMBOLS = {"M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"};
+
+ private static String toRoman(int num) {
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < ROMAN_VALUES.length; i++) {
+ while (num >= ROMAN_VALUES[i]) {
+ sb.append(ROMAN_SYMBOLS[i]);
+ num -= ROMAN_VALUES[i];
+ }
+ }
+ return sb.toString();
+ }
+
+ private static String formatWithGroupingSeparators(int num, String picture) {
+ String pic = picture;
+ if (pic.endsWith("o") || pic.endsWith("c")) { pic = pic.substring(0, pic.length() - 1); }
+ if (pic.endsWith(",*")) { pic = pic.substring(0, pic.length() - 2); }
+
+ int zero = '0';
+ for (int i = 0; i < pic.length(); i++) {
+ final int family = net.sf.saxon.expr.number.Alphanumeric.getDigitFamily(pic.charAt(i));
+ if (family >= 0) { zero = family; break; }
+ }
+
+ // Map separator positions (counted from the right)
+ final List sepPositions = new ArrayList<>();
+ final List sepChars = new ArrayList<>();
+ int digitCount = 0;
+ for (int i = pic.length() - 1; i >= 0; i--) {
+ final char ch = pic.charAt(i);
+ final int family = net.sf.saxon.expr.number.Alphanumeric.getDigitFamily(ch);
+ if (family >= 0 || ch == '#') {
+ digitCount++;
+ } else {
+ sepPositions.add(digitCount);
+ sepChars.add(ch);
+ }
+ }
+
+ final String digits = Integer.toString(num);
+ final StringBuilder result = new StringBuilder();
+ int digitIdx = digits.length() - 1;
+ int pos = 0;
+ while (digitIdx >= 0) {
+ for (int s = 0; s < sepPositions.size(); s++) {
+ if (sepPositions.get(s) == pos && pos > 0) {
+ result.insert(0, sepChars.get(s));
+ }
+ }
+ result.insert(0, digits.charAt(digitIdx));
+ digitIdx--;
+ pos++;
+ }
+
+ if (zero != '0') {
+ final StringBuilder converted = new StringBuilder();
+ for (int i = 0; i < result.length(); i++) {
+ final char ch = result.charAt(i);
+ if (ch >= '0' && ch <= '9') {
+ converted.append((char)(zero + (ch - '0')));
+ } else {
+ converted.append(ch);
+ }
+ }
+ return converted.toString();
+ }
+ return result.toString();
+ }
+
+ private static boolean isKnownCalendar(final String calendar) {
+ return switch (calendar.toUpperCase()) {
+ case "AD", "ISO", "OS", "NS" -> true;
+ default -> false;
+ };
+ }
+
private int[] getWidths(String width) throws XPathException {
if (width == null || width.isEmpty())
{return null;}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java
index 3633d2c71fc..7be81fbf44d 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java
@@ -83,6 +83,7 @@
import org.exist.dom.QName;
import org.exist.util.CodePointString;
import org.exist.xquery.*;
+import org.exist.xquery.functions.map.MapType;
import org.exist.xquery.value.*;
import javax.annotation.Nullable;
@@ -125,7 +126,7 @@ public class FnFormatNumbers extends BasicFunction {
arity(
FS_PARAM_VALUE,
FS_PARAM_PICTURE,
- optParam("decimal-format-name", Type.STRING, "The name (as an EQName) of a decimal format to use.")
+ optParam("options", Type.ITEM, "The name (as an EQName) of a decimal format, or a map of formatting options (XQuery 4.0).")
)
)
);
@@ -138,22 +139,8 @@ public FnFormatNumbers(final XQueryContext context, final FunctionSignature sign
public Sequence eval(final Sequence[] args, final Sequence contextSequence)
throws XPathException {
- // get the decimal format
- final QName qnDecimalFormat;
- if (args.length == 3 && !args[2].isEmpty()) {
- final String decimalFormatName = args[2].itemAt(0).getStringValue().trim();
- try {
- qnDecimalFormat = QName.parse(context, decimalFormatName);
- } catch (final QName.IllegalQNameException e) {
- throw new XPathException(this, ErrorCodes.FODF1280, "Invalid decimal format QName.", args[2], e);
- }
- } else {
- qnDecimalFormat = null;
- }
- final DecimalFormat decimalFormat = context.getStaticDecimalFormat(qnDecimalFormat);
- if (decimalFormat == null) {
- throw new XPathException(this, ErrorCodes.FODF1280, "No known decimal format of that name.", args[2]);
- }
+ // Resolve decimal format from the options argument (XQ4: string or map)
+ final DecimalFormat decimalFormat = resolveDecimalFormat(args);
final NumericValue number;
if (args[0].isEmpty()) {
@@ -171,6 +158,145 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence)
return new StringValue(this, value);
}
+ /**
+ * Resolves the decimal format from the 3rd argument.
+ * XQ3.1: absent or xs:string (decimal format name).
+ * XQ4: map(*) with formatting properties and optional format-name.
+ */
+ private DecimalFormat resolveDecimalFormat(final Sequence[] args) throws XPathException {
+ if (args.length < 3 || args[2].isEmpty()) {
+ // No options — use unnamed default
+ final DecimalFormat df = context.getStaticDecimalFormat(null);
+ if (df == null) {
+ throw new XPathException(this, ErrorCodes.FODF1280, "No unnamed decimal format in static context.");
+ }
+ return df;
+ }
+
+ final Item optionsItem = args[2].itemAt(0);
+
+ if (optionsItem instanceof MapType) {
+ // XQ4 map overload
+ return resolveDecimalFormatFromMap((MapType) optionsItem);
+ }
+
+ // XQ3.1 string overload (decimal format name)
+ final String decimalFormatName = optionsItem.getStringValue().trim();
+ final QName qnDecimalFormat;
+ try {
+ qnDecimalFormat = QName.parse(context, decimalFormatName);
+ } catch (final QName.IllegalQNameException e) {
+ throw new XPathException(this, ErrorCodes.FODF1280, "Invalid decimal format QName.", args[2], e);
+ }
+ final DecimalFormat df = context.getStaticDecimalFormat(qnDecimalFormat);
+ if (df == null) {
+ throw new XPathException(this, ErrorCodes.FODF1280, "No known decimal format of that name.", args[2]);
+ }
+ return df;
+ }
+
+ /**
+ * Resolves a decimal format from an XQ4 options map.
+ * The map can contain format-name (to select a base format) and
+ * individual property overrides (decimal-separator, grouping-separator, etc.).
+ *
+ * Properties use the char:rendition pattern — a single character is both
+ * marker and rendition; "char:string" splits marker from rendition.
+ * For this implementation, only the marker (first character) is used for
+ * picture string analysis; the rendition is used for output formatting.
+ */
+ private DecimalFormat resolveDecimalFormatFromMap(final MapType map) throws XPathException {
+ // Start with the named or unnamed base format
+ final Sequence formatNameSeq = map.get(new StringValue(this, "format-name"));
+ DecimalFormat base;
+ if (formatNameSeq != null && !formatNameSeq.isEmpty()) {
+ final String formatName = formatNameSeq.itemAt(0).getStringValue().trim();
+ final QName qn;
+ try {
+ qn = QName.parse(context, formatName);
+ } catch (final QName.IllegalQNameException e) {
+ throw new XPathException(this, ErrorCodes.FODF1280, "Invalid format-name in options map.", formatNameSeq, e);
+ }
+ base = context.getStaticDecimalFormat(qn);
+ if (base == null) {
+ throw new XPathException(this, ErrorCodes.FODF1280, "No known decimal format: " + formatName);
+ }
+ } else {
+ base = context.getStaticDecimalFormat(null);
+ if (base == null) {
+ base = DecimalFormat.UNNAMED;
+ }
+ }
+
+ // Override individual properties from the map, extracting char:rendition
+ final CharRendition decSep = getCharRenditionProperty(map, "decimal-separator", base.decimalSeparator);
+ final CharRendition grpSep = getCharRenditionProperty(map, "grouping-separator", base.groupingSeparator);
+ final CharRendition expSep = getCharRenditionProperty(map, "exponent-separator", base.exponentSeparator);
+ final CharRendition pct = getCharRenditionProperty(map, "percent", base.percent);
+ final CharRendition pml = getCharRenditionProperty(map, "per-mille", base.perMille);
+ final int zeroDigit = getCharProperty(map, "zero-digit", base.zeroDigit);
+ final int digit = getCharProperty(map, "digit", base.digit);
+ final int patternSeparator = getCharProperty(map, "pattern-separator", base.patternSeparator);
+ final int minusSign = getCharProperty(map, "minus-sign", base.minusSign);
+ final String infinity = getStringProperty(map, "infinity", base.infinity);
+ final String nan = getStringProperty(map, "NaN", base.NaN);
+
+ return new DecimalFormat(decSep.marker(), expSep.marker(), grpSep.marker(),
+ pct.marker(), pml.marker(), zeroDigit, digit, patternSeparator, infinity, nan, minusSign,
+ decSep.rendition(), expSep.rendition(), grpSep.rendition(),
+ pct.rendition(), pml.rendition());
+ }
+
+ /**
+ * Result of parsing a char:rendition property value.
+ * Marker is used for picture string parsing; rendition for output.
+ */
+ private record CharRendition(int marker, String rendition) {}
+
+ /**
+ * Extracts a single-character property from the map, handling the
+ * char:rendition pattern. Returns marker (first char) and rendition.
+ * If the property is absent, returns the default marker with null rendition.
+ */
+ private CharRendition getCharRenditionProperty(final MapType map, final String key, final int defaultValue) throws XPathException {
+ final Sequence seq = map.get(new StringValue(this, key));
+ if (seq == null || seq.isEmpty()) {
+ return new CharRendition(defaultValue, null);
+ }
+ final String value = seq.itemAt(0).getStringValue();
+ if (value.isEmpty()) {
+ throw new XPathException(this, ErrorCodes.FODF1280,
+ "Decimal format property '" + key + "' must not be empty.");
+ }
+ final int marker = value.codePointAt(0);
+ final int markerLen = Character.charCount(marker);
+ // char:rendition pattern: "X:rendition" where X is the marker
+ if (value.length() > markerLen && value.charAt(markerLen) == ':') {
+ final String rendition = value.substring(markerLen + 1);
+ return new CharRendition(marker, rendition);
+ }
+ return new CharRendition(marker, null);
+ }
+
+ /**
+ * Extracts a single-character property (no rendition support).
+ */
+ private int getCharProperty(final MapType map, final String key, final int defaultValue) throws XPathException {
+ return getCharRenditionProperty(map, key, defaultValue).marker();
+ }
+
+ /**
+ * Extracts a string property from the map.
+ * If absent, returns the default.
+ */
+ private String getStringProperty(final MapType map, final String key, final String defaultValue) throws XPathException {
+ final Sequence seq = map.get(new StringValue(this, key));
+ if (seq == null || seq.isEmpty()) {
+ return defaultValue;
+ }
+ return seq.itemAt(0).getStringValue();
+ }
+
enum AnalyzeState {
MANTISSA_PART,
INTEGER_PART,
@@ -715,19 +841,58 @@ private String format(final NumericValue number, final DecimalFormat decimalForm
if (minimumExponentSize > 0) {
formatted.append(decimalFormat.exponentSeparator);
- final CodePointString expStr = new CodePointString(String.valueOf(exp));
+ // Handle negative exponents: pad the absolute value, then prepend sign
+ final boolean negativeExp = exp < 0;
+ final CodePointString expStr = new CodePointString(String.valueOf(Math.abs(exp)));
final int expPadLen = subPicture.getMinimumExponentSize() - expStr.length();
if (expPadLen > 0) {
expStr.leftPad(decimalFormat.zeroDigit, expPadLen);
}
+ if (negativeExp) {
+ expStr.insert(0, decimalFormat.minusSign);
+ }
+
formatted.append(expStr);
}
// Rule 14 - concatenate prefix, formatted number, and suffix
- final String result = subPicture.getPrefixString() + formatted + subPicture.getSuffixString();
+ String result = subPicture.getPrefixString() + formatted + subPicture.getSuffixString();
+
+ // XQ4: Apply char:rendition substitutions — replace marker characters with
+ // their rendition strings in the final output
+ result = applyRenditions(result, decimalFormat);
+
+ return result;
+ }
+ /**
+ * XQ4 char:rendition: replace marker characters with their rendition strings
+ * in the formatted output. Only applies when a rendition differs from the
+ * marker (i.e., the property was specified as "marker:rendition").
+ */
+ private static String applyRenditions(String result, final DecimalFormat df) {
+ final String decMarker = new String(Character.toChars(df.decimalSeparator));
+ if (!decMarker.equals(df.decimalSeparatorRendition)) {
+ result = result.replace(decMarker, df.decimalSeparatorRendition);
+ }
+ final String grpMarker = new String(Character.toChars(df.groupingSeparator));
+ if (!grpMarker.equals(df.groupingSeparatorRendition)) {
+ result = result.replace(grpMarker, df.groupingSeparatorRendition);
+ }
+ final String expMarker = new String(Character.toChars(df.exponentSeparator));
+ if (!expMarker.equals(df.exponentSeparatorRendition)) {
+ result = result.replace(expMarker, df.exponentSeparatorRendition);
+ }
+ final String pctMarker = new String(Character.toChars(df.percent));
+ if (!pctMarker.equals(df.percentRendition)) {
+ result = result.replace(pctMarker, df.percentRendition);
+ }
+ final String pmlMarker = new String(Character.toChars(df.perMille));
+ if (!pmlMarker.equals(df.perMilleRendition)) {
+ result = result.replace(pmlMarker, df.perMilleRendition);
+ }
return result;
}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFunctionAnnotations.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFunctionAnnotations.java
new file mode 100644
index 00000000000..6bdfd09ceeb
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFunctionAnnotations.java
@@ -0,0 +1,84 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.functions.map.MapType;
+import org.exist.xquery.value.*;
+
+/**
+ * Implements fn:function-annotations (XQuery 4.0).
+ *
+ * Returns annotations on a function item as a sequence of single-entry maps,
+ * where each map has the annotation QName as key and annotation values as value.
+ */
+public class FnFunctionAnnotations extends BasicFunction {
+
+ public static final FunctionSignature FN_FUNCTION_ANNOTATIONS = new FunctionSignature(
+ new QName("function-annotations", Function.BUILTIN_FUNCTION_NS),
+ "Returns the annotations of a function item as a sequence of single-entry maps.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("function", Type.FUNCTION,
+ Cardinality.EXACTLY_ONE, "The function item to inspect")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.ZERO_OR_MORE,
+ "A sequence of single-entry maps, one per annotation"));
+
+ public FnFunctionAnnotations(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Item funcItem = args[0].itemAt(0);
+ if (!(funcItem instanceof FunctionReference ref)) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final FunctionSignature sig = ref.getSignature();
+ final Annotation[] annotations = sig.getAnnotations();
+ if (annotations == null || annotations.length == 0) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final ValueSequence result = new ValueSequence(annotations.length);
+ for (final Annotation ann : annotations) {
+ final MapType map = new MapType(this, context);
+ final QNameValue qnameKey = new QNameValue(this, context, ann.getName());
+
+ // Build annotation values sequence
+ final LiteralValue[] values = ann.getValue();
+ if (values == null || values.length == 0) {
+ map.add(qnameKey, Sequence.EMPTY_SEQUENCE);
+ } else {
+ final ValueSequence valSeq = new ValueSequence(values.length);
+ for (final LiteralValue lv : values) {
+ valSeq.add(lv.getValue());
+ }
+ map.add(qnameKey, valSeq);
+ }
+ result.add(map);
+ }
+ return result;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFunctionIdentity.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFunctionIdentity.java
new file mode 100644
index 00000000000..e4c1fdadf07
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnFunctionIdentity.java
@@ -0,0 +1,100 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.functions.array.ArrayType;
+import org.exist.xquery.functions.map.AbstractMapType;
+import org.exist.xquery.value.*;
+
+import java.util.IdentityHashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Implements fn:function-identity (XQuery 4.0).
+ *
+ * Returns a string that uniquely identifies a function item. Two calls with
+ * the same function return codepoint-equal strings; calls with different
+ * functions return different strings.
+ *
+ * For named functions, identity is based on QName + arity.
+ * For anonymous functions, maps, and arrays, identity is based on object identity.
+ */
+public class FnFunctionIdentity extends BasicFunction {
+
+ /** Counter for assigning unique IDs to anonymous function items, maps, and arrays. */
+ private static final AtomicLong ID_COUNTER = new AtomicLong(1);
+
+ /** Identity-based map to ensure the same object always gets the same ID.
+ * Uses reference equality (==), not equals(), so structurally equal but
+ * distinct maps/arrays get different IDs per the spec. */
+ private static final Map IDENTITY_MAP = new IdentityHashMap<>();
+
+ private static synchronized long getOrAssignId(final Object obj) {
+ return IDENTITY_MAP.computeIfAbsent(obj, k -> ID_COUNTER.getAndIncrement());
+ }
+
+ public static final FunctionSignature FN_FUNCTION_IDENTITY = new FunctionSignature(
+ new QName("function-identity", Function.BUILTIN_FUNCTION_NS),
+ "Returns a string that uniquely identifies a function item.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("function", Type.ITEM,
+ Cardinality.EXACTLY_ONE, "The function item to identify")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE,
+ "A string uniquely identifying the function"));
+
+ public FnFunctionIdentity(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Item funcItem = args[0].itemAt(0);
+ return new StringValue(this, computeIdentity(funcItem));
+ }
+
+ private static String computeIdentity(final Item item) throws XPathException {
+ if (item instanceof FunctionReference ref) {
+ final FunctionSignature sig = ref.getSignature();
+ final QName name = sig.getName();
+ if (name != null && name != InlineFunction.INLINE_FUNCTION_QNAME) {
+ // Named function: identity based on expanded QName + arity
+ return "Q{" + (name.getNamespaceURI() != null ? name.getNamespaceURI() : "")
+ + "}" + name.getLocalPart() + "#" + sig.getArgumentCount();
+ }
+ // Anonymous function: use counter-based identity
+ return "anon@" + getOrAssignId(ref);
+ }
+ if (item instanceof AbstractMapType) {
+ // Each distinct map object gets a unique ID
+ return "map@" + getOrAssignId(item);
+ }
+ if (item instanceof ArrayType) {
+ return "array@" + getOrAssignId(item);
+ }
+ // Fallback for other function types
+ return "func@" + getOrAssignId(item);
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnGet.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnGet.java
new file mode 100644
index 00000000000..a5c1b7d57cc
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnGet.java
@@ -0,0 +1,91 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.functions.array.ArrayType;
+import org.exist.xquery.functions.map.AbstractMapType;
+import org.exist.xquery.value.*;
+
+/**
+ * fn:get($key as xs:anyAtomicType) as item()*
+ *
+ * XQuery 4.0 context-dependent lookup function. Looks up a value from
+ * the context item:
+ * - For arrays: returns the member at the given position
+ * - For maps: returns the value for the given key
+ * - For atomic values: returns the value itself (identity)
+ */
+public class FnGet extends BasicFunction {
+
+ public static final FunctionSignature FN_GET = new FunctionSignature(
+ new QName("get", Function.BUILTIN_FUNCTION_NS),
+ "Looks up a value from the context item. For arrays, returns the member " +
+ "at the given position. For maps, returns the value for the given key.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("key", Type.ANY_ATOMIC_TYPE,
+ Cardinality.EXACTLY_ONE, "The lookup key or index")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE,
+ "The looked-up value"));
+
+ public FnGet(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ // Get the context item
+ Sequence ctxSeq = contextSequence;
+ if (ctxSeq == null || ctxSeq.isEmpty()) {
+ throw new XPathException(this, ErrorCodes.XPDY0002,
+ "fn:get requires a context item");
+ }
+
+ final Item contextItem = ctxSeq.itemAt(0);
+ final AtomicValue key = (AtomicValue) args[0].itemAt(0);
+
+ if (contextItem instanceof ArrayType) {
+ // Array lookup by position
+ final ArrayType array = (ArrayType) contextItem;
+ final int index = ((IntegerValue) key.convertTo(Type.INTEGER)).getInt();
+ if (index < 1 || index > array.getSize()) {
+ throw new XPathException(this, ErrorCodes.FOAY0001,
+ "Array index " + index + " out of bounds (1.." + array.getSize() + ")");
+ }
+ return array.get(index - 1);
+ } else if (contextItem instanceof AbstractMapType) {
+ // Map lookup by key
+ final AbstractMapType map = (AbstractMapType) contextItem;
+ final Sequence value = map.get(key);
+ return value != null ? value : Sequence.EMPTY_SEQUENCE;
+ } else if (contextItem instanceof FunctionReference) {
+ // Function application
+ final FunctionReference funcRef = (FunctionReference) contextItem;
+ return funcRef.evalFunction(null, null, new Sequence[]{key.toSequence()});
+ } else {
+ // Atomic value: return the context item itself
+ return contextItem.toSequence();
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnGraphemes.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnGraphemes.java
new file mode 100644
index 00000000000..45701961288
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnGraphemes.java
@@ -0,0 +1,86 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import com.ibm.icu.text.BreakIterator;
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+/**
+ * Implements fn:graphemes (XQuery 4.0).
+ *
+ * Splits the supplied string into a sequence of strings, each containing
+ * one Unicode extended grapheme cluster.
+ *
+ * Uses ICU4J's BreakIterator for Unicode grapheme cluster boundary detection,
+ * which handles combining marks, emoji sequences, regional indicators, etc.
+ */
+public class FnGraphemes extends BasicFunction {
+
+ public static final FunctionSignature FN_GRAPHEMES = new FunctionSignature(
+ new QName("graphemes", Function.BUILTIN_FUNCTION_NS),
+ "Splits the supplied string into a sequence of strings, each containing " +
+ "one Unicode extended grapheme cluster.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.STRING, Cardinality.ZERO_OR_ONE,
+ "The string to split into grapheme clusters")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE,
+ "a sequence of strings, each containing one grapheme cluster"));
+
+ public FnGraphemes(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ final String str = args[0].getStringValue();
+ if (str.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final BreakIterator bi = BreakIterator.getCharacterInstance();
+ bi.setText(str);
+
+ final ValueSequence result = new ValueSequence();
+ int start = bi.first();
+ for (int end = bi.next(); end != BreakIterator.DONE; start = end, end = bi.next()) {
+ result.add(new StringValue(this, str.substring(start, end)));
+ }
+ return result;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnHash.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnHash.java
new file mode 100644
index 00000000000..47a02cb8b9e
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnHash.java
@@ -0,0 +1,177 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.zip.CRC32;
+
+import org.bouncycastle.crypto.digests.Blake3Digest;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.BinaryValue;
+import org.exist.xquery.value.BinaryValueFromBinaryString;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.HexBinaryValueType;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+/**
+ * Implements fn:hash (XQuery 4.0).
+ *
+ * Returns the result of a hash/checksum function applied to the input.
+ * Supports MD5, SHA-1, SHA-256, CRC-32.
+ */
+public class FnHash extends BasicFunction {
+
+ public static final ErrorCodes.ErrorCode FOHA0001 = new ErrorCodes.ErrorCode("FOHA0001",
+ "Unsupported hash algorithm");
+
+ public static final FunctionSignature[] FN_HASH = {
+ new FunctionSignature(
+ new QName("hash", Function.BUILTIN_FUNCTION_NS),
+ "Returns the hash of the input value using the default algorithm (MD5).",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_ONE, "The value to hash (string, hexBinary, or base64Binary)")
+ },
+ new FunctionReturnSequenceType(Type.HEX_BINARY, Cardinality.ZERO_OR_ONE, "the hash value")),
+ new FunctionSignature(
+ new QName("hash", Function.BUILTIN_FUNCTION_NS),
+ "Returns the hash of the input value using the specified algorithm.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_ONE, "The value to hash (string, hexBinary, or base64Binary)"),
+ new FunctionParameterSequenceType("algorithm", Type.STRING, Cardinality.ZERO_OR_ONE, "The hash algorithm (MD5, SHA-1, SHA-256, CRC-32)")
+ },
+ new FunctionReturnSequenceType(Type.HEX_BINARY, Cardinality.ZERO_OR_ONE, "the hash value")),
+ new FunctionSignature(
+ new QName("hash", Function.BUILTIN_FUNCTION_NS),
+ "Returns the hash of the input value using the specified algorithm and options.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_ONE, "The value to hash (string, hexBinary, or base64Binary)"),
+ new FunctionParameterSequenceType("algorithm", Type.STRING, Cardinality.ZERO_OR_ONE, "The hash algorithm (MD5, SHA-1, SHA-256, CRC-32)"),
+ new FunctionParameterSequenceType("options", Type.MAP_ITEM, Cardinality.ZERO_OR_ONE, "Options map (reserved for future use)")
+ },
+ new FunctionReturnSequenceType(Type.HEX_BINARY, Cardinality.ZERO_OR_ONE, "the hash value"))
+ };
+
+ public FnHash(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ // Get the input bytes
+ final byte[] inputBytes = getInputBytes(args[0]);
+
+ // Get the algorithm
+ String algorithm = "MD5";
+ if (args.length > 1 && !args[1].isEmpty()) {
+ algorithm = args[1].getStringValue().trim().toUpperCase();
+ }
+
+ // Compute hash
+ final byte[] hashBytes;
+ if ("CRC-32".equals(algorithm) || "CRC32".equals(algorithm)) {
+ final CRC32 crc32 = new CRC32();
+ crc32.update(inputBytes);
+ final long crcValue = crc32.getValue();
+ // Return as 4-byte big-endian hexBinary
+ hashBytes = ByteBuffer.allocate(4).putInt((int) crcValue).array();
+ } else if ("BLAKE3".equals(algorithm)) {
+ final Blake3Digest blake3 = new Blake3Digest(32);
+ blake3.update(inputBytes, 0, inputBytes.length);
+ hashBytes = new byte[32];
+ blake3.doFinal(hashBytes, 0);
+ } else {
+ // Map algorithm names to Java MessageDigest names
+ final String javaAlgorithm;
+ switch (algorithm) {
+ case "MD5":
+ javaAlgorithm = "MD5";
+ break;
+ case "SHA-1":
+ case "SHA1":
+ javaAlgorithm = "SHA-1";
+ break;
+ case "SHA-256":
+ case "SHA256":
+ javaAlgorithm = "SHA-256";
+ break;
+ case "SHA-384":
+ case "SHA384":
+ javaAlgorithm = "SHA-384";
+ break;
+ case "SHA-512":
+ case "SHA512":
+ javaAlgorithm = "SHA-512";
+ break;
+ default:
+ throw new XPathException(this, FOHA0001,
+ "Unsupported hash algorithm: " + algorithm);
+ }
+ try {
+ final MessageDigest digest = MessageDigest.getInstance(javaAlgorithm);
+ hashBytes = digest.digest(inputBytes);
+ } catch (final NoSuchAlgorithmException e) {
+ throw new XPathException(this, FOHA0001,
+ "Hash algorithm not available: " + javaAlgorithm);
+ }
+ }
+
+ // Return as hexBinary — use BinaryValueFromBinaryString to avoid
+ // stream registration with the XQuery context (prevents deadlock
+ // in concurrent test execution environments)
+ final StringBuilder hex = new StringBuilder(hashBytes.length * 2);
+ for (final byte b : hashBytes) {
+ hex.append(String.format("%02X", b & 0xFF));
+ }
+ return new BinaryValueFromBinaryString(this, new HexBinaryValueType(), hex.toString());
+ }
+
+ private byte[] getInputBytes(final Sequence value) throws XPathException {
+ final int type = value.itemAt(0).getType();
+ if (Type.subTypeOf(type, Type.STRING) || Type.subTypeOf(type, Type.ANY_URI) || Type.subTypeOf(type, Type.UNTYPED_ATOMIC)) {
+ return value.getStringValue().getBytes(StandardCharsets.UTF_8);
+ } else if (Type.subTypeOf(type, Type.BASE64_BINARY) || Type.subTypeOf(type, Type.HEX_BINARY)) {
+ final BinaryValue binaryValue = (BinaryValue) value.itemAt(0);
+ return binaryValue.toJavaObject(byte[].class);
+ } else {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "fn:hash expects string, hexBinary, or base64Binary, got: " + Type.getTypeName(type));
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnHigherOrderFun40.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnHigherOrderFun40.java
new file mode 100644
index 00000000000..bbd77a86d8e
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnHigherOrderFun40.java
@@ -0,0 +1,361 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.exist.dom.QName;
+import org.exist.xquery.AnalyzeContextInfo;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceIterator;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+import org.exist.xquery.functions.array.ArrayType;
+
+/**
+ * Implements XQuery 4.0 higher-order functions:
+ * fn:index-where, fn:take-while, fn:do-until, fn:while-do, fn:sort-with,
+ * fn:scan-left, fn:scan-right.
+ */
+public class FnHigherOrderFun40 extends BasicFunction {
+
+ public static final FunctionSignature FN_INDEX_WHERE = new FunctionSignature(
+ new QName("index-where", Function.BUILTIN_FUNCTION_NS),
+ "Returns the positions of items that match the supplied predicate.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("predicate", Type.FUNCTION, Cardinality.EXACTLY_ONE, "The predicate function")
+ },
+ new FunctionReturnSequenceType(Type.INTEGER, Cardinality.ZERO_OR_MORE, "positions where the predicate is true"));
+
+ public static final FunctionSignature FN_TAKE_WHILE = new FunctionSignature(
+ new QName("take-while", Function.BUILTIN_FUNCTION_NS),
+ "Returns items from the input sequence prior to the first one that fails to match a supplied predicate.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("predicate", Type.FUNCTION, Cardinality.EXACTLY_ONE, "The predicate function")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "the leading items matching the predicate"));
+
+ public static final FunctionSignature FN_WHILE_DO = new FunctionSignature(
+ new QName("while-do", Function.BUILTIN_FUNCTION_NS),
+ "Processes a supplied value repeatedly, continuing while a condition is true.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The initial input"),
+ new FunctionParameterSequenceType("predicate", Type.FUNCTION, Cardinality.EXACTLY_ONE, "The condition to test"),
+ new FunctionParameterSequenceType("action", Type.FUNCTION, Cardinality.EXACTLY_ONE, "The action to apply")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "the first value that fails the predicate"));
+
+ public static final FunctionSignature FN_DO_UNTIL = new FunctionSignature(
+ new QName("do-until", Function.BUILTIN_FUNCTION_NS),
+ "Processes a supplied value repeatedly, continuing until a condition becomes true.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The initial input"),
+ new FunctionParameterSequenceType("action", Type.FUNCTION, Cardinality.EXACTLY_ONE, "The action to apply"),
+ new FunctionParameterSequenceType("predicate", Type.FUNCTION, Cardinality.EXACTLY_ONE, "The condition to test")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "the first value that satisfies the predicate"));
+
+ public static final FunctionSignature FN_SORT_WITH = new FunctionSignature(
+ new QName("sort-with", Function.BUILTIN_FUNCTION_NS),
+ "Sorts a sequence according to a supplied comparator function.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The sequence to sort"),
+ new FunctionParameterSequenceType("comparators", Type.FUNCTION, Cardinality.ONE_OR_MORE, "The comparator function(s)")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "the sorted sequence"));
+
+ public static final FunctionSignature FN_SCAN_LEFT = new FunctionSignature(
+ new QName("scan-left", Function.BUILTIN_FUNCTION_NS),
+ "Returns successive partial results of fold-left.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("init", Type.ITEM, Cardinality.ZERO_OR_MORE, "The initial value"),
+ new FunctionParameterSequenceType("action", Type.FUNCTION, Cardinality.EXACTLY_ONE,
+ "The accumulation function: fn(accumulator, item) as item()*")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.ZERO_OR_MORE,
+ "sequence of single-member arrays with successive fold results"));
+
+ public static final FunctionSignature FN_SCAN_RIGHT = new FunctionSignature(
+ new QName("scan-right", Function.BUILTIN_FUNCTION_NS),
+ "Returns successive partial results of fold-right.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("init", Type.ITEM, Cardinality.ZERO_OR_MORE, "The initial value"),
+ new FunctionParameterSequenceType("action", Type.FUNCTION, Cardinality.EXACTLY_ONE,
+ "The accumulation function: fn(item, accumulator) as item()*")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.ZERO_OR_MORE,
+ "sequence of single-member arrays with successive fold results"));
+
+ private AnalyzeContextInfo cachedContextInfo;
+
+ public FnHigherOrderFun40(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ cachedContextInfo = new AnalyzeContextInfo(contextInfo);
+ super.analyze(cachedContextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (isCalledAs("while-do")) {
+ return whileDo(args);
+ } else if (isCalledAs("do-until")) {
+ return doUntil(args);
+ } else if (isCalledAs("sort-with")) {
+ return sortWith(args);
+ } else if (isCalledAs("scan-left")) {
+ return scanLeft(args);
+ } else if (isCalledAs("scan-right")) {
+ return scanRight(args);
+ }
+
+ final Sequence input = args[0];
+ if (input.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ try (final FunctionReference ref = (FunctionReference) args[1].itemAt(0)) {
+ ref.analyze(cachedContextInfo);
+ final int arity = ref.getSignature().getArgumentCount();
+
+ if (isCalledAs("index-where")) {
+ return indexWhere(input, ref, arity);
+ } else {
+ return takeWhile(input, ref, arity);
+ }
+ }
+ }
+
+ private Sequence indexWhere(final Sequence input, final FunctionReference ref, final int arity) throws XPathException {
+ final ValueSequence result = new ValueSequence();
+ int pos = 1;
+ for (final SequenceIterator i = input.iterate(); i.hasNext(); pos++) {
+ final Item item = i.nextItem();
+ final Sequence r = callPredicate(ref, item, pos, arity);
+ if (!r.isEmpty() && r.effectiveBooleanValue()) {
+ result.add(new IntegerValue(this, pos));
+ }
+ }
+ return result;
+ }
+
+ private Sequence takeWhile(final Sequence input, final FunctionReference ref, final int arity) throws XPathException {
+ final ValueSequence result = new ValueSequence();
+ int pos = 1;
+ for (final SequenceIterator i = input.iterate(); i.hasNext(); pos++) {
+ final Item item = i.nextItem();
+ final Sequence r = callPredicate(ref, item, pos, arity);
+ if (r.isEmpty() || !r.effectiveBooleanValue()) {
+ break;
+ }
+ result.add(item);
+ }
+ return result;
+ }
+
+ private Sequence callPredicate(final FunctionReference ref, final Item item, final int pos, final int arity) throws XPathException {
+ if (arity == 1) {
+ return ref.evalFunction(null, null, new Sequence[]{item.toSequence()});
+ } else {
+ return ref.evalFunction(null, null, new Sequence[]{item.toSequence(), new IntegerValue(this, pos)});
+ }
+ }
+
+ private Sequence callWithSeqAndPos(final FunctionReference ref, final Sequence input, final int pos, final int arity) throws XPathException {
+ if (arity == 1) {
+ return ref.evalFunction(null, null, new Sequence[]{input});
+ } else {
+ return ref.evalFunction(null, null, new Sequence[]{input, new IntegerValue(this, pos)});
+ }
+ }
+
+ private Sequence whileDo(final Sequence[] args) throws XPathException {
+ Sequence input = args[0];
+ try (final FunctionReference predicate = (FunctionReference) args[1].itemAt(0);
+ final FunctionReference action = (FunctionReference) args[2].itemAt(0)) {
+ predicate.analyze(cachedContextInfo);
+ action.analyze(cachedContextInfo);
+ final int predArity = predicate.getSignature().getArgumentCount();
+ final int actArity = action.getSignature().getArgumentCount();
+ int pos = 1;
+ while (true) {
+ final Sequence test = callWithSeqAndPos(predicate, input, pos, predArity);
+ if (test.isEmpty() || !test.effectiveBooleanValue()) {
+ return input;
+ }
+ input = callWithSeqAndPos(action, input, pos, actArity);
+ pos++;
+ }
+ }
+ }
+
+ private Sequence doUntil(final Sequence[] args) throws XPathException {
+ Sequence input = args[0];
+ try (final FunctionReference action = (FunctionReference) args[1].itemAt(0);
+ final FunctionReference predicate = (FunctionReference) args[2].itemAt(0)) {
+ action.analyze(cachedContextInfo);
+ predicate.analyze(cachedContextInfo);
+ final int actArity = action.getSignature().getArgumentCount();
+ final int predArity = predicate.getSignature().getArgumentCount();
+ int pos = 1;
+ while (true) {
+ input = callWithSeqAndPos(action, input, pos, actArity);
+ final Sequence test = callWithSeqAndPos(predicate, input, pos, predArity);
+ if (!test.isEmpty() && test.effectiveBooleanValue()) {
+ return input;
+ }
+ pos++;
+ }
+ }
+ }
+
+ private Sequence sortWith(final Sequence[] args) throws XPathException {
+ final Sequence input = args[0];
+ if (input.getItemCount() <= 1) {
+ return input;
+ }
+ final Sequence comparators = args[1];
+
+ // Collect all items into a list
+ final List- items = new ArrayList<>(input.getItemCount());
+ for (final SequenceIterator i = input.iterate(); i.hasNext(); ) {
+ items.add(i.nextItem());
+ }
+
+ // Get the first comparator (most test cases use a single one)
+ final FunctionReference[] comparatorRefs = new FunctionReference[comparators.getItemCount()];
+ for (int c = 0; c < comparators.getItemCount(); c++) {
+ comparatorRefs[c] = (FunctionReference) comparators.itemAt(c);
+ comparatorRefs[c].analyze(cachedContextInfo);
+ }
+
+ // Sort using the comparator(s)
+ try {
+ items.sort((a, b) -> {
+ try {
+ for (final FunctionReference comp : comparatorRefs) {
+ final Sequence result = comp.evalFunction(null, null,
+ new Sequence[]{a.toSequence(), b.toSequence()});
+ final long cmp = ((IntegerValue) result.itemAt(0)).getLong();
+ if (cmp != 0) {
+ return Long.compare(cmp, 0);
+ }
+ }
+ return 0;
+ } catch (final XPathException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ } catch (final RuntimeException e) {
+ if (e.getCause() instanceof XPathException) {
+ throw (XPathException) e.getCause();
+ }
+ throw e;
+ }
+
+ final ValueSequence result = new ValueSequence(items.size());
+ for (final Item item : items) {
+ result.add(item);
+ }
+ return result;
+ }
+
+ private Sequence scanLeft(final Sequence[] args) throws XPathException {
+ final Sequence input = args[0];
+ Sequence accumulator = args[1];
+ try (final FunctionReference action = (FunctionReference) args[2].itemAt(0)) {
+ action.analyze(cachedContextInfo);
+
+ final int count = input.getItemCount();
+ final ValueSequence result = new ValueSequence(count + 1);
+
+ // First element: [init]
+ result.add(new ArrayType(this, context, Collections.singletonList(accumulator)));
+
+ // For each input item, apply action and wrap result
+ for (final SequenceIterator i = input.iterate(); i.hasNext(); ) {
+ final Item item = i.nextItem();
+ accumulator = action.evalFunction(null, null,
+ new Sequence[]{accumulator, item.toSequence()});
+ result.add(new ArrayType(this, context, Collections.singletonList(accumulator)));
+ }
+
+ return result;
+ }
+ }
+
+ private Sequence scanRight(final Sequence[] args) throws XPathException {
+ final Sequence input = args[0];
+ final Sequence init = args[1];
+ try (final FunctionReference action = (FunctionReference) args[2].itemAt(0)) {
+ action.analyze(cachedContextInfo);
+
+ // Collect items into a list for reverse iteration
+ final List
- items = new ArrayList<>(input.getItemCount());
+ for (final SequenceIterator i = input.iterate(); i.hasNext(); ) {
+ items.add(i.nextItem());
+ }
+
+ // Build results from right to left
+ final List
results = new ArrayList<>(items.size() + 1);
+ Sequence accumulator = init;
+ results.add(accumulator);
+
+ for (int idx = items.size() - 1; idx >= 0; idx--) {
+ accumulator = action.evalFunction(null, null,
+ new Sequence[]{items.get(idx).toSequence(), accumulator});
+ results.add(accumulator);
+ }
+
+ // Reverse so first result is fold-right of entire sequence
+ Collections.reverse(results);
+
+ final ValueSequence result = new ValueSequence(results.size());
+ for (final Sequence s : results) {
+ result.add(new ArrayType(this, context, Collections.singletonList(s)));
+ }
+ return result;
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnHighestLowest.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnHighestLowest.java
new file mode 100644
index 00000000000..a2abad4fd94
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnHighestLowest.java
@@ -0,0 +1,226 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import com.ibm.icu.text.Collator;
+import org.exist.dom.QName;
+import org.exist.xquery.AnalyzeContextInfo;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.AtomicValue;
+import org.exist.xquery.value.DoubleValue;
+import org.exist.xquery.value.FloatValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.NumericValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceIterator;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+/**
+ * Implements fn:highest and fn:lowest (XQuery 4.0).
+ *
+ * Returns items from the input having the highest/lowest key values.
+ */
+public class FnHighestLowest extends BasicFunction {
+
+ public static final FunctionSignature[] FN_HIGHEST = {
+ new FunctionSignature(
+ new QName("highest", Function.BUILTIN_FUNCTION_NS),
+ "Returns items with the highest key value.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "items with highest key")),
+ new FunctionSignature(
+ new QName("highest", Function.BUILTIN_FUNCTION_NS),
+ "Returns items with the highest key value.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("collation", Type.STRING, Cardinality.ZERO_OR_ONE, "The collation URI")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "items with highest key")),
+ new FunctionSignature(
+ new QName("highest", Function.BUILTIN_FUNCTION_NS),
+ "Returns items with the highest key value.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("collation", Type.STRING, Cardinality.ZERO_OR_ONE, "The collation URI"),
+ new FunctionParameterSequenceType("key", Type.FUNCTION, Cardinality.ZERO_OR_ONE, "Key function")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "items with highest key"))
+ };
+
+ public static final FunctionSignature[] FN_LOWEST = {
+ new FunctionSignature(
+ new QName("lowest", Function.BUILTIN_FUNCTION_NS),
+ "Returns items with the lowest key value.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "items with lowest key")),
+ new FunctionSignature(
+ new QName("lowest", Function.BUILTIN_FUNCTION_NS),
+ "Returns items with the lowest key value.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("collation", Type.STRING, Cardinality.ZERO_OR_ONE, "The collation URI")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "items with lowest key")),
+ new FunctionSignature(
+ new QName("lowest", Function.BUILTIN_FUNCTION_NS),
+ "Returns items with the lowest key value.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("collation", Type.STRING, Cardinality.ZERO_OR_ONE, "The collation URI"),
+ new FunctionParameterSequenceType("key", Type.FUNCTION, Cardinality.ZERO_OR_ONE, "Key function")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "items with lowest key"))
+ };
+
+ private AnalyzeContextInfo cachedContextInfo;
+
+ public FnHighestLowest(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ cachedContextInfo = new AnalyzeContextInfo(contextInfo);
+ super.analyze(cachedContextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence input = args[0];
+ if (input.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ // Resolve collation
+ final Collator collator;
+ if (args.length >= 2 && !args[1].isEmpty()) {
+ collator = context.getCollator(args[1].getStringValue());
+ } else {
+ collator = context.getDefaultCollator();
+ }
+
+ // Resolve key function (default is data#1)
+ FunctionReference keyRef = null;
+ if (args.length >= 3 && !args[2].isEmpty()) {
+ keyRef = (FunctionReference) args[2].itemAt(0);
+ keyRef.analyze(cachedContextInfo);
+ }
+
+ final boolean findHighest = isCalledAs("highest");
+
+ // Compute keys for all items
+ final List- items = new ArrayList<>(input.getItemCount());
+ final List
keys = new ArrayList<>(input.getItemCount());
+
+ for (final SequenceIterator i = input.iterate(); i.hasNext(); ) {
+ final Item item = i.nextItem();
+ items.add(item);
+
+ // Compute key: apply key function or default atomization (fn:data)
+ final AtomicValue keyVal;
+ if (keyRef != null) {
+ final Sequence keyResult = keyRef.evalFunction(null, null, new Sequence[]{item.toSequence()});
+ if (keyResult.isEmpty()) {
+ keyVal = null;
+ } else {
+ AtomicValue kv = keyResult.itemAt(0).atomize();
+ if (kv.getType() == Type.UNTYPED_ATOMIC) {
+ kv = kv.convertTo(Type.DOUBLE);
+ }
+ keyVal = kv;
+ }
+ } else {
+ // Default key is fn:data() — atomize the item directly
+ final AtomicValue atomized = item.atomize();
+ if (atomized.getType() == Type.UNTYPED_ATOMIC) {
+ keyVal = atomized.convertTo(Type.DOUBLE);
+ } else {
+ keyVal = atomized;
+ }
+ }
+ keys.add(keyVal);
+ }
+
+ // Find the extreme value
+ AtomicValue extremeKey = null;
+ for (final AtomicValue key : keys) {
+ if (key == null || isNaN(key)) {
+ continue;
+ }
+ if (extremeKey == null) {
+ extremeKey = key;
+ } else {
+ final int cmp = key.compareTo(collator, extremeKey);
+ if (findHighest ? cmp > 0 : cmp < 0) {
+ extremeKey = key;
+ }
+ }
+ }
+
+ if (extremeKey == null) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ // Collect all items with the extreme key value
+ final ValueSequence result = new ValueSequence();
+ for (int i = 0; i < items.size(); i++) {
+ final AtomicValue key = keys.get(i);
+ if (key != null && !isNaN(key) && key.compareTo(collator, extremeKey) == 0) {
+ result.add(items.get(i));
+ }
+ }
+
+ if (keyRef != null) {
+ keyRef.close();
+ }
+
+ return result;
+ }
+
+ private static boolean isNaN(final AtomicValue v) {
+ if (v instanceof DoubleValue) {
+ return Double.isNaN(((DoubleValue) v).getDouble());
+ }
+ if (v instanceof FloatValue) {
+ return Float.isNaN(((FloatValue) v).getValue());
+ }
+ return false;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnHtmlDoc.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnHtmlDoc.java
new file mode 100644
index 00000000000..bade6cf1717
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnHtmlDoc.java
@@ -0,0 +1,71 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.value.*;
+
+/**
+ * fn:html-doc($uri) — Like fn:doc but for HTML.
+ * Loads HTML from a URI, parses it through fn:parse-html, returns XHTML document.
+ */
+public class FnHtmlDoc extends BasicFunction {
+
+ public static final FunctionSignature FN_HTML_DOC = new FunctionSignature(
+ new QName("html-doc", Function.BUILTIN_FUNCTION_NS),
+ "Loads an HTML resource from a URI and returns the parsed XHTML document.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("uri", Type.STRING,
+ Cardinality.ZERO_OR_ONE, "The URI of the HTML resource")
+ },
+ new FunctionReturnSequenceType(Type.DOCUMENT, Cardinality.ZERO_OR_ONE,
+ "The parsed XHTML document"));
+
+ public FnHtmlDoc(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final String uri = args[0].getStringValue();
+
+ // Load text content using unparsed-text logic
+ final FunUnparsedText unparsedText = new FunUnparsedText(context,
+ FunUnparsedText.FS_UNPARSED_TEXT[0]);
+ final Sequence textResult = unparsedText.eval(
+ new Sequence[]{new StringValue(this, uri)}, contextSequence);
+
+ if (textResult.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ // Parse through fn:parse-html
+ final FnParseHtml parseHtml = new FnParseHtml(context,
+ FnParseHtml.FN_PARSE_HTML[0]);
+ return parseHtml.eval(new Sequence[]{textResult}, contextSequence);
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnIdentityVoid.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnIdentityVoid.java
new file mode 100644
index 00000000000..777b717788d
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnIdentityVoid.java
@@ -0,0 +1,78 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+/**
+ * Implements fn:identity and fn:void (XQuery 4.0).
+ */
+public class FnIdentityVoid extends BasicFunction {
+
+ public static final FunctionSignature FN_IDENTITY = new FunctionSignature(
+ new QName("identity", Function.BUILTIN_FUNCTION_NS),
+ "Returns its argument value unchanged.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input value")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "the input value unchanged"));
+
+ public static final FunctionSignature[] FN_VOID = {
+ new FunctionSignature(
+ new QName("void", Function.BUILTIN_FUNCTION_NS),
+ "Absorbs the argument and returns the empty sequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input to discard")
+ },
+ new FunctionReturnSequenceType(Type.EMPTY_SEQUENCE, Cardinality.EMPTY_SEQUENCE, "the empty sequence")),
+ new FunctionSignature(
+ new QName("void", Function.BUILTIN_FUNCTION_NS),
+ "Returns the empty sequence.",
+ new SequenceType[] {},
+ new FunctionReturnSequenceType(Type.EMPTY_SEQUENCE, Cardinality.EMPTY_SEQUENCE, "the empty sequence"))
+ };
+
+ public FnIdentityVoid(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (isCalledAs("identity")) {
+ return args[0];
+ } else {
+ // void: discard input, return empty sequence
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnInScopeNamespaces.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnInScopeNamespaces.java
new file mode 100644
index 00000000000..b75d1d508bf
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnInScopeNamespaces.java
@@ -0,0 +1,152 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.Namespaces;
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.functions.map.MapType;
+import org.exist.xquery.value.*;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.HashSet;
+
+/**
+ * Implements XQuery 4.0 fn:in-scope-namespaces.
+ *
+ * Returns a map(xs:string, xs:string) where keys are namespace prefixes
+ * (empty string for the default namespace) and values are namespace URIs.
+ *
+ * Uses nearest-ancestor-wins semantics: for each prefix, the declaration on
+ * the nearest ancestor (or the element itself) takes precedence.
+ */
+public class FnInScopeNamespaces extends BasicFunction {
+
+ public static final FunctionSignature FN_IN_SCOPE_NAMESPACES = new FunctionSignature(
+ new QName("in-scope-namespaces", Function.BUILTIN_FUNCTION_NS),
+ "Returns a map from namespace prefixes to namespace URIs for all in-scope namespaces of the given element.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("element", Type.ELEMENT, Cardinality.EXACTLY_ONE, "The element node")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.EXACTLY_ONE, "A map of prefix to URI"));
+
+ public FnInScopeNamespaces(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final NodeValue nodeValue = (NodeValue) args[0].itemAt(0);
+
+ // Collect all in-scope namespaces with nearest-ancestor-wins semantics
+ final Map nsMap = new LinkedHashMap<>();
+ nsMap.put("xml", Namespaces.XML_NS);
+
+ // Start with static context namespaces (lowest priority)
+ final Map inScopePrefixes = context.getInScopePrefixes();
+ if (inScopePrefixes != null) {
+ nsMap.putAll(inScopePrefixes);
+ }
+
+ // Walk from element up to root, collecting namespace declarations.
+ // Track which prefixes we've already seen from closer ancestors
+ // so that nearer declarations override farther ones.
+ final Set seen = new HashSet<>();
+ final Map elementNs = new LinkedHashMap<>();
+ Node node = nodeValue.getNode();
+
+ if (context.preserveNamespaces()) {
+ while (node != null && node.getNodeType() == Node.ELEMENT_NODE) {
+ if (context.inheritNamespaces() || node == nodeValue.getNode()) {
+ collectElementNamespaces((Element) node, elementNs, seen);
+ }
+ node = node.getParentNode();
+ }
+ }
+
+ // Element declarations override static context (merge on top)
+ nsMap.putAll(elementNs);
+
+ // Clean up: remove entries where both key and value are empty
+ nsMap.entrySet().removeIf(entry ->
+ (entry.getKey() == null || entry.getKey().isEmpty()) &&
+ (entry.getValue() == null || entry.getValue().isEmpty()));
+
+ // Build the result map
+ MapType result = new MapType(this, context);
+ for (final Map.Entry entry : nsMap.entrySet()) {
+ result = (MapType) result.put(
+ new StringValue(this, entry.getKey()),
+ new StringValue(this, entry.getValue()));
+ }
+
+ return result;
+ }
+
+ /**
+ * Collect namespace declarations from a single element, respecting nearest-wins.
+ * Only adds prefixes not already in the {@code seen} set.
+ */
+ private static void collectElementNamespaces(final Element element, final Map nsMap, final Set seen) {
+ // Element's own namespace
+ final String namespaceURI = element.getNamespaceURI();
+ if (namespaceURI != null && !namespaceURI.isEmpty()) {
+ final String prefix = element.getPrefix();
+ final String key = prefix == null ? "" : prefix;
+ if (seen.add(key)) {
+ nsMap.put(key, namespaceURI);
+ }
+ }
+
+ // Namespace declarations from the element
+ if (element instanceof org.exist.dom.memtree.ElementImpl) {
+ final Map elemNs = new LinkedHashMap<>();
+ ((org.exist.dom.memtree.ElementImpl) element).getNamespaceMap(elemNs);
+ for (final Map.Entry entry : elemNs.entrySet()) {
+ if (seen.add(entry.getKey())) {
+ nsMap.put(entry.getKey(), entry.getValue());
+ }
+ }
+ } else if (element instanceof org.exist.dom.persistent.ElementImpl) {
+ final org.exist.dom.persistent.ElementImpl elemImpl = (org.exist.dom.persistent.ElementImpl) element;
+ if (elemImpl.declaresNamespacePrefixes()) {
+ for (final java.util.Iterator i = elemImpl.getPrefixes(); i.hasNext(); ) {
+ final String prefix = i.next();
+ if (seen.add(prefix)) {
+ nsMap.put(prefix, elemImpl.getNamespaceForPrefix(prefix));
+ }
+ }
+ }
+ }
+
+ // Handle undeclaration: if namespace URI is explicitly empty, remove the prefix
+ if (namespaceURI != null && namespaceURI.isEmpty()) {
+ final String prefix = element.getPrefix();
+ final String key = prefix == null ? "" : prefix;
+ nsMap.remove(key);
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnInsertSeparator.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnInsertSeparator.java
new file mode 100644
index 00000000000..ffe3729a0d3
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnInsertSeparator.java
@@ -0,0 +1,74 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+/**
+ * Implements fn:insert-separator (XQuery 4.0).
+ *
+ * Inserts a separator between adjacent items in a sequence.
+ */
+public class FnInsertSeparator extends BasicFunction {
+
+ public static final FunctionSignature FN_INSERT_SEPARATOR = new FunctionSignature(
+ new QName("insert-separator", Function.BUILTIN_FUNCTION_NS),
+ "Inserts a separator between adjacent items in a sequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("separator", Type.ITEM, Cardinality.ZERO_OR_MORE, "The separator to insert")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "the sequence with separators inserted"));
+
+ public FnInsertSeparator(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence input = args[0];
+ final Sequence separator = args[1];
+ final int inputSize = input.getItemCount();
+ if (inputSize <= 1 || separator.isEmpty()) {
+ return input;
+ }
+ final ValueSequence result = new ValueSequence(inputSize + (inputSize - 1) * separator.getItemCount());
+ result.add(input.itemAt(0));
+ for (int i = 1; i < inputSize; i++) {
+ result.addAll(separator);
+ result.add(input.itemAt(i));
+ }
+ return result;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnInvisibleXml.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnInvisibleXml.java
new file mode 100644
index 00000000000..3599bf77dac
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnInvisibleXml.java
@@ -0,0 +1,308 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import de.bottlecaps.markup.Blitz;
+import de.bottlecaps.markup.BlitzException;
+import de.bottlecaps.markup.BlitzParseException;
+
+import org.exist.Namespaces;
+import org.exist.dom.QName;
+import org.exist.dom.memtree.DocumentImpl;
+import org.exist.dom.memtree.SAXAdapter;
+import org.exist.util.XMLReaderPool;
+import org.exist.xquery.value.NodeValue;
+import org.exist.xquery.AbstractExpression;
+import org.exist.xquery.AnalyzeContextInfo;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionCall;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.UserDefinedFunction;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.functions.map.AbstractMapType;
+import org.exist.xquery.util.ExpressionDumper;
+import org.exist.xquery.value.BooleanValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+import org.xml.sax.InputSource;
+import org.xml.sax.XMLReader;
+
+import javax.xml.XMLConstants;
+import java.io.StringReader;
+
+/**
+ * Implements fn:invisible-xml() (XQuery 4.0).
+ *
+ * Compiles an Invisible XML grammar and returns a function that parses input
+ * strings into XML documents.
+ *
+ * Uses the Markup Blitz library for ixml grammar compilation and parsing.
+ * Integration pattern informed by BaseX's implementation.
+ */
+public class FnInvisibleXml extends BasicFunction {
+
+ // Blitz.generateFromXml() is not thread-safe — synchronize XML grammar compilation
+ private static final Object BLITZ_XML_LOCK = new Object();
+
+ private static final FunctionParameterSequenceType PARAM_GRAMMAR =
+ new FunctionParameterSequenceType("grammar", Type.ITEM,
+ Cardinality.ZERO_OR_ONE, "The ixml grammar (string or element node)");
+ private static final FunctionParameterSequenceType PARAM_OPTIONS =
+ new FunctionParameterSequenceType("options", Type.MAP_ITEM,
+ Cardinality.ZERO_OR_ONE, "Options map (fail-on-error: xs:boolean)");
+ private static final FunctionReturnSequenceType RETURN_TYPE =
+ new FunctionReturnSequenceType(Type.FUNCTION, Cardinality.EXACTLY_ONE,
+ "a function that parses strings according to the grammar");
+
+ public static final FunctionSignature[] SIGNATURES = {
+ new FunctionSignature(
+ new QName("invisible-xml", Function.BUILTIN_FUNCTION_NS),
+ "Compiles an Invisible XML grammar and returns a parsing function.",
+ new SequenceType[] { PARAM_GRAMMAR },
+ RETURN_TYPE),
+ new FunctionSignature(
+ new QName("invisible-xml", Function.BUILTIN_FUNCTION_NS),
+ "Compiles an Invisible XML grammar and returns a parsing function.",
+ new SequenceType[] { PARAM_GRAMMAR, PARAM_OPTIONS },
+ RETURN_TYPE)
+ };
+
+ private AnalyzeContextInfo cachedContextInfo;
+
+ public FnInvisibleXml(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ cachedContextInfo = new AnalyzeContextInfo(contextInfo);
+ super.analyze(cachedContextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence grammarArg = args[0];
+
+ // Parse options — default fail-on-error is false per spec
+ boolean failOnError = false;
+ if (args.length > 1 && !args[1].isEmpty()) {
+ final AbstractMapType options = (AbstractMapType) args[1].itemAt(0);
+ final Sequence failOpt = options.get(new StringValue(this, "fail-on-error"));
+ if (!failOpt.isEmpty()) {
+ final Item failItem = failOpt.itemAt(0);
+ if (failItem.getType() != Type.BOOLEAN) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Option 'fail-on-error' must be xs:boolean, got: " +
+ Type.getTypeName(failItem.getType()));
+ }
+ failOnError = ((BooleanValue) failItem).getValue();
+ } else if (options.contains(new StringValue(this, "fail-on-error"))) {
+ // Key exists but value is empty sequence
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Option 'fail-on-error' must be xs:boolean, got empty sequence");
+ }
+ // else: key not present, use default (false)
+ }
+
+ // Compile the grammar
+ final de.bottlecaps.markup.blitz.Parser parser;
+ try {
+ if (grammarArg.isEmpty()) {
+ // Empty sequence = use default ixml grammar
+ parser = failOnError
+ ? Blitz.generate(Blitz.ixmlGrammar(), Blitz.Option.FAIL_ON_ERROR)
+ : Blitz.generate(Blitz.ixmlGrammar());
+ } else {
+ final Item grammarItem = grammarArg.itemAt(0);
+ final int grammarType = grammarItem.getType();
+
+ if (Type.subTypeOf(grammarType, Type.ELEMENT)) {
+ // Element node — serialize to XML string and use generateFromXml
+ // Synchronized: Blitz.generateFromXml() is not thread-safe
+ final String xmlGrammar = serializeItem(grammarItem);
+ synchronized (BLITZ_XML_LOCK) {
+ parser = failOnError
+ ? Blitz.generateFromXml(xmlGrammar, Blitz.Option.FAIL_ON_ERROR)
+ : Blitz.generateFromXml(xmlGrammar);
+ }
+ } else if (Type.subTypeOf(grammarType, Type.STRING) ||
+ grammarType == Type.UNTYPED_ATOMIC) {
+ // String grammar
+ final String grammarStr = grammarItem.getStringValue();
+ parser = failOnError
+ ? Blitz.generate(grammarStr, Blitz.Option.FAIL_ON_ERROR)
+ : Blitz.generate(grammarStr);
+ } else if (Type.subTypeOf(grammarType, Type.NODE)) {
+ // Other node types (document, etc.) — not valid
+ throw new XPathException(this, ErrorCodes.FOIX0001,
+ "Grammar must be an element node or string, got: " +
+ Type.getTypeName(grammarType));
+ } else {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Grammar must be a string or element node, got: " +
+ Type.getTypeName(grammarType));
+ }
+ }
+ } catch (final BlitzParseException ex) {
+ throw new XPathException(this, ErrorCodes.FOIX0001,
+ "Invalid ixml grammar at line " + ex.getLine() + ", column " + ex.getColumn()
+ + ": " + ex.getOffendingToken());
+ } catch (final BlitzException ex) {
+ throw new XPathException(this, ErrorCodes.FOIX0001,
+ "Invalid ixml grammar: " + ex.getMessage());
+ }
+
+ // Create a function item that parses input strings using the compiled grammar
+ final QName inputParam = new QName("input", XMLConstants.NULL_NS_URI);
+
+ final FunctionSignature parseSig = new FunctionSignature(
+ new QName("invisible-xml-parser", Function.BUILTIN_FUNCTION_NS),
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.STRING,
+ Cardinality.EXACTLY_ONE, "The string to parse")
+ },
+ new FunctionReturnSequenceType(Type.DOCUMENT, Cardinality.EXACTLY_ONE,
+ "the parsed XML document"));
+
+ final UserDefinedFunction func = new UserDefinedFunction(context, parseSig);
+ func.addVariable(inputParam);
+ func.setFunctionBody(new ParseExpression(context, parser, inputParam, failOnError));
+
+ final FunctionCall call = new FunctionCall(context, func);
+ call.setLocation(getLine(), getColumn());
+
+ return new FunctionReference(this, call);
+ }
+
+ private String serializeItem(final Item item) throws XPathException {
+ try {
+ final org.exist.storage.serializers.Serializer serializer =
+ context.getBroker().borrowSerializer();
+ try {
+ serializer.setProperty(javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION, "yes");
+ serializer.setProperty(javax.xml.transform.OutputKeys.INDENT, "no");
+ return serializer.serialize((NodeValue) item);
+ } finally {
+ context.getBroker().returnSerializer(serializer);
+ }
+ } catch (final Exception ex) {
+ throw new XPathException(this, ErrorCodes.FOIX0001,
+ "Failed to serialize grammar node: " + ex.getMessage());
+ }
+ }
+
+ /**
+ * Expression that parses an input string using a compiled ixml parser.
+ */
+ private static class ParseExpression extends AbstractExpression {
+
+ private final de.bottlecaps.markup.blitz.Parser parser;
+ private final QName inputVar;
+ private final boolean failOnError;
+
+ ParseExpression(final XQueryContext context, final de.bottlecaps.markup.blitz.Parser parser,
+ final QName inputVar, final boolean failOnError) {
+ super(context);
+ this.parser = parser;
+ this.inputVar = inputVar;
+ this.failOnError = failOnError;
+ }
+
+ @Override
+ public Sequence eval(final Sequence contextSequence, final Item contextItem) throws XPathException {
+ final String input = context.resolveVariable(inputVar).getValue().getStringValue();
+
+ // Parse the input using the compiled ixml parser
+ final String xmlResult;
+ try {
+ xmlResult = parser.parse(input);
+ } catch (final BlitzParseException ex) {
+ if (failOnError) {
+ throw new XPathException(this, ErrorCodes.FOIX0002,
+ "ixml parse error at line " + ex.getLine() + ", column " + ex.getColumn()
+ + ": " + ex.getOffendingToken());
+ }
+ // Should not happen when FAIL_ON_ERROR is not set, but handle gracefully
+ throw new XPathException(this, ErrorCodes.FOIX0002,
+ "ixml parse error: " + ex.getMessage());
+ } catch (final BlitzException ex) {
+ throw new XPathException(this, ErrorCodes.FOIX0002,
+ "ixml parse error: " + ex.getMessage());
+ }
+
+ // Check for ixml:state="failed" on the root element when fail-on-error is true
+ if (failOnError && xmlResult.contains("ixml:state=\"failed\"")) {
+ throw new XPathException(this, ErrorCodes.FOIX0002,
+ "ixml parse failed: input is ambiguous or does not match the grammar");
+ }
+
+ // Parse the XML string into an in-memory document
+ return parseXmlString(xmlResult);
+ }
+
+ private DocumentImpl parseXmlString(final String xml) throws XPathException {
+ final XMLReaderPool parserPool = context.getBroker().getBrokerPool().getXmlReaderPool();
+ XMLReader xr = null;
+ try {
+ xr = parserPool.borrowXMLReader();
+ final InputSource src = new InputSource(new StringReader(xml));
+ final SAXAdapter adapter = new SAXAdapter(this, context);
+ xr.setContentHandler(adapter);
+ xr.setProperty(Namespaces.SAX_LEXICAL_HANDLER, adapter);
+ xr.parse(src);
+ return adapter.getDocument();
+ } catch (final Exception ex) {
+ throw new XPathException(this, ErrorCodes.FOIX0002,
+ "Failed to parse ixml output as XML: " + ex.getMessage());
+ } finally {
+ if (xr != null) {
+ parserPool.returnXMLReader(xr);
+ }
+ }
+ }
+
+ @Override
+ public int returnsType() {
+ return Type.DOCUMENT;
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ // nothing to analyze
+ }
+
+ @Override
+ public void dump(final ExpressionDumper dumper) {
+ dumper.display("invisible-xml-parser(...)");
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnIsNaN.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnIsNaN.java
new file mode 100644
index 00000000000..5e3e8b1754b
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnIsNaN.java
@@ -0,0 +1,71 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.BooleanValue;
+import org.exist.xquery.value.DoubleValue;
+import org.exist.xquery.value.FloatValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+/**
+ * Implements fn:is-NaN (XQuery 4.0).
+ *
+ * Returns true if the argument is the xs:float or xs:double value NaN.
+ */
+public class FnIsNaN extends BasicFunction {
+
+ public static final FunctionSignature FN_IS_NAN = new FunctionSignature(
+ new QName("is-NaN", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if the argument is the xs:float or xs:double value NaN.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.ANY_ATOMIC_TYPE, Cardinality.EXACTLY_ONE, "The value to test")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if the value is NaN"));
+
+ public FnIsNaN(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Item item = args[0].itemAt(0);
+ final int type = item.getType();
+ if (type == Type.DOUBLE) {
+ return BooleanValue.valueOf(Double.isNaN(((DoubleValue) item).getValue()));
+ } else if (type == Type.FLOAT) {
+ return BooleanValue.valueOf(Float.isNaN(((FloatValue) item).getValue()));
+ }
+ return BooleanValue.FALSE;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnItemsAt.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnItemsAt.java
new file mode 100644
index 00000000000..55c9bf64d74
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnItemsAt.java
@@ -0,0 +1,79 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceIterator;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+/**
+ * Implements fn:items-at (XQuery 4.0).
+ *
+ * Returns items from the input at the positions specified by the second argument.
+ */
+public class FnItemsAt extends BasicFunction {
+
+ public static final FunctionSignature FN_ITEMS_AT = new FunctionSignature(
+ new QName("items-at", Function.BUILTIN_FUNCTION_NS),
+ "Returns the items at the specified positions in the input sequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("at", Type.INTEGER, Cardinality.ZERO_OR_MORE, "The positions to select")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "items at the specified positions"));
+
+ public FnItemsAt(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence input = args[0];
+ final Sequence at = args[1];
+ if (input.isEmpty() || at.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ final int inputSize = input.getItemCount();
+ final ValueSequence result = new ValueSequence();
+ for (final SequenceIterator i = at.iterate(); i.hasNext(); ) {
+ final Item posItem = i.nextItem();
+ final int pos = (int) ((IntegerValue) posItem).getLong();
+ if (pos >= 1 && pos <= inputSize) {
+ result.add(input.itemAt(pos - 1));
+ }
+ }
+ return result;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnMessage.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnMessage.java
new file mode 100644
index 00000000000..db50f1319b4
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnMessage.java
@@ -0,0 +1,85 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+/**
+ * Implements XQuery 4.0 fn:message.
+ *
+ * Similar to fn:trace but returns empty-sequence() instead of passing through values.
+ * Outputs the input values (and optional label) to the log.
+ */
+public class FnMessage extends BasicFunction {
+
+ private static final Logger LOG = LogManager.getLogger(FnMessage.class);
+
+ public static final FunctionSignature[] FN_MESSAGE = {
+ new FunctionSignature(
+ new QName("message", Function.BUILTIN_FUNCTION_NS),
+ "Outputs values to the log and returns empty sequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The values to output")
+ },
+ new FunctionReturnSequenceType(Type.EMPTY_SEQUENCE, Cardinality.EMPTY_SEQUENCE, "empty sequence")),
+ new FunctionSignature(
+ new QName("message", Function.BUILTIN_FUNCTION_NS),
+ "Outputs values to the log with a label and returns empty sequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The values to output"),
+ new FunctionParameterSequenceType("label", Type.STRING, Cardinality.ZERO_OR_ONE, "Optional label for the output")
+ },
+ new FunctionReturnSequenceType(Type.EMPTY_SEQUENCE, Cardinality.EMPTY_SEQUENCE, "empty sequence"))
+ };
+
+ public FnMessage(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence input = args[0];
+ final String label = (args.length > 1 && !args[1].isEmpty()) ? args[1].getStringValue() : null;
+
+ final String value = input.getStringValue();
+ if (label != null && !label.isEmpty()) {
+ LOG.info("{}: {}", label, value);
+ } else {
+ LOG.info("{}", value);
+ }
+
+ return Sequence.EMPTY_SEQUENCE;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnModule.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnModule.java
index 413c58b5f3d..9aeade4aab4 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnModule.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnModule.java
@@ -67,13 +67,15 @@ public class FnModule extends AbstractInternalModule {
new FunctionDef(FunData.signatures[1], FunData.class),
new FunctionDef(FunDateTime.signature, FunDateTime.class),
new FunctionDef(FunDeepEqual.signatures[0], FunDeepEqual.class),
- new FunctionDef(FunDeepEqual.signatures[1], FunDeepEqual.class),
+ new FunctionDef(FnDeepEqualOptions.FN_DEEP_EQUAL_OPTIONS, FnDeepEqualOptions.class),
new FunctionDef(FunDefaultCollation.signature, FunDefaultCollation.class),
new FunctionDef(FnDefaultLanguage.FS_DEFAULT_LANGUAGE, FnDefaultLanguage.class),
new FunctionDef(FunDistinctValues.signatures[0], FunDistinctValues.class),
new FunctionDef(FunDistinctValues.signatures[1], FunDistinctValues.class),
new FunctionDef(FunDoc.signature, FunDoc.class),
+ new FunctionDef(FunDoc.signatureWithOptions, FunDoc.class),
new FunctionDef(FunDocAvailable.signature, FunDocAvailable.class),
+ new FunctionDef(FunDocAvailable.signatureWithOptions, FunDocAvailable.class),
new FunctionDef(FunDocumentURI.FS_DOCUMENT_URI_0, FunDocumentURI.class),
new FunctionDef(FunDocumentURI.FS_DOCUMENT_URI_1, FunDocumentURI.class),
new FunctionDef(FunElementWithId.FS_ELEMENT_WITH_ID_SIGNATURES[0], FunElementWithId.class),
@@ -178,6 +180,7 @@ public class FnModule extends AbstractInternalModule {
new FunctionDef(FnOuterMost.FNS_OUTERMOST, FnOuterMost.class),
new FunctionDef(FunPath.FS_PATH_SIGNATURES[0], FunPath.class),
new FunctionDef(FunPath.FS_PATH_SIGNATURES[1], FunPath.class),
+ new FunctionDef(FunPath.FS_PATH_SIGNATURES[2], FunPath.class),
new FunctionDef(FunPosition.signature, FunPosition.class),
new FunctionDef(FunQName.signature, FunQName.class),
new FunctionDef(FunRemove.signature, FunRemove.class),
@@ -190,6 +193,7 @@ public class FnModule extends AbstractInternalModule {
new FunctionDef(FunRoot.signatures[1], FunRoot.class),
new FunctionDef(FunRound.FN_ROUND_SIGNATURES[0], FunRound.class),
new FunctionDef(FunRound.FN_ROUND_SIGNATURES[1], FunRound.class),
+ new FunctionDef(FunRound.FN_ROUND_SIGNATURES[2], FunRound.class),
new FunctionDef(FunRoundHalfToEven.FN_ROUND_HALF_TO_EVEN_SIGNATURES[0], FunRoundHalfToEven.class),
new FunctionDef(FunRoundHalfToEven.FN_ROUND_HALF_TO_EVEN_SIGNATURES[1], FunRoundHalfToEven.class),
new FunctionDef(FunSerialize.signatures[0], FunSerialize.class),
@@ -240,8 +244,10 @@ public class FnModule extends AbstractInternalModule {
new FunctionDef(FunEquals.signatures[1], FunEquals.class),
new FunctionDef(FunAnalyzeString.signatures[0], FunAnalyzeString.class),
new FunctionDef(FunAnalyzeString.signatures[1], FunAnalyzeString.class),
- new FunctionDef(FunHeadTail.signatures[0], FunHeadTail.class),
- new FunctionDef(FunHeadTail.signatures[1], FunHeadTail.class),
+ new FunctionDef(FunHeadTail.FN_HEAD, FunHeadTail.class),
+ new FunctionDef(FunHeadTail.FN_TAIL, FunHeadTail.class),
+ new FunctionDef(FunHeadTail.FN_FOOT, FunHeadTail.class),
+ new FunctionDef(FunHeadTail.FN_TRUNK, FunHeadTail.class),
new FunctionDef(FunHigherOrderFun.FN_FOR_EACH, FunHigherOrderFun.class),
new FunctionDef(FunHigherOrderFun.FN_FOR_EACH_PAIR, FunHigherOrderFun.class),
new FunctionDef(FunHigherOrderFun.FN_FILTER, FunHigherOrderFun.class),
@@ -252,6 +258,8 @@ public class FnModule extends AbstractInternalModule {
new FunctionDef(FunEnvironment.signature[1], FunEnvironment.class),
new FunctionDef(ParsingFunctions.signatures[0], ParsingFunctions.class),
new FunctionDef(ParsingFunctions.signatures[1], ParsingFunctions.class),
+ new FunctionDef(ParsingFunctions.signatures[2], ParsingFunctions.class),
+ new FunctionDef(ParsingFunctions.signatures[3], ParsingFunctions.class),
new FunctionDef(JSON.FS_PARSE_JSON[0], JSON.class),
new FunctionDef(JSON.FS_PARSE_JSON[1], JSON.class),
new FunctionDef(JSON.FS_JSON_DOC[0], JSON.class),
@@ -272,7 +280,126 @@ public class FnModule extends AbstractInternalModule {
new FunctionDef(FnRandomNumberGenerator.FS_RANDOM_NUMBER_GENERATOR[0], FnRandomNumberGenerator.class),
new FunctionDef(FnRandomNumberGenerator.FS_RANDOM_NUMBER_GENERATOR[1], FnRandomNumberGenerator.class),
new FunctionDef(FunContainsToken.FS_CONTAINS_TOKEN[0], FunContainsToken.class),
- new FunctionDef(FunContainsToken.FS_CONTAINS_TOKEN[1], FunContainsToken.class)
+ new FunctionDef(FunContainsToken.FS_CONTAINS_TOKEN[1], FunContainsToken.class),
+ // XQuery 4.0 functions
+ new FunctionDef(FnIdentityVoid.FN_IDENTITY, FnIdentityVoid.class),
+ new FunctionDef(FnIdentityVoid.FN_VOID[0], FnIdentityVoid.class),
+ new FunctionDef(FnIdentityVoid.FN_VOID[1], FnIdentityVoid.class),
+ new FunctionDef(FnIsNaN.FN_IS_NAN, FnIsNaN.class),
+ new FunctionDef(FnCharacters.FN_CHARACTERS, FnCharacters.class),
+ new FunctionDef(FnGraphemes.FN_GRAPHEMES, FnGraphemes.class),
+ new FunctionDef(FnParseHtml.FN_PARSE_HTML[0], FnParseHtml.class),
+ new FunctionDef(FnParseHtml.FN_PARSE_HTML[1], FnParseHtml.class),
+ new FunctionDef(FnCollation.FN_COLLATION[0], FnCollation.class),
+ new FunctionDef(FnCollation.FN_COLLATION[1], FnCollation.class),
+ new FunctionDef(FnCollation.FN_COLLATION_AVAILABLE, FnCollation.class),
+ new FunctionDef(FnHtmlDoc.FN_HTML_DOC, FnHtmlDoc.class),
+ new FunctionDef(FnUnparsedBinary.FN_UNPARSED_BINARY, FnUnparsedBinary.class),
+ new FunctionDef(FnSchemaType.FN_SCHEMA_TYPE, FnSchemaType.class),
+ new FunctionDef(FnElementToMapPlan.FN_ELEMENT_TO_MAP_PLAN, FnElementToMapPlan.class),
+ new FunctionDef(FnGet.FN_GET, FnGet.class),
+ new FunctionDef(FnFunctionAnnotations.FN_FUNCTION_ANNOTATIONS, FnFunctionAnnotations.class),
+ new FunctionDef(FnFunctionIdentity.FN_FUNCTION_IDENTITY, FnFunctionIdentity.class),
+ new FunctionDef(FnDateTimeParts.FN_BUILD_DATETIME, FnDateTimeParts.class),
+ new FunctionDef(FnDateTimeParts.FN_PARTS_OF_DATETIME, FnDateTimeParts.class),
+ new FunctionDef(FnReplicate.FN_REPLICATE, FnReplicate.class),
+ new FunctionDef(FnInsertSeparator.FN_INSERT_SEPARATOR, FnInsertSeparator.class),
+ new FunctionDef(FnAllEqualDifferent.FN_ALL_EQUAL[0], FnAllEqualDifferent.class),
+ new FunctionDef(FnAllEqualDifferent.FN_ALL_EQUAL[1], FnAllEqualDifferent.class),
+ new FunctionDef(FnAllEqualDifferent.FN_ALL_DIFFERENT[0], FnAllEqualDifferent.class),
+ new FunctionDef(FnAllEqualDifferent.FN_ALL_DIFFERENT[1], FnAllEqualDifferent.class),
+ new FunctionDef(FnItemsAt.FN_ITEMS_AT, FnItemsAt.class),
+ new FunctionDef(FnHigherOrderFun40.FN_INDEX_WHERE, FnHigherOrderFun40.class),
+ new FunctionDef(FnHigherOrderFun40.FN_TAKE_WHILE, FnHigherOrderFun40.class),
+ new FunctionDef(FnHigherOrderFun40.FN_WHILE_DO, FnHigherOrderFun40.class),
+ new FunctionDef(FnHigherOrderFun40.FN_DO_UNTIL, FnHigherOrderFun40.class),
+ new FunctionDef(FnHigherOrderFun40.FN_SORT_WITH, FnHigherOrderFun40.class),
+ new FunctionDef(FnSlice.FN_SLICE[0], FnSlice.class),
+ new FunctionDef(FnSlice.FN_SLICE[1], FnSlice.class),
+ new FunctionDef(FnSlice.FN_SLICE[2], FnSlice.class),
+ new FunctionDef(FnSlice.FN_SLICE[3], FnSlice.class),
+ new FunctionDef(FnDuplicateValues.FN_DUPLICATE_VALUES[0], FnDuplicateValues.class),
+ new FunctionDef(FnDuplicateValues.FN_DUPLICATE_VALUES[1], FnDuplicateValues.class),
+ new FunctionDef(FnHash.FN_HASH[0], FnHash.class),
+ new FunctionDef(FnHash.FN_HASH[1], FnHash.class),
+ new FunctionDef(FnHash.FN_HASH[2], FnHash.class),
+ new FunctionDef(FnOp.FN_OP, FnOp.class),
+ new FunctionDef(FnChar.FN_CHAR, FnChar.class),
+ new FunctionDef(FnAtomicEqual.FN_ATOMIC_EQUAL, FnAtomicEqual.class),
+ new FunctionDef(FnExpandedQName.FN_EXPANDED_QNAME, FnExpandedQName.class),
+ new FunctionDef(FnHighestLowest.FN_HIGHEST[0], FnHighestLowest.class),
+ new FunctionDef(FnHighestLowest.FN_HIGHEST[1], FnHighestLowest.class),
+ new FunctionDef(FnHighestLowest.FN_HIGHEST[2], FnHighestLowest.class),
+ new FunctionDef(FnHighestLowest.FN_LOWEST[0], FnHighestLowest.class),
+ new FunctionDef(FnHighestLowest.FN_LOWEST[1], FnHighestLowest.class),
+ new FunctionDef(FnHighestLowest.FN_LOWEST[2], FnHighestLowest.class),
+ new FunctionDef(FnPartition.FN_PARTITION, FnPartition.class),
+ new FunctionDef(FnParseUri.FN_PARSE_URI[0], FnParseUri.class),
+ new FunctionDef(FnParseUri.FN_PARSE_URI[1], FnParseUri.class),
+ new FunctionDef(FnBuildUri.FN_BUILD_URI[0], FnBuildUri.class),
+ new FunctionDef(FnBuildUri.FN_BUILD_URI[1], FnBuildUri.class),
+ new FunctionDef(FnHigherOrderFun40.FN_SCAN_LEFT, FnHigherOrderFun40.class),
+ new FunctionDef(FnHigherOrderFun40.FN_SCAN_RIGHT, FnHigherOrderFun40.class),
+ // XQuery 4.0 functions — batch 1: HOFs and subsequence matching
+ new FunctionDef(FnEverySome.FN_EVERY[0], FnEverySome.class),
+ new FunctionDef(FnEverySome.FN_EVERY[1], FnEverySome.class),
+ new FunctionDef(FnEverySome.FN_SOME[0], FnEverySome.class),
+ new FunctionDef(FnEverySome.FN_SOME[1], FnEverySome.class),
+ new FunctionDef(FnSortBy.FN_SORT_BY, FnSortBy.class),
+ new FunctionDef(FnPartialApply.FN_PARTIAL_APPLY, FnPartialApply.class),
+ new FunctionDef(FnSubsequenceMatching.FN_CONTAINS_SUBSEQUENCE[0], FnSubsequenceMatching.class),
+ new FunctionDef(FnSubsequenceMatching.FN_CONTAINS_SUBSEQUENCE[1], FnSubsequenceMatching.class),
+ new FunctionDef(FnSubsequenceMatching.FN_STARTS_WITH_SUBSEQUENCE[0], FnSubsequenceMatching.class),
+ new FunctionDef(FnSubsequenceMatching.FN_STARTS_WITH_SUBSEQUENCE[1], FnSubsequenceMatching.class),
+ new FunctionDef(FnSubsequenceMatching.FN_ENDS_WITH_SUBSEQUENCE[0], FnSubsequenceMatching.class),
+ new FunctionDef(FnSubsequenceMatching.FN_ENDS_WITH_SUBSEQUENCE[1], FnSubsequenceMatching.class),
+ // XQuery 4.0 functions — batch 2: string/number/URI
+ new FunctionDef(FnDecodeFromUri.FN_DECODE_FROM_URI, FnDecodeFromUri.class),
+ new FunctionDef(FnParseInteger.FN_PARSE_INTEGER[0], FnParseInteger.class),
+ new FunctionDef(FnParseInteger.FN_PARSE_INTEGER[1], FnParseInteger.class),
+ new FunctionDef(FnDivideDecimals.FN_DIVIDE_DECIMALS[0], FnDivideDecimals.class),
+ new FunctionDef(FnDivideDecimals.FN_DIVIDE_DECIMALS[1], FnDivideDecimals.class),
+ // XQuery 4.0 functions — batch 3: node and type
+ new FunctionDef(FnDistinctOrderedNodes.FN_DISTINCT_ORDERED_NODES, FnDistinctOrderedNodes.class),
+ new FunctionDef(FnSiblings.FN_SIBLINGS[0], FnSiblings.class),
+ new FunctionDef(FnSiblings.FN_SIBLINGS[1], FnSiblings.class),
+ new FunctionDef(FnTypeOf.FN_TYPE_OF, FnTypeOf.class),
+ // XQuery 4.0 functions — batch 4: date/time and misc
+ new FunctionDef(FnUnixDateTime.FN_UNIX_DATETIME[0], FnUnixDateTime.class),
+ new FunctionDef(FnUnixDateTime.FN_UNIX_DATETIME[1], FnUnixDateTime.class),
+ new FunctionDef(FnMessage.FN_MESSAGE[0], FnMessage.class),
+ new FunctionDef(FnMessage.FN_MESSAGE[1], FnMessage.class),
+ // XQuery 4.0 functions — batch 2 (continued): parse-QName
+ new FunctionDef(FnParseQName.FN_PARSE_QNAME, FnParseQName.class),
+ // XQuery 4.0 functions — batch 3 (continued): type annotation
+ new FunctionDef(FnTypeAnnotation.FN_ATOMIC_TYPE_ANNOTATION, FnTypeAnnotation.class),
+ new FunctionDef(FnTypeAnnotation.FN_NODE_TYPE_ANNOTATION, FnTypeAnnotation.class),
+ // XQuery 4.0 functions — batch 4 (continued): civil-timezone
+ new FunctionDef(FnCivilTimezone.FN_CIVIL_TIMEZONE[0], FnCivilTimezone.class),
+ new FunctionDef(FnCivilTimezone.FN_CIVIL_TIMEZONE[1], FnCivilTimezone.class),
+ // XQuery 4.0 functions — batch 5: CSV functions
+ new FunctionDef(CsvFunctions.FN_CSV_TO_ARRAYS[0], CsvFunctions.class),
+ new FunctionDef(CsvFunctions.FN_CSV_TO_ARRAYS[1], CsvFunctions.class),
+ new FunctionDef(CsvFunctions.FN_PARSE_CSV[0], CsvFunctions.class),
+ new FunctionDef(CsvFunctions.FN_PARSE_CSV[1], CsvFunctions.class),
+ new FunctionDef(CsvFunctions.FN_CSV_TO_XML[0], CsvFunctions.class),
+ new FunctionDef(CsvFunctions.FN_CSV_TO_XML[1], CsvFunctions.class),
+ new FunctionDef(CsvFunctions.FN_CSV_DOC[0], CsvFunctions.class),
+ new FunctionDef(CsvFunctions.FN_CSV_DOC[1], CsvFunctions.class),
+ // XQuery 4.0 functions — batch 6: subsequence-where, seconds, in-scope-namespaces
+ new FunctionDef(FnSubsequenceWhere.FN_SUBSEQUENCE_WHERE[0], FnSubsequenceWhere.class),
+ new FunctionDef(FnSubsequenceWhere.FN_SUBSEQUENCE_WHERE[1], FnSubsequenceWhere.class),
+ new FunctionDef(FnSeconds.FN_SECONDS, FnSeconds.class),
+ new FunctionDef(FnInScopeNamespaces.FN_IN_SCOPE_NAMESPACES, FnInScopeNamespaces.class),
+ // XQuery 4.0 functions — batch 7: transitive-closure, element-to-map
+ new FunctionDef(FnTransitiveClosure.FN_TRANSITIVE_CLOSURE, FnTransitiveClosure.class),
+ new FunctionDef(FnElementToMap.FN_ELEMENT_TO_MAP[0], FnElementToMap.class),
+ new FunctionDef(FnElementToMap.FN_ELEMENT_TO_MAP[1], FnElementToMap.class),
+
+ // --- Invisible XML (feature/fn-invisible-xml) ---
+ new FunctionDef(FnInvisibleXml.SIGNATURES[0], FnInvisibleXml.class),
+ new FunctionDef(FnInvisibleXml.SIGNATURES[1], FnInvisibleXml.class)
+ // --- End Invisible XML ---
};
static {
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnOp.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnOp.java
new file mode 100644
index 00000000000..f0785604f57
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnOp.java
@@ -0,0 +1,404 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.AbstractExpression;
+import org.exist.xquery.AnalyzeContextInfo;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionCall;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.UserDefinedFunction;
+import org.exist.xquery.ValueComparison;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.util.ExpressionDumper;
+import org.exist.xquery.value.BooleanValue;
+import org.exist.xquery.value.ComputableValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+/**
+ * Implements fn:op (XQuery 4.0).
+ *
+ * Returns a function reference for a named operator.
+ */
+public class FnOp extends BasicFunction {
+
+ private static final QName PARAM_A = new QName("a", javax.xml.XMLConstants.NULL_NS_URI);
+ private static final QName PARAM_B = new QName("b", javax.xml.XMLConstants.NULL_NS_URI);
+
+ public static final FunctionSignature FN_OP = new FunctionSignature(
+ new QName("op", Function.BUILTIN_FUNCTION_NS),
+ "Returns a function that applies a given operator.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("operator", Type.STRING, Cardinality.EXACTLY_ONE, "The operator name")
+ },
+ new FunctionReturnSequenceType(Type.FUNCTION, Cardinality.EXACTLY_ONE, "a function implementing the operator"));
+
+ public FnOp(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ private static final ErrorCodes.ErrorCode FOAP0001 = new ErrorCodes.ErrorCode(
+ "FOAP0001", "Invalid operator name");
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final String operator = args[0].getStringValue();
+
+ // Validate operator name
+ if (!isValidOperator(operator)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Unknown operator: " + operator);
+ }
+
+ // Create a UserDefinedFunction with 2 parameters ($a, $b)
+ final FunctionSignature opSig = new FunctionSignature(
+ new QName("op#" + operator, Function.BUILTIN_FUNCTION_NS),
+ new SequenceType[] {
+ new FunctionParameterSequenceType("a", Type.ITEM, Cardinality.ZERO_OR_MORE, "left operand"),
+ new FunctionParameterSequenceType("b", Type.ITEM, Cardinality.ZERO_OR_MORE, "right operand")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "result"));
+
+ final UserDefinedFunction func = new UserDefinedFunction(context, opSig);
+ func.addVariable(PARAM_A);
+ func.addVariable(PARAM_B);
+
+ // Set the body to an expression that evaluates the operator
+ func.setFunctionBody(new OperatorExpression(context, operator));
+
+ final FunctionCall call = new FunctionCall(context, func);
+ call.setLocation(getLine(), getColumn());
+
+ return new FunctionReference(this, call);
+ }
+
+ private boolean isValidOperator(final String op) {
+ switch (op) {
+ case ",": case "and": case "or":
+ case "+": case "-": case "*": case "div": case "idiv": case "mod":
+ case "=": case "<": case "<=": case ">": case ">=": case "!=":
+ case "eq": case "lt": case "le": case "gt": case "ge": case "ne":
+ case "<<": case ">>": case "precedes": case "follows":
+ case "precedes-or-is": case "follows-or-is":
+ case "is": case "is-not":
+ case "||": case "|": case "union": case "except": case "intersect":
+ case "to": case "otherwise":
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Expression that evaluates an operator on two variables $a and $b
+ * from the local variable context.
+ */
+ private static class OperatorExpression extends AbstractExpression {
+
+ private final String operator;
+
+ OperatorExpression(final XQueryContext context, final String operator) {
+ super(context);
+ this.operator = operator;
+ }
+
+ @Override
+ public Sequence eval(final Sequence contextSequence, final Item contextItem) throws XPathException {
+ final Sequence a = context.resolveVariable(PARAM_A).getValue();
+ final Sequence b = context.resolveVariable(PARAM_B).getValue();
+
+ switch (operator) {
+ // Arithmetic
+ case "+": return arithmetic(a, b, "plus");
+ case "-": return arithmetic(a, b, "minus");
+ case "*": return arithmetic(a, b, "mult");
+ case "div": return arithmetic(a, b, "div");
+ case "idiv": return arithmetic(a, b, "idiv");
+ case "mod": return arithmetic(a, b, "mod");
+
+ // General comparison
+ case "=": return generalCompare(a, b, org.exist.xquery.Constants.Comparison.EQ);
+ case "!=": return generalCompare(a, b, org.exist.xquery.Constants.Comparison.NEQ);
+ case "<": return generalCompare(a, b, org.exist.xquery.Constants.Comparison.LT);
+ case "<=": return generalCompare(a, b, org.exist.xquery.Constants.Comparison.LTEQ);
+ case ">": return generalCompare(a, b, org.exist.xquery.Constants.Comparison.GT);
+ case ">=": return generalCompare(a, b, org.exist.xquery.Constants.Comparison.GTEQ);
+
+ // Value comparison
+ case "eq": return valueCompare(a, b, org.exist.xquery.Constants.Comparison.EQ);
+ case "ne": return valueCompare(a, b, org.exist.xquery.Constants.Comparison.NEQ);
+ case "lt": return valueCompare(a, b, org.exist.xquery.Constants.Comparison.LT);
+ case "le": return valueCompare(a, b, org.exist.xquery.Constants.Comparison.LTEQ);
+ case "gt": return valueCompare(a, b, org.exist.xquery.Constants.Comparison.GT);
+ case "ge": return valueCompare(a, b, org.exist.xquery.Constants.Comparison.GTEQ);
+
+ // Boolean
+ case "and": return BooleanValue.valueOf(a.effectiveBooleanValue() && b.effectiveBooleanValue());
+ case "or": return BooleanValue.valueOf(a.effectiveBooleanValue() || b.effectiveBooleanValue());
+
+ // String concatenation
+ case "||": return new StringValue(this, a.getStringValue() + b.getStringValue());
+
+ // Sequence
+ case ",": return opComma(a, b);
+ case "|":
+ case "union": return opVenn(a, b, "union");
+ case "except": return opVenn(a, b, "except");
+ case "intersect": return opVenn(a, b, "intersect");
+ case "to": return opTo(a, b);
+ case "otherwise": return a.isEmpty() ? b : a;
+
+ // Node comparison
+ case "is": return nodeIs(a, b);
+ case "is-not": return nodeIsNot(a, b);
+ case "<<":
+ case "precedes": return nodePrecedes(a, b);
+ case ">>":
+ case "follows": return nodeFollows(a, b);
+ case "precedes-or-is": return nodePrecedesOrIs(a, b);
+ case "follows-or-is": return nodeFollowsOrIs(a, b);
+
+ default:
+ throw new XPathException(this, ErrorCodes.FOJS0005, "Unknown operator: " + operator);
+ }
+ }
+
+ private Sequence arithmetic(final Sequence a, final Sequence b, final String op) throws XPathException {
+ if (a.isEmpty() || b.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ final ComputableValue left = toComputable(a.itemAt(0).atomize());
+ final ComputableValue right = toComputable(b.itemAt(0).atomize());
+ switch (op) {
+ case "plus": return left.plus(right);
+ case "minus": return left.minus(right);
+ case "mult": return left.mult(right);
+ case "div": return left.div(right);
+ case "idiv": return ((org.exist.xquery.value.NumericValue) left).idiv((org.exist.xquery.value.NumericValue) right);
+ case "mod": return ((org.exist.xquery.value.NumericValue) left).mod((org.exist.xquery.value.NumericValue) right);
+ default: throw new IllegalStateException();
+ }
+ }
+
+ private Sequence generalCompare(final Sequence a, final Sequence b,
+ final org.exist.xquery.Constants.Comparison comp) throws XPathException {
+ // General comparison: existential semantics — true if any pair matches
+ if (a.isEmpty() || b.isEmpty()) {
+ return BooleanValue.FALSE;
+ }
+ final com.ibm.icu.text.Collator collator = context.getDefaultCollator();
+ for (int i = 0; i < a.getItemCount(); i++) {
+ final org.exist.xquery.value.AtomicValue lv = a.itemAt(i).atomize();
+ for (int j = 0; j < b.getItemCount(); j++) {
+ final org.exist.xquery.value.AtomicValue rv = b.itemAt(j).atomize();
+ if (ValueComparison.compareAtomic(collator, lv, rv,
+ org.exist.xquery.Constants.StringTruncationOperator.NONE, comp)) {
+ return BooleanValue.TRUE;
+ }
+ }
+ }
+ return BooleanValue.FALSE;
+ }
+
+ private Sequence valueCompare(final Sequence a, final Sequence b,
+ final org.exist.xquery.Constants.Comparison comp) throws XPathException {
+ if (a.isEmpty() || b.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ if (a.getItemCount() > 1 || b.getItemCount() > 1) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Value comparison requires singleton operands");
+ }
+ final org.exist.xquery.value.AtomicValue lv = a.itemAt(0).atomize();
+ final org.exist.xquery.value.AtomicValue rv = b.itemAt(0).atomize();
+ final com.ibm.icu.text.Collator collator = context.getDefaultCollator();
+ return BooleanValue.valueOf(ValueComparison.compareAtomic(collator, lv, rv,
+ org.exist.xquery.Constants.StringTruncationOperator.NONE, comp));
+ }
+
+ private Sequence opComma(final Sequence a, final Sequence b) throws XPathException {
+ final ValueSequence result = new ValueSequence(a.getItemCount() + b.getItemCount());
+ result.addAll(a);
+ result.addAll(b);
+ return result;
+ }
+
+ private Sequence opVenn(final Sequence a, final Sequence b, final String op) throws XPathException {
+ // Check that operands are nodes
+ for (int i = 0; i < a.getItemCount(); i++) {
+ if (!(a.itemAt(i) instanceof org.w3c.dom.Node)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Set operation requires node operands, got " + Type.getTypeName(a.itemAt(i).getType()));
+ }
+ }
+ for (int i = 0; i < b.getItemCount(); i++) {
+ if (!(b.itemAt(i) instanceof org.w3c.dom.Node)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Set operation requires node operands, got " + Type.getTypeName(b.itemAt(i).getType()));
+ }
+ }
+ try {
+ switch (op) {
+ case "union": return a.toNodeSet().union(b.toNodeSet());
+ case "except": return a.toNodeSet().except(b.toNodeSet());
+ case "intersect": return a.toNodeSet().intersection(b.toNodeSet());
+ default: throw new IllegalStateException();
+ }
+ } catch (final XPathException e) {
+ throw new XPathException(this, ErrorCodes.XPTY0004, e.getMessage());
+ }
+ }
+
+ private Sequence opTo(final Sequence a, final Sequence b) throws XPathException {
+ if (a.isEmpty() || b.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ final long start = ((IntegerValue) a.itemAt(0)).getLong();
+ final long end = ((IntegerValue) b.itemAt(0)).getLong();
+ if (start > end) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ final ValueSequence result = new ValueSequence((int) (end - start + 1));
+ for (long i = start; i <= end; i++) {
+ result.add(new IntegerValue(this, i));
+ }
+ return result;
+ }
+
+ private void checkNodeOperands(final Sequence a, final Sequence b) throws XPathException {
+ if (!a.isEmpty() && !(a.itemAt(0) instanceof org.w3c.dom.Node)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Node comparison requires node operands, got " + Type.getTypeName(a.itemAt(0).getType()));
+ }
+ if (!b.isEmpty() && !(b.itemAt(0) instanceof org.w3c.dom.Node)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Node comparison requires node operands, got " + Type.getTypeName(b.itemAt(0).getType()));
+ }
+ }
+
+ private Sequence nodeIs(final Sequence a, final Sequence b) throws XPathException {
+ checkNodeOperands(a, b);
+ if (a.isEmpty() || b.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ return BooleanValue.valueOf(a.itemAt(0).equals(b.itemAt(0)));
+ }
+
+ private Sequence nodeIsNot(final Sequence a, final Sequence b) throws XPathException {
+ checkNodeOperands(a, b);
+ if (a.isEmpty() || b.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ return BooleanValue.valueOf(!a.itemAt(0).equals(b.itemAt(0)));
+ }
+
+ private ComputableValue toComputable(final org.exist.xquery.value.AtomicValue v) throws XPathException {
+ if (v instanceof ComputableValue) {
+ return (ComputableValue) v;
+ }
+ // Untyped atomic → promote to xs:double for arithmetic
+ if (v.getType() == Type.UNTYPED_ATOMIC) {
+ return (ComputableValue) v.convertTo(Type.DOUBLE);
+ }
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Cannot use " + Type.getTypeName(v.getType()) + " in arithmetic");
+ }
+
+ private int nodeCompare(final Sequence a, final Sequence b) throws XPathException {
+ checkNodeOperands(a, b);
+ final Item left = a.itemAt(0);
+ final Item right = b.itemAt(0);
+ if (left instanceof org.exist.dom.persistent.NodeProxy && right instanceof org.exist.dom.persistent.NodeProxy) {
+ return ((org.exist.dom.persistent.NodeProxy) left).compareTo((org.exist.dom.persistent.NodeProxy) right);
+ }
+ // For in-memory nodes, compare using NodeId if available
+ if (left instanceof org.exist.dom.memtree.NodeImpl && right instanceof org.exist.dom.memtree.NodeImpl) {
+ final org.exist.dom.memtree.NodeImpl leftNode = (org.exist.dom.memtree.NodeImpl) left;
+ final org.exist.dom.memtree.NodeImpl rightNode = (org.exist.dom.memtree.NodeImpl) right;
+ return Integer.compare(leftNode.getNodeNumber(), rightNode.getNodeNumber());
+ }
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Node comparison requires node operands");
+ }
+
+ private Sequence nodePrecedes(final Sequence a, final Sequence b) throws XPathException {
+ checkNodeOperands(a, b);
+ if (a.isEmpty() || b.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ return BooleanValue.valueOf(nodeCompare(a, b) < 0);
+ }
+
+ private Sequence nodeFollows(final Sequence a, final Sequence b) throws XPathException {
+ checkNodeOperands(a, b);
+ if (a.isEmpty() || b.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ return BooleanValue.valueOf(nodeCompare(a, b) > 0);
+ }
+
+ private Sequence nodePrecedesOrIs(final Sequence a, final Sequence b) throws XPathException {
+ checkNodeOperands(a, b);
+ if (a.isEmpty() || b.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ return BooleanValue.valueOf(nodeCompare(a, b) <= 0);
+ }
+
+ private Sequence nodeFollowsOrIs(final Sequence a, final Sequence b) throws XPathException {
+ checkNodeOperands(a, b);
+ if (a.isEmpty() || b.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ return BooleanValue.valueOf(nodeCompare(a, b) >= 0);
+ }
+
+ @Override
+ public int returnsType() {
+ return Type.ITEM;
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ // nothing to analyze
+ }
+
+ @Override
+ public void dump(final ExpressionDumper dumper) {
+ dumper.display("op(\"" + operator + "\")");
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnParseHtml.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnParseHtml.java
new file mode 100644
index 00000000000..b7c0325aba9
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnParseHtml.java
@@ -0,0 +1,177 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import nu.validator.htmlparser.common.XmlViolationPolicy;
+import nu.validator.htmlparser.sax.HtmlParser;
+import org.exist.Namespaces;
+import org.exist.dom.QName;
+import org.exist.dom.memtree.SAXAdapter;
+import org.exist.xquery.*;
+import org.exist.xquery.functions.map.MapType;
+import org.exist.xquery.value.*;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.nio.charset.Charset;
+
+/**
+ * Implements fn:parse-html (XQuery 4.0).
+ *
+ * Parses an HTML string (which may be malformed) into an XDM document node
+ * with all elements in the XHTML namespace.
+ */
+public class FnParseHtml extends BasicFunction {
+
+ public static final FunctionSignature[] FN_PARSE_HTML = {
+ new FunctionSignature(
+ new QName("parse-html", Function.BUILTIN_FUNCTION_NS),
+ "Parses the supplied HTML string into an XDM document node. " +
+ "The input need not be well-formed; it is processed by an HTML parser " +
+ "that corrects errors and produces well-formed XHTML output.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.ITEM,
+ Cardinality.ZERO_OR_ONE, "The HTML to parse (string or binary)")
+ },
+ new FunctionReturnSequenceType(Type.DOCUMENT, Cardinality.ZERO_OR_ONE,
+ "The parsed XHTML document")),
+ new FunctionSignature(
+ new QName("parse-html", Function.BUILTIN_FUNCTION_NS),
+ "Parses the supplied HTML string into an XDM document node with options. " +
+ "The input need not be well-formed; it is processed by an HTML parser " +
+ "that corrects errors and produces well-formed XHTML output.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.ITEM,
+ Cardinality.ZERO_OR_ONE, "The HTML to parse (string or binary)"),
+ new FunctionParameterSequenceType("options", Type.MAP_ITEM,
+ Cardinality.EXACTLY_ONE, "Options map")
+ },
+ new FunctionReturnSequenceType(Type.DOCUMENT, Cardinality.ZERO_OR_ONE,
+ "The parsed XHTML document"))
+ };
+
+ public FnParseHtml(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ // Extract options if present
+ boolean failOnError = false;
+ String encoding = "UTF-8";
+ if (getArgumentCount() == 2 && !args[1].isEmpty()) {
+ final MapType options = (MapType) args[1].itemAt(0);
+ failOnError = getBooleanOption(options, "fail-on-error", false);
+ encoding = getStringOption(options, "encoding", "UTF-8");
+
+ // Validate option types per spec — unknown options with wrong types raise XPTY0004
+ validateOptionType(options, "include-template-content");
+ validateOptionType(options, "exclude-template-content");
+ }
+
+ // Get the HTML content as a string
+ final String htmlContent = getHtmlContent(args[0].itemAt(0), encoding);
+
+ // Parse with the configured HTML-to-XML parser
+ return parseHtml(htmlContent, failOnError);
+ }
+
+ private String getHtmlContent(final Item item, final String encoding) throws XPathException {
+ if (item instanceof BinaryValue) {
+ final BinaryValue binary = (BinaryValue) item;
+ try (final java.io.InputStream is = binary.getInputStream()) {
+ final Charset charset = Charset.forName(encoding);
+ return new String(is.readAllBytes(), charset);
+ } catch (final Exception e) {
+ throw new XPathException(this, ErrorCodes.FODC0006,
+ "Error decoding binary value: " + e.getMessage());
+ }
+ }
+ return item.getStringValue();
+ }
+
+ private Sequence parseHtml(final String htmlContent, final boolean failOnError) throws XPathException {
+ final SAXAdapter adapter = new SAXAdapter(this, context);
+
+ try {
+ // Use Validator.nu HTML5 parser — SAX-based, same pipeline as NekoHTML
+ // but follows the WHATWG HTML5 parsing algorithm. Outputs XHTML namespace
+ // by default, handles , , foreign content.
+ final HtmlParser reader = new HtmlParser(XmlViolationPolicy.ALTER_INFOSET);
+
+ reader.setContentHandler(adapter);
+ reader.setProperty(Namespaces.SAX_LEXICAL_HANDLER, adapter);
+
+ final InputSource src = new InputSource(new StringReader(htmlContent));
+ reader.parse(src);
+
+ } catch (final SAXException e) {
+ if (failOnError) {
+ throw new XPathException(this, ErrorCodes.FODC0011,
+ "HTML parsing error: " + e.getMessage());
+ }
+ } catch (final IOException e) {
+ throw new XPathException(this, ErrorCodes.FODC0006,
+ "Error reading HTML input: " + e.getMessage());
+ }
+
+ return adapter.getDocument();
+ }
+
+ private boolean getBooleanOption(final MapType options, final String key,
+ final boolean defaultValue) throws XPathException {
+ final Sequence value = options.get(new StringValue(key));
+ if (value != null && !value.isEmpty()) {
+ return value.itemAt(0).convertTo(Type.BOOLEAN).effectiveBooleanValue();
+ }
+ return defaultValue;
+ }
+
+ private String getStringOption(final MapType options, final String key,
+ final String defaultValue) throws XPathException {
+ final Sequence value = options.get(new StringValue(key));
+ if (value != null && !value.isEmpty()) {
+ final Item item = value.itemAt(0);
+ if (!(item instanceof StringValue)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Option '" + key + "' must be a string, got " + Type.getTypeName(item.getType()));
+ }
+ return item.getStringValue();
+ }
+ return defaultValue;
+ }
+
+ private void validateOptionType(final MapType options, final String key) throws XPathException {
+ final Sequence value = options.get(new StringValue(key));
+ if (value != null && !value.isEmpty()) {
+ // These options are not supported — raise XPTY0004 per spec
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Option '" + key + "' is not supported");
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnParseInteger.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnParseInteger.java
new file mode 100644
index 00000000000..6031d9d161a
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnParseInteger.java
@@ -0,0 +1,142 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+import java.math.BigInteger;
+
+/**
+ * Implements XQuery 4.0 fn:parse-integer.
+ *
+ * fn:parse-integer($value, $radix?) parses a string as an integer in the given radix (2-36).
+ */
+public class FnParseInteger extends BasicFunction {
+
+ private static final ErrorCodes.ErrorCode FORG0011 = new ErrorCodes.ErrorCode("FORG0011",
+ "Radix is out of range (must be 2-36)");
+ private static final ErrorCodes.ErrorCode FORG0012 = new ErrorCodes.ErrorCode("FORG0012",
+ "Invalid integer string for the given radix");
+
+ public static final FunctionSignature[] FN_PARSE_INTEGER = {
+ new FunctionSignature(
+ new QName("parse-integer", Function.BUILTIN_FUNCTION_NS),
+ "Parses a string as an integer in the given radix.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.STRING, Cardinality.ZERO_OR_ONE, "The string to parse"),
+ new FunctionParameterSequenceType("radix", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The radix (2-36), default 10")
+ },
+ new FunctionReturnSequenceType(Type.INTEGER, Cardinality.ZERO_OR_ONE, "the parsed integer")),
+ new FunctionSignature(
+ new QName("parse-integer", Function.BUILTIN_FUNCTION_NS),
+ "Parses a string as a decimal integer.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.STRING, Cardinality.ZERO_OR_ONE, "The string to parse")
+ },
+ new FunctionReturnSequenceType(Type.INTEGER, Cardinality.ZERO_OR_ONE, "the parsed integer"))
+ };
+
+ public FnParseInteger(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final String value = args[0].getStringValue();
+
+ int radix = 10;
+ if (args.length > 1 && !args[1].isEmpty()) {
+ radix = (int) ((IntegerValue) args[1].itemAt(0)).getLong();
+ }
+
+ if (radix < 2 || radix > 36) {
+ throw new XPathException(this, FORG0011, "Radix must be between 2 and 36, got: " + radix);
+ }
+
+ // Preprocess: strip whitespace and underscores
+ String stripped = value.replaceAll("[\\s_]", "");
+
+ if (stripped.isEmpty()) {
+ throw new XPathException(this, FORG0012, "Empty string after stripping whitespace and underscores");
+ }
+
+ // Handle optional sign
+ boolean negative = false;
+ if (stripped.charAt(0) == '-') {
+ negative = true;
+ stripped = stripped.substring(1);
+ } else if (stripped.charAt(0) == '+') {
+ stripped = stripped.substring(1);
+ }
+
+ if (stripped.isEmpty()) {
+ throw new XPathException(this, FORG0012, "No digits found after sign");
+ }
+
+ // Validate digits for the given radix
+ final String lowerStripped = stripped.toLowerCase();
+ for (int i = 0; i < lowerStripped.length(); i++) {
+ final char c = lowerStripped.charAt(i);
+ final int digit;
+ if (c >= '0' && c <= '9') {
+ digit = c - '0';
+ } else if (c >= 'a' && c <= 'z') {
+ digit = c - 'a' + 10;
+ } else {
+ throw new XPathException(this, FORG0012,
+ "Invalid character '" + c + "' for radix " + radix);
+ }
+ if (digit >= radix) {
+ throw new XPathException(this, FORG0012,
+ "Invalid character '" + c + "' for radix " + radix);
+ }
+ }
+
+ try {
+ BigInteger result = new BigInteger(lowerStripped, radix);
+ if (negative) {
+ result = result.negate();
+ }
+ return new IntegerValue(this, result);
+ } catch (final NumberFormatException e) {
+ throw new XPathException(this, FORG0012,
+ "Cannot parse '" + value + "' as integer with radix " + radix);
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnParseQName.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnParseQName.java
new file mode 100644
index 00000000000..f44f4df396c
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnParseQName.java
@@ -0,0 +1,174 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.QNameValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+import javax.xml.XMLConstants;
+
+/**
+ * Implements XQuery 4.0 fn:parse-QName.
+ *
+ * fn:parse-QName($value as xs:string?) as xs:QName?
+ *
+ * Parses an EQName string to xs:QName. Supports:
+ * - NCName (no namespace)
+ * - prefix:local (resolved from static context)
+ * - Q{uri}local (URIQualifiedName)
+ */
+public class FnParseQName extends BasicFunction {
+
+ private static final ErrorCodes.ErrorCode FOCA0002 = new ErrorCodes.ErrorCode("FOCA0002",
+ "Invalid lexical form for xs:QName");
+
+ public static final FunctionSignature FN_PARSE_QNAME = new FunctionSignature(
+ new QName("parse-QName", Function.BUILTIN_FUNCTION_NS),
+ "Parses an EQName string to xs:QName.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.STRING, Cardinality.ZERO_OR_ONE, "The EQName string to parse")
+ },
+ new FunctionReturnSequenceType(Type.QNAME, Cardinality.ZERO_OR_ONE, "the parsed QName"));
+
+ public FnParseQName(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final String value = args[0].getStringValue().strip();
+
+ if (value.isEmpty()) {
+ throw new XPathException(this, FOCA0002, "Empty string is not a valid EQName");
+ }
+
+ // URIQualifiedName: Q{uri}local
+ if (value.startsWith("Q{")) {
+ return parseURIQualifiedName(value);
+ }
+
+ // Prefixed QName: prefix:local
+ final int colon = value.indexOf(':');
+ if (colon > 0) {
+ final String prefix = value.substring(0, colon);
+ final String local = value.substring(colon + 1);
+
+ if (!isNCName(prefix) || !isNCName(local)) {
+ throw new XPathException(this, FOCA0002,
+ "Invalid prefixed QName: " + value);
+ }
+
+ final String uri = context.getURIForPrefix(prefix);
+ if (uri == null || uri.isEmpty()) {
+ throw new XPathException(this, ErrorCodes.FONS0004,
+ "Undeclared prefix: " + prefix);
+ }
+
+ return new QNameValue(this, context, new QName(local, uri, prefix));
+ }
+
+ // NCName (no namespace)
+ if (!isNCName(value)) {
+ throw new XPathException(this, FOCA0002,
+ "Invalid NCName: " + value);
+ }
+
+ return new QNameValue(this, context, new QName(value, XMLConstants.NULL_NS_URI));
+ }
+
+ private Sequence parseURIQualifiedName(final String value) throws XPathException {
+ final int closeBrace = value.indexOf('}');
+ if (closeBrace < 0) {
+ throw new XPathException(this, FOCA0002,
+ "Missing closing '}' in URIQualifiedName: " + value);
+ }
+
+ final String uri = value.substring(2, closeBrace);
+ final String rest = value.substring(closeBrace + 1);
+
+ if (rest.isEmpty()) {
+ throw new XPathException(this, FOCA0002,
+ "Missing local name after Q{...}: " + value);
+ }
+
+ // rest may be prefix:local or just local
+ final int colon = rest.indexOf(':');
+ final String local;
+ final String prefix;
+ if (colon > 0) {
+ prefix = rest.substring(0, colon);
+ local = rest.substring(colon + 1);
+ } else {
+ prefix = XMLConstants.DEFAULT_NS_PREFIX;
+ local = rest;
+ }
+
+ if (!isNCName(local) || (colon > 0 && !isNCName(prefix))) {
+ throw new XPathException(this, FOCA0002,
+ "Invalid URIQualifiedName: " + value);
+ }
+
+ return new QNameValue(this, context, new QName(local, uri, prefix));
+ }
+
+ private static boolean isNCName(final String s) {
+ if (s == null || s.isEmpty()) {
+ return false;
+ }
+ if (!isNCNameStart(s.charAt(0))) {
+ return false;
+ }
+ for (int i = 1; i < s.length(); i++) {
+ if (!isNCNameChar(s.charAt(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean isNCNameStart(final char c) {
+ return Character.isLetter(c) || c == '_';
+ }
+
+ private static boolean isNCNameChar(final char c) {
+ return Character.isLetterOrDigit(c) || c == '.' || c == '-' || c == '_'
+ || c == '\u00B7'
+ || (c >= '\u0300' && c <= '\u036F')
+ || (c >= '\u203F' && c <= '\u2040');
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnParseUri.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnParseUri.java
new file mode 100644
index 00000000000..5af1d9e7466
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnParseUri.java
@@ -0,0 +1,462 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.functions.map.MapType;
+import org.exist.xquery.value.AtomicValue;
+import org.exist.xquery.value.BooleanValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+/**
+ * Implements fn:parse-uri (XQuery 4.0).
+ *
+ * Parses a URI string and returns a map of its constituent parts,
+ * following the algorithm specified in XPath Functions 4.0.
+ */
+public class FnParseUri extends BasicFunction {
+
+ private static final ErrorCodes.ErrorCode FOUR0001 = new ErrorCodes.ErrorCode(
+ "FOUR0001", "Invalid URI");
+
+ private static final Pattern SCHEME_PATTERN =
+ Pattern.compile("^([a-zA-Z][A-Za-z0-9+\\-.]+):(.*)$");
+ private static final Pattern FRAGMENT_PATTERN =
+ Pattern.compile("^(.*?)#(.*)$");
+ private static final Pattern QUERY_PATTERN =
+ Pattern.compile("^(.*?)\\?(.*)$");
+ private static final Pattern DRIVE_LETTER_PATTERN =
+ Pattern.compile("^/*([a-zA-Z][:|].*)$");
+ private static final Pattern AUTHORITY_PATH_PATTERN =
+ Pattern.compile("^//([^/]*)(/.*)$");
+ private static final Pattern AUTHORITY_ONLY_PATTERN =
+ Pattern.compile("^//([^/]+)$");
+
+ // Authority parsing patterns from the spec
+ private static final Pattern AUTH_IPV6_PATTERN =
+ Pattern.compile("^(([^@]*)@)?(\\[[^\\]]*\\])(:([^:]*))?$");
+ private static final Pattern AUTH_IPV6_OPEN_PATTERN =
+ Pattern.compile("^(([^@]*)@)?\\[.*$");
+ private static final Pattern AUTH_NORMAL_PATTERN =
+ Pattern.compile("^(([^@]*)@)?([^:]+)(:([^:]*))?$");
+
+ private static final Set NON_HIERARCHICAL_SCHEMES = new HashSet<>(Arrays.asList(
+ "mailto", "news", "urn", "tel", "tag", "jar", "data", "javascript", "cid", "mid"
+ ));
+ private static final Set HIERARCHICAL_SCHEMES = new HashSet<>(Arrays.asList(
+ "http", "https", "ftp", "ftps", "sftp", "file", "ssh", "telnet",
+ "ldap", "ldaps", "svn", "svn+ssh", "git", "s3", "hdfs"
+ ));
+
+ public static final FunctionSignature[] FN_PARSE_URI = {
+ new FunctionSignature(
+ new QName("parse-uri", Function.BUILTIN_FUNCTION_NS),
+ "Parses a URI and returns a map of its components.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.STRING,
+ Cardinality.ZERO_OR_ONE, "The URI to parse")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM,
+ Cardinality.ZERO_OR_ONE, "map of URI components")),
+ new FunctionSignature(
+ new QName("parse-uri", Function.BUILTIN_FUNCTION_NS),
+ "Parses a URI and returns a map of its components.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.STRING,
+ Cardinality.ZERO_OR_ONE, "The URI to parse"),
+ new FunctionParameterSequenceType("options", Type.MAP_ITEM,
+ Cardinality.ZERO_OR_ONE, "Options map")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM,
+ Cardinality.ZERO_OR_ONE, "map of URI components"))
+ };
+
+ public FnParseUri(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final String originalUri = args[0].getStringValue();
+ if (originalUri.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ // Parse options
+ boolean allowDeprecated = false;
+ boolean omitDefaultPorts = false;
+ boolean uncPath = false;
+ if (args.length > 1 && !args[1].isEmpty()) {
+ final MapType options = (MapType) args[1].itemAt(0);
+ allowDeprecated = getBooleanOption(options, "allow-deprecated-features", false);
+ omitDefaultPorts = getBooleanOption(options, "omit-default-ports", false);
+ uncPath = getBooleanOption(options, "unc-path", false);
+ }
+
+ // Step 1: Replace backslashes with forward slashes
+ String str = originalUri.replace('\\', '/');
+
+ // Step 2: Strip fragment
+ String fragment = null;
+ Matcher m = FRAGMENT_PATTERN.matcher(str);
+ if (m.matches()) {
+ str = m.group(1);
+ fragment = m.group(2);
+ if (fragment.isEmpty()) {
+ fragment = null;
+ } else {
+ fragment = uriDecode(fragment);
+ }
+ }
+
+ // Step 3: Strip query
+ String query = null;
+ m = QUERY_PATTERN.matcher(str);
+ if (m.matches()) {
+ str = m.group(1);
+ query = m.group(2);
+ if (query.isEmpty()) {
+ query = null;
+ }
+ }
+
+ // Step 4: Identify scheme
+ String scheme = null;
+ m = SCHEME_PATTERN.matcher(str);
+ if (m.matches()) {
+ scheme = m.group(1);
+ str = m.group(2);
+ }
+
+ // Step 5: absolute flag — scheme present and no fragment
+ Boolean absolute = (scheme != null && fragment == null) ? Boolean.TRUE : null;
+
+ // Step 6: Handle file: and drive letters
+ String filepath = null;
+ if (scheme == null || "file".equalsIgnoreCase(scheme)) {
+ m = DRIVE_LETTER_PATTERN.matcher(str);
+ if (m.matches()) {
+ scheme = "file";
+ String matched = m.group(1);
+ // Replace | with : if necessary
+ if (matched.length() > 1 && matched.charAt(1) == '|') {
+ matched = matched.charAt(0) + ":" + matched.substring(2);
+ }
+ str = "/" + matched;
+ } else if (uncPath && scheme == null) {
+ scheme = "file";
+ }
+ }
+
+ // Step 7: Determine hierarchical
+ Boolean hierarchical = null;
+ if (scheme != null) {
+ final String schemeLower = scheme.toLowerCase();
+ if (HIERARCHICAL_SCHEMES.contains(schemeLower)) {
+ hierarchical = Boolean.TRUE;
+ } else if (NON_HIERARCHICAL_SCHEMES.contains(schemeLower)) {
+ hierarchical = Boolean.FALSE;
+ } else if (str.isEmpty()) {
+ hierarchical = null;
+ } else {
+ hierarchical = str.startsWith("/");
+ }
+ } else {
+ // No scheme — hierarchical if starts with /
+ if (!str.isEmpty()) {
+ hierarchical = str.startsWith("/");
+ }
+ }
+
+ // Non-hierarchical → absolute is not applicable
+ if (hierarchical != null && !hierarchical) {
+ absolute = null;
+ }
+
+ // Step 8: Handle file: scheme filepath
+ if ("file".equalsIgnoreCase(scheme)) {
+ if (uncPath) {
+ // UNC path handling
+ final Pattern uncPattern = Pattern.compile("^/*(//[^/].*)$");
+ m = uncPattern.matcher(str);
+ if (m.matches()) {
+ filepath = m.group(1);
+ str = filepath;
+ }
+ }
+ if (filepath == null) {
+ // Check for //X:/ pattern (multiple leading slashes before drive)
+ if (str.matches("^//*[A-Za-z]:/.*$")) {
+ // Remove all but one leading slash
+ str = str.replaceFirst("^/+", "/");
+ filepath = str.replaceFirst("^/", "");
+ } else {
+ // Replace multiple leading slashes with single slash
+ str = str.replaceFirst("^/+", "/");
+ filepath = str;
+ }
+ }
+ }
+
+ // Step 9: Extract authority (hierarchical URIs only, NOT file: scheme)
+ String authority = null;
+ if (hierarchical != null && hierarchical
+ && !"file".equalsIgnoreCase(scheme)) {
+ m = AUTHORITY_ONLY_PATTERN.matcher(str);
+ if (m.matches()) {
+ authority = m.group(1);
+ str = "";
+ } else {
+ m = AUTHORITY_PATH_PATTERN.matcher(str);
+ if (m.matches()) {
+ authority = m.group(1);
+ str = m.group(2);
+ }
+ }
+ // Treat empty authority as absent
+ if (authority != null && authority.isEmpty()) {
+ authority = null;
+ }
+ }
+
+ // Step 10: Parse authority into userinfo, host, port
+ String userinfo = null;
+ String host = null;
+ Integer port = null;
+ if (authority != null && !authority.isEmpty()) {
+ // Parse userinfo
+ final int atIdx = authority.indexOf('@');
+ String authRemainder = authority;
+ if (atIdx >= 0) {
+ userinfo = authority.substring(0, atIdx);
+ authRemainder = authority.substring(atIdx + 1);
+ // Check for deprecated password
+ if (!allowDeprecated && userinfo.contains(":")) {
+ final String password = userinfo.substring(userinfo.indexOf(':') + 1);
+ if (!password.isEmpty()) {
+ userinfo = null;
+ }
+ }
+ }
+
+ // Parse host and port from authRemainder
+ m = AUTH_IPV6_PATTERN.matcher(authority);
+ if (m.matches()) {
+ host = m.group(3);
+ final String portStr = m.group(5);
+ if (portStr != null && !portStr.isEmpty()) {
+ try {
+ port = Integer.parseInt(portStr);
+ } catch (final NumberFormatException ignored) {
+ }
+ }
+ } else {
+ m = AUTH_IPV6_OPEN_PATTERN.matcher(authority);
+ if (m.matches()) {
+ throw new XPathException(this, FOUR0001,
+ "Unmatched '[' in URI authority: " + authority);
+ }
+ m = AUTH_NORMAL_PATTERN.matcher(authority);
+ if (m.matches()) {
+ host = m.group(3);
+ final String portStr = m.group(5);
+ if (portStr != null && !portStr.isEmpty()) {
+ try {
+ port = Integer.parseInt(portStr);
+ } catch (final NumberFormatException ignored) {
+ }
+ }
+ }
+ }
+
+ // Omit default ports
+ if (omitDefaultPorts && port != null && scheme != null) {
+ if (isDefaultPort(scheme.toLowerCase(), port)) {
+ port = null;
+ }
+ }
+ }
+
+ // Step 11: Determine path and filepath
+ final String path = str.isEmpty() ? null : str;
+ if (scheme == null && filepath == null && path != null) {
+ filepath = path;
+ }
+ // URI-decode filepath
+ if (filepath != null) {
+ filepath = uriDecode(filepath);
+ }
+
+ // Step 12: Build path-segments
+ List pathSegments = null;
+ if (path != null) {
+ final String[] parts = str.split("/", -1);
+ pathSegments = new ArrayList<>(parts.length);
+ for (final String part : parts) {
+ pathSegments.add(uriDecode(part));
+ }
+ }
+
+ // Step 13: Parse query parameters
+ MapType queryParams = null;
+ if (query != null && !query.isEmpty()) {
+ queryParams = new MapType(this, context);
+ for (final String param : query.split("&")) {
+ final int eq = param.indexOf('=');
+ final String key;
+ final String value;
+ if (eq >= 0) {
+ key = uriDecode(param.substring(0, eq));
+ value = uriDecode(param.substring(eq + 1));
+ } else {
+ key = "";
+ value = uriDecode(param);
+ }
+ final AtomicValue keyVal = new StringValue(this, key);
+ final Sequence existing = queryParams.get(keyVal);
+ if (existing != null && !existing.isEmpty()) {
+ final ValueSequence combined = new ValueSequence();
+ combined.addAll(existing);
+ combined.add(new StringValue(this, value));
+ queryParams.add(keyVal, combined);
+ } else {
+ queryParams.add(keyVal, new StringValue(this, value));
+ }
+ }
+ }
+
+ // Build result map — omit keys with empty values per spec
+ final MapType result = new MapType(this, context);
+
+ // uri (the original input, always present)
+ result.add(new StringValue(this, "uri"), new StringValue(this, originalUri));
+
+ if (scheme != null) {
+ result.add(new StringValue(this, "scheme"), new StringValue(this, scheme));
+ }
+ if (hierarchical != null) {
+ result.add(new StringValue(this, "hierarchical"), BooleanValue.valueOf(hierarchical));
+ }
+ if (absolute != null) {
+ result.add(new StringValue(this, "absolute"), BooleanValue.valueOf(absolute));
+ }
+ if (authority != null) {
+ result.add(new StringValue(this, "authority"), new StringValue(this, authority));
+ }
+ if (userinfo != null) {
+ result.add(new StringValue(this, "userinfo"), new StringValue(this, userinfo));
+ }
+ if (host != null) {
+ result.add(new StringValue(this, "host"), new StringValue(this, host));
+ }
+ if (port != null) {
+ result.add(new StringValue(this, "port"), new IntegerValue(this, port));
+ }
+ if (path != null) {
+ result.add(new StringValue(this, "path"), new StringValue(this, path));
+ }
+ if (filepath != null) {
+ result.add(new StringValue(this, "filepath"), new StringValue(this, filepath));
+ }
+ if (pathSegments != null) {
+ final ValueSequence segSeq = new ValueSequence(pathSegments.size());
+ for (final String seg : pathSegments) {
+ segSeq.add(new StringValue(this, seg));
+ }
+ result.add(new StringValue(this, "path-segments"), segSeq);
+ }
+ if (query != null) {
+ result.add(new StringValue(this, "query"), new StringValue(this, query));
+ }
+ if (queryParams != null) {
+ result.add(new StringValue(this, "query-parameters"), queryParams);
+ } else if (query != null) {
+ result.add(new StringValue(this, "query-parameters"), new MapType(this, context));
+ }
+ if (fragment != null) {
+ result.add(new StringValue(this, "fragment"), new StringValue(this, fragment));
+ }
+
+ return result;
+ }
+
+ private boolean getBooleanOption(final MapType options, final String key,
+ final boolean defaultValue) throws XPathException {
+ final Sequence val = options.get(new StringValue(this, key));
+ if (val != null && !val.isEmpty()) {
+ return val.effectiveBooleanValue();
+ }
+ return defaultValue;
+ }
+
+ private static boolean isDefaultPort(final String scheme, final int port) {
+ switch (scheme) {
+ case "http": return port == 80;
+ case "https": return port == 443;
+ case "ftp": return port == 21;
+ case "ssh": return port == 22;
+ default: return false;
+ }
+ }
+
+ private static String uriDecode(final String s) {
+ if (s == null || s.isEmpty()) {
+ return s;
+ }
+ if (s.indexOf('%') < 0 && s.indexOf('+') < 0) {
+ return s;
+ }
+ try {
+ return URLDecoder.decode(s, "UTF-8");
+ } catch (final UnsupportedEncodingException | IllegalArgumentException e) {
+ return s;
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnPartialApply.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnPartialApply.java
new file mode 100644
index 00000000000..b810ebb87fd
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnPartialApply.java
@@ -0,0 +1,188 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.AbstractExpression;
+import org.exist.xquery.AnalyzeContextInfo;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionCall;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.UserDefinedFunction;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.util.ExpressionDumper;
+import org.exist.xquery.value.AtomicValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceIterator;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+import org.exist.xquery.functions.map.AbstractMapType;
+
+import javax.xml.XMLConstants;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * Implements XQuery 4.0 fn:partial-apply.
+ *
+ * fn:partial-apply($function, $arguments) binds selected arguments to a function,
+ * returning a partially applied function with reduced arity.
+ */
+public class FnPartialApply extends BasicFunction {
+
+ public static final FunctionSignature FN_PARTIAL_APPLY = new FunctionSignature(
+ new QName("partial-apply", Function.BUILTIN_FUNCTION_NS),
+ "Returns a partially applied function with specified arguments bound.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("function", Type.FUNCTION, Cardinality.EXACTLY_ONE, "The function to partially apply"),
+ new FunctionParameterSequenceType("arguments", Type.MAP_ITEM, Cardinality.EXACTLY_ONE, "Map from argument positions (xs:positiveInteger) to values")
+ },
+ new FunctionReturnSequenceType(Type.FUNCTION, Cardinality.EXACTLY_ONE, "the partially applied function"));
+
+ private AnalyzeContextInfo cachedContextInfo;
+
+ public FnPartialApply(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ cachedContextInfo = new AnalyzeContextInfo(contextInfo);
+ super.analyze(cachedContextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final FunctionReference ref = (FunctionReference) args[0].itemAt(0);
+ final AbstractMapType argMap = (AbstractMapType) args[1].itemAt(0);
+
+ ref.analyze(cachedContextInfo);
+ final int originalArity = ref.getSignature().getArgumentCount();
+
+ // Extract bound arguments from the map (1-based positions)
+ final Map boundArgs = new TreeMap<>();
+ final Sequence keys = argMap.keys();
+ for (final SequenceIterator ki = keys.iterate(); ki.hasNext(); ) {
+ final AtomicValue key = ki.nextItem().atomize();
+ final int pos = (int) ((IntegerValue) key).getLong();
+ if (pos >= 1 && pos <= originalArity) {
+ boundArgs.put(pos, argMap.get(key));
+ }
+ }
+
+ if (boundArgs.isEmpty()) {
+ return ref;
+ }
+
+ // Build parameter list for the new function (unbound positions only)
+ final int newArity = originalArity - boundArgs.size();
+ final SequenceType[] newParamTypes = new SequenceType[newArity];
+ final List variables = new ArrayList<>();
+
+ int paramIdx = 0;
+ for (int pos = 1; pos <= originalArity; pos++) {
+ if (!boundArgs.containsKey(pos)) {
+ final QName varName = new QName("pa" + paramIdx, XMLConstants.NULL_NS_URI);
+ variables.add(varName);
+ newParamTypes[paramIdx] = new FunctionParameterSequenceType(
+ "pa" + paramIdx, Type.ITEM, Cardinality.ZERO_OR_MORE, "unbound parameter");
+ paramIdx++;
+ }
+ }
+
+ final QName name = new QName("partial" + hashCode(), XMLConstants.NULL_NS_URI);
+ final FunctionSignature newSignature = new FunctionSignature(name, newParamTypes,
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "result"));
+ final UserDefinedFunction func = new UserDefinedFunction(context, newSignature);
+
+ for (final QName varName : variables) {
+ func.addVariable(varName);
+ }
+
+ // Body expression: resolves variables and bound args, calls the original function
+ func.setFunctionBody(new PartialCallExpression(context, ref, boundArgs, originalArity, variables));
+
+ final FunctionCall newCall = new FunctionCall(context, func);
+ newCall.setLocation(getLine(), getColumn());
+ return new FunctionReference(this, newCall);
+ }
+
+ /**
+ * Expression that invokes the original function with bound + unbound args assembled.
+ */
+ private static class PartialCallExpression extends AbstractExpression {
+ private final FunctionReference originalRef;
+ private final Map boundArgs;
+ private final int originalArity;
+ private final List unboundVars;
+
+ PartialCallExpression(final XQueryContext context, final FunctionReference ref,
+ final Map boundArgs, final int originalArity,
+ final List unboundVars) {
+ super(context);
+ this.originalRef = ref;
+ this.boundArgs = boundArgs;
+ this.originalArity = originalArity;
+ this.unboundVars = unboundVars;
+ }
+
+ @Override
+ public Sequence eval(final Sequence contextSequence, final Item contextItem) throws XPathException {
+ final Sequence[] fullArgs = new Sequence[originalArity];
+ int unboundIdx = 0;
+ for (int pos = 1; pos <= originalArity; pos++) {
+ if (boundArgs.containsKey(pos)) {
+ fullArgs[pos - 1] = boundArgs.get(pos);
+ } else {
+ fullArgs[pos - 1] = context.resolveVariable(unboundVars.get(unboundIdx)).getValue();
+ unboundIdx++;
+ }
+ }
+ return originalRef.evalFunction(null, null, fullArgs);
+ }
+
+ @Override
+ public int returnsType() {
+ return Type.ITEM;
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ }
+
+ @Override
+ public void dump(final ExpressionDumper dumper) {
+ dumper.display("partial-apply(...)");
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnPartition.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnPartition.java
new file mode 100644
index 00000000000..872edef1be4
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnPartition.java
@@ -0,0 +1,140 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.AnalyzeContextInfo;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceIterator;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.exist.xquery.functions.array.ArrayType;
+
+/**
+ * Implements fn:partition (XQuery 4.0).
+ *
+ * Splits a sequence into partitions based on a predicate function.
+ * The predicate receives (current-partition, next-item, position) and
+ * returns true to start a new partition.
+ */
+public class FnPartition extends BasicFunction {
+
+ public static final FunctionSignature FN_PARTITION = new FunctionSignature(
+ new QName("partition", Function.BUILTIN_FUNCTION_NS),
+ "Splits a sequence into partitions based on a predicate function.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("split-when", Type.FUNCTION, Cardinality.EXACTLY_ONE,
+ "Predicate: fn(current-partition, next-item, position) as xs:boolean?")
+ },
+ new FunctionReturnSequenceType(Type.ARRAY_ITEM, Cardinality.ZERO_OR_MORE, "sequence of arrays, each containing a partition"));
+
+ private AnalyzeContextInfo cachedContextInfo;
+
+ public FnPartition(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ cachedContextInfo = new AnalyzeContextInfo(contextInfo);
+ super.analyze(cachedContextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence input = args[0];
+ if (input.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ try (final FunctionReference splitWhen = (FunctionReference) args[1].itemAt(0)) {
+ splitWhen.analyze(cachedContextInfo);
+ final int arity = splitWhen.getSignature().getArgumentCount();
+
+ final List partitions = new ArrayList<>();
+ ValueSequence currentPartition = new ValueSequence();
+
+ int pos = 1;
+ for (final SequenceIterator i = input.iterate(); i.hasNext(); pos++) {
+ final Item item = i.nextItem();
+
+ if (pos == 1) {
+ // First item always starts the first partition
+ currentPartition.add(item);
+ } else {
+ // Call predicate to decide if we should split
+ final Sequence splitResult;
+ if (arity == 1) {
+ splitResult = splitWhen.evalFunction(null, null,
+ new Sequence[]{currentPartition});
+ } else if (arity == 2) {
+ splitResult = splitWhen.evalFunction(null, null,
+ new Sequence[]{currentPartition, item.toSequence()});
+ } else {
+ splitResult = splitWhen.evalFunction(null, null,
+ new Sequence[]{currentPartition, item.toSequence(), new IntegerValue(this, pos)});
+ }
+
+ final boolean split = !splitResult.isEmpty() && splitResult.effectiveBooleanValue();
+ if (split) {
+ partitions.add(currentPartition);
+ currentPartition = new ValueSequence();
+ }
+ currentPartition.add(item);
+ }
+ }
+
+ // Add the last partition
+ if (!currentPartition.isEmpty()) {
+ partitions.add(currentPartition);
+ }
+
+ // Convert to sequence of arrays — each partition item becomes an array member
+ final ValueSequence result = new ValueSequence(partitions.size());
+ for (final ValueSequence partition : partitions) {
+ final List members = new ArrayList<>(partition.getItemCount());
+ for (final SequenceIterator pi = partition.iterate(); pi.hasNext(); ) {
+ members.add(pi.nextItem().toSequence());
+ }
+ result.add(new ArrayType(this, context, members));
+ }
+ return result;
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnReplicate.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnReplicate.java
new file mode 100644
index 00000000000..28053ad84b5
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnReplicate.java
@@ -0,0 +1,77 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+/**
+ * Implements fn:replicate (XQuery 4.0).
+ *
+ * Produces multiple copies of a sequence.
+ */
+public class FnReplicate extends BasicFunction {
+
+ public static final FunctionSignature FN_REPLICATE = new FunctionSignature(
+ new QName("replicate", Function.BUILTIN_FUNCTION_NS),
+ "Produces multiple copies of a sequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The sequence to replicate"),
+ new FunctionParameterSequenceType("count", Type.INTEGER, Cardinality.EXACTLY_ONE, "The number of copies")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "the replicated sequence"));
+
+ public FnReplicate(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence input = args[0];
+ final long count = ((IntegerValue) args[1].itemAt(0)).getLong();
+ if (count < 0) {
+ throw new XPathException(this, ErrorCodes.XPTY0004, "The count argument to fn:replicate must be non-negative, got: " + count);
+ }
+ if (count == 0 || input.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ final int inputSize = input.getItemCount();
+ final ValueSequence result = new ValueSequence((int) Math.min(count * inputSize, Integer.MAX_VALUE));
+ for (long c = 0; c < count; c++) {
+ result.addAll(input);
+ }
+ return result;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSchemaType.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSchemaType.java
new file mode 100644
index 00000000000..fe53f8a66d8
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSchemaType.java
@@ -0,0 +1,72 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.value.*;
+
+/**
+ * fn:schema-type($name as xs:QName) as map(*)
+ *
+ * Returns a schema-type-record for the named type, with the same structure
+ * as fn:atomic-type-annotation: name, is-simple, variety, base-type(),
+ * primitive-type(), matches(), constructor().
+ *
+ * Delegates to {@link FnTypeAnnotation} for the full record chain.
+ */
+public class FnSchemaType extends BasicFunction {
+
+ public static final FunctionSignature FN_SCHEMA_TYPE = new FunctionSignature(
+ new QName("schema-type", Function.BUILTIN_FUNCTION_NS),
+ "Returns a map describing the named schema type.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("name", Type.QNAME,
+ Cardinality.EXACTLY_ONE, "The QName of the type")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.EXACTLY_ONE,
+ "A schema-type-record describing the type"));
+
+ public FnSchemaType(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final QNameValue qnameVal = (QNameValue) args[0].itemAt(0);
+ final QName typeName = qnameVal.getQName();
+
+ // Look up the type in eXist's type system
+ final int typeCode = Type.getType(typeName);
+ if (typeCode == Type.ITEM && !"item".equals(typeName.getLocalPart())) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Unknown schema type: " + typeName);
+ }
+
+ // Delegate to FnTypeAnnotation for the full schema-type-record
+ // with base-type, primitive-type, matches, constructor
+ final boolean isSimple = typeCode != Type.ANY_TYPE && typeCode != Type.UNTYPED;
+ final FnTypeAnnotation helper = new FnTypeAnnotation(context,
+ FnTypeAnnotation.FN_ATOMIC_TYPE_ANNOTATION);
+ return helper.buildSchemaTypeRecord(typeCode, isSimple);
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSeconds.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSeconds.java
new file mode 100644
index 00000000000..3f953b98757
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSeconds.java
@@ -0,0 +1,64 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.value.*;
+
+/**
+ * Implements XQuery 4.0 fn:seconds.
+ *
+ * Converts a number of seconds (as xs:double) to an xs:dayTimeDuration.
+ */
+public class FnSeconds extends BasicFunction {
+
+ public static final FunctionSignature FN_SECONDS = new FunctionSignature(
+ new QName("seconds", Function.BUILTIN_FUNCTION_NS),
+ "Returns a dayTimeDuration representing the given number of seconds.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("value", Type.DOUBLE, Cardinality.ZERO_OR_ONE, "The number of seconds")
+ },
+ new FunctionReturnSequenceType(Type.DAY_TIME_DURATION, Cardinality.ZERO_OR_ONE, "The duration"));
+
+ public FnSeconds(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final double seconds = ((DoubleValue) args[0].itemAt(0).convertTo(Type.DOUBLE)).getDouble();
+
+ if (Double.isNaN(seconds) || Double.isInfinite(seconds)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Cannot create duration from " + (Double.isNaN(seconds) ? "NaN" : "Infinity"));
+ }
+
+ // Convert seconds to dayTimeDuration (constructor takes milliseconds)
+ final long millis = Math.round(seconds * 1000.0);
+ return new DayTimeDurationValue(this, millis);
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSiblings.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSiblings.java
new file mode 100644
index 00000000000..7fa291387ee
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSiblings.java
@@ -0,0 +1,111 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+/**
+ * Implements XQuery 4.0 fn:siblings.
+ *
+ * Returns the node together with its siblings in document order.
+ * If the node has no parent (or is an attribute/namespace), returns just the node itself.
+ */
+public class FnSiblings extends BasicFunction {
+
+ public static final FunctionSignature[] FN_SIBLINGS = {
+ new FunctionSignature(
+ new QName("siblings", Function.BUILTIN_FUNCTION_NS),
+ "Returns the supplied node together with its siblings in document order.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("node", Type.NODE, Cardinality.ZERO_OR_ONE, "The node whose siblings to return")
+ },
+ new FunctionReturnSequenceType(Type.NODE, Cardinality.ZERO_OR_MORE, "the node and its siblings in document order")),
+ new FunctionSignature(
+ new QName("siblings", Function.BUILTIN_FUNCTION_NS),
+ "Returns the context node together with its siblings in document order.",
+ new SequenceType[0],
+ new FunctionReturnSequenceType(Type.NODE, Cardinality.ZERO_OR_MORE, "the context node and its siblings in document order"))
+ };
+
+ public FnSiblings(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence input;
+ if (args.length == 0) {
+ // 0-arity: use context item
+ if (contextSequence == null || contextSequence.isEmpty()) {
+ throw new XPathException(this, ErrorCodes.XPDY0002,
+ "fn:siblings() called with no context item");
+ }
+ input = contextSequence;
+ } else {
+ input = args[0];
+ }
+
+ if (input.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final Item nodeItem = input.itemAt(0);
+ final int nodeType = nodeItem.getType();
+
+ // Attribute and namespace nodes: return just the node itself
+ if (nodeType == Type.ATTRIBUTE || nodeType == Type.NAMESPACE) {
+ return nodeItem.toSequence();
+ }
+
+ final Node node = (Node) nodeItem;
+ final Node parent = node.getParentNode();
+
+ // No parent: return just the node
+ if (parent == null) {
+ return nodeItem.toSequence();
+ }
+
+ // Return all children of the parent (which includes all siblings + the node itself)
+ final NodeList children = parent.getChildNodes();
+ final ValueSequence result = new ValueSequence(children.getLength());
+ for (int i = 0; i < children.getLength(); i++) {
+ result.add((Item) children.item(i));
+ }
+ return result;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSlice.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSlice.java
new file mode 100644
index 00000000000..40d27dac51d
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSlice.java
@@ -0,0 +1,149 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+/**
+ * Implements fn:slice (XQuery 4.0).
+ *
+ * Returns selected items from a sequence based on position, with support for
+ * negative indexing and step values (Python-style slicing with 1-based indexing).
+ */
+public class FnSlice extends BasicFunction {
+
+ private static final String DESCRIPTION = "Returns selected items from the input sequence based on their position.";
+
+ public static final FunctionSignature[] FN_SLICE = {
+ new FunctionSignature(
+ new QName("slice", Function.BUILTIN_FUNCTION_NS),
+ DESCRIPTION,
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "the selected items")),
+ new FunctionSignature(
+ new QName("slice", Function.BUILTIN_FUNCTION_NS),
+ DESCRIPTION,
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("start", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The start position")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "the selected items")),
+ new FunctionSignature(
+ new QName("slice", Function.BUILTIN_FUNCTION_NS),
+ DESCRIPTION,
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("start", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The start position"),
+ new FunctionParameterSequenceType("end", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The end position")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "the selected items")),
+ new FunctionSignature(
+ new QName("slice", Function.BUILTIN_FUNCTION_NS),
+ DESCRIPTION,
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("start", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The start position"),
+ new FunctionParameterSequenceType("end", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The end position"),
+ new FunctionParameterSequenceType("step", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The step value")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "the selected items"))
+ };
+
+ public FnSlice(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence input = args[0];
+ if (input.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final int count = input.getItemCount();
+
+ // Resolve start
+ int s;
+ if (args.length < 2 || args[1].isEmpty() || ((IntegerValue) args[1].itemAt(0)).getLong() == 0) {
+ s = 1;
+ } else {
+ final long sv = ((IntegerValue) args[1].itemAt(0)).getLong();
+ s = (int) (sv < 0 ? count + sv + 1 : sv);
+ }
+
+ // Resolve end
+ int e;
+ if (args.length < 3 || args[2].isEmpty() || ((IntegerValue) args[2].itemAt(0)).getLong() == 0) {
+ e = count;
+ } else {
+ final long ev = ((IntegerValue) args[2].itemAt(0)).getLong();
+ e = (int) (ev < 0 ? count + ev + 1 : ev);
+ }
+
+ // Resolve step
+ int step;
+ if (args.length < 4 || args[3].isEmpty() || ((IntegerValue) args[3].itemAt(0)).getLong() == 0) {
+ step = (e >= s) ? 1 : -1;
+ } else {
+ step = (int) ((IntegerValue) args[3].itemAt(0)).getLong();
+ }
+
+ // Handle negative step: reverse input and recurse with negated positions
+ if (step < 0) {
+ final ValueSequence reversed = new ValueSequence(count);
+ for (int i = count - 1; i >= 0; i--) {
+ reversed.add(input.itemAt(i));
+ }
+ // slice(reverse($input), -$s, -$e, -$step)
+ final Sequence[] newArgs = new Sequence[4];
+ newArgs[0] = reversed;
+ newArgs[1] = new IntegerValue(this, -s);
+ newArgs[2] = new IntegerValue(this, -e);
+ newArgs[3] = new IntegerValue(this, -step);
+ return eval(newArgs, contextSequence);
+ }
+
+ // Positive step: select items where position >= S, position <= E, and (position - S) mod step == 0
+ final ValueSequence result = new ValueSequence();
+ for (int pos = s; pos <= e && pos <= count; pos += step) {
+ if (pos >= 1) {
+ result.add(input.itemAt(pos - 1));
+ }
+ }
+ return result;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSortBy.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSortBy.java
new file mode 100644
index 00000000000..5c45a9b6879
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSortBy.java
@@ -0,0 +1,267 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import com.ibm.icu.text.Collator;
+import org.exist.dom.QName;
+import org.exist.xquery.AnalyzeContextInfo;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.AtomicValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceIterator;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+
+import org.exist.xquery.functions.array.ArrayType;
+import org.exist.xquery.functions.map.AbstractMapType;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implements XQuery 4.0 fn:sort-by.
+ *
+ * fn:sort-by($input, $keys) sorts a sequence based on sort key specifications
+ * provided as records (maps) with optional key, collation, and order fields.
+ */
+public class FnSortBy extends BasicFunction {
+
+ public static final FunctionSignature FN_SORT_BY = new FunctionSignature(
+ new QName("sort-by", Function.BUILTIN_FUNCTION_NS),
+ "Sorts a sequence based on sort key specifications.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The sequence to sort"),
+ new FunctionParameterSequenceType("keys", Type.MAP_ITEM, Cardinality.ZERO_OR_MORE, "Sort key records with optional key, collation, and order fields")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "the sorted sequence"));
+
+ private AnalyzeContextInfo cachedContextInfo;
+
+ public FnSortBy(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ cachedContextInfo = new AnalyzeContextInfo(contextInfo);
+ super.analyze(cachedContextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence input = args[0];
+ if (input.getItemCount() <= 1) {
+ return input;
+ }
+
+ final Sequence keys = args[1];
+
+ // Parse sort key specifications
+ final List sortKeys = new ArrayList<>();
+ if (keys.isEmpty()) {
+ // Default: sort by fn:data#1 ascending
+ final SortKey defaultKey = new SortKey();
+ defaultKey.collator = context.getDefaultCollator();
+ sortKeys.add(defaultKey);
+ } else {
+ for (final SequenceIterator ki = keys.iterate(); ki.hasNext(); ) {
+ final AbstractMapType keyMap = (AbstractMapType) ki.nextItem();
+ sortKeys.add(parseSortKey(keyMap));
+ }
+ }
+
+ // Collect items
+ final List- items = new ArrayList<>(input.getItemCount());
+ for (final SequenceIterator i = input.iterate(); i.hasNext(); ) {
+ items.add(i.nextItem());
+ }
+
+ // Pre-compute sort keys for each item
+ final Sequence[][] keyValues = new Sequence[items.size()][sortKeys.size()];
+ for (int idx = 0; idx < items.size(); idx++) {
+ for (int k = 0; k < sortKeys.size(); k++) {
+ final SortKey sk = sortKeys.get(k);
+ if (sk.keyFunction != null) {
+ keyValues[idx][k] = sk.keyFunction.evalFunction(null, null,
+ new Sequence[]{items.get(idx).toSequence()});
+ } else {
+ final Item item = items.get(idx);
+ if (item instanceof ArrayType) {
+ // Arrays use composite sort key: flatten members to atomic values
+ final ArrayType arr = (ArrayType) item;
+ final ValueSequence atomized = new ValueSequence(arr.getSize());
+ for (int m = 0; m < arr.getSize(); m++) {
+ final Sequence member = arr.get(m);
+ for (final SequenceIterator mi = member.iterate(); mi.hasNext(); ) {
+ atomized.add(mi.nextItem().atomize());
+ }
+ }
+ keyValues[idx][k] = atomized;
+ } else {
+ keyValues[idx][k] = item.atomize().toSequence();
+ }
+ }
+ }
+ }
+
+ // Build index array for stable sort
+ final Integer[] indices = new Integer[items.size()];
+ for (int i = 0; i < indices.length; i++) {
+ indices[i] = i;
+ }
+
+ try {
+ java.util.Arrays.sort(indices, (a, b) -> {
+ try {
+ for (int k = 0; k < sortKeys.size(); k++) {
+ final SortKey sk = sortKeys.get(k);
+ final Sequence va = keyValues[a][k];
+ final Sequence vb = keyValues[b][k];
+ final int cmp = compareKeys(va, vb, sk.collator);
+ if (cmp != 0) {
+ return sk.descending ? -cmp : cmp;
+ }
+ }
+ return 0;
+ } catch (final XPathException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ } catch (final RuntimeException e) {
+ if (e.getCause() instanceof XPathException) {
+ throw (XPathException) e.getCause();
+ }
+ throw e;
+ }
+
+ final ValueSequence result = new ValueSequence(items.size());
+ for (final int idx : indices) {
+ result.add(items.get(idx));
+ }
+ return result;
+ }
+
+ private int compareKeys(final Sequence a, final Sequence b, final Collator collator) throws XPathException {
+ final boolean emptyA = a.isEmpty();
+ final boolean emptyB = b.isEmpty();
+ if (emptyA && emptyB) {
+ return 0;
+ }
+ if (emptyA) {
+ return -1; // empty precedes non-empty
+ }
+ if (emptyB) {
+ return 1;
+ }
+ // Lexicographic comparison for composite sort keys
+ final int len = Math.min(a.getItemCount(), b.getItemCount());
+ for (int i = 0; i < len; i++) {
+ final AtomicValue va = a.itemAt(i).atomize();
+ final AtomicValue vb = b.itemAt(i).atomize();
+ // Type check: sort keys must be mutually comparable
+ checkComparable(va, vb);
+ final int cmp = FunCompare.compare(va, vb, collator);
+ if (cmp != 0) {
+ return cmp;
+ }
+ }
+ // Shorter sequence is less
+ return Integer.compare(a.getItemCount(), b.getItemCount());
+ }
+
+ /**
+ * Check that two sort key values are of mutually comparable types.
+ * Per XQ4 spec, it is XPTY0004 if they are not.
+ */
+ private void checkComparable(final AtomicValue va, final AtomicValue vb) throws XPathException {
+ final int t1 = va.getType();
+ final int t2 = vb.getType();
+ // Same base type family is always comparable
+ if (t1 == t2) {
+ return;
+ }
+ // String-like types are mutually comparable
+ if (isStringLike(t1) && isStringLike(t2)) {
+ return;
+ }
+ // Numeric types are mutually comparable
+ if (va instanceof org.exist.xquery.value.NumericValue && vb instanceof org.exist.xquery.value.NumericValue) {
+ return;
+ }
+ // Same base type hierarchy is comparable
+ if (Type.subTypeOf(t1, t2) || Type.subTypeOf(t2, t1)) {
+ return;
+ }
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Sort key values are not mutually comparable: " + Type.getTypeName(t1) + " and " + Type.getTypeName(t2));
+ }
+
+ private static boolean isStringLike(final int type) {
+ return Type.subTypeOf(type, Type.STRING)
+ || type == Type.UNTYPED_ATOMIC
+ || Type.subTypeOf(type, Type.ANY_URI);
+ }
+
+ private SortKey parseSortKey(final AbstractMapType map) throws XPathException {
+ final SortKey sk = new SortKey();
+
+ // key field: function to extract sort key
+ final Sequence keySeq = map.get(new org.exist.xquery.value.StringValue(this, "key"));
+ if (keySeq != null && !keySeq.isEmpty()) {
+ sk.keyFunction = (FunctionReference) keySeq.itemAt(0);
+ sk.keyFunction.analyze(cachedContextInfo);
+ }
+
+ // collation field
+ final Sequence collSeq = map.get(new org.exist.xquery.value.StringValue(this, "collation"));
+ if (collSeq != null && !collSeq.isEmpty()) {
+ sk.collator = context.getCollator(collSeq.getStringValue(), ErrorCodes.FOCH0002);
+ } else {
+ sk.collator = context.getDefaultCollator();
+ }
+
+ // order field: "ascending" (default) or "descending"
+ final Sequence orderSeq = map.get(new org.exist.xquery.value.StringValue(this, "order"));
+ if (orderSeq != null && !orderSeq.isEmpty()) {
+ sk.descending = "descending".equals(orderSeq.getStringValue());
+ }
+
+ return sk;
+ }
+
+ private static class SortKey {
+ FunctionReference keyFunction;
+ Collator collator;
+ boolean descending;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSubsequenceMatching.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSubsequenceMatching.java
new file mode 100644
index 00000000000..2ffe00b4a61
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSubsequenceMatching.java
@@ -0,0 +1,208 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.AnalyzeContextInfo;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.ErrorCodes;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.BooleanValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.Type;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implements XQuery 4.0 fn:contains-subsequence, fn:starts-with-subsequence,
+ * fn:ends-with-subsequence.
+ */
+public class FnSubsequenceMatching extends BasicFunction {
+
+ public static final FunctionSignature[] FN_CONTAINS_SUBSEQUENCE = {
+ new FunctionSignature(
+ new QName("contains-subsequence", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if the input sequence contains a contiguous subsequence matching the supplied subsequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("subsequence", Type.ITEM, Cardinality.ZERO_OR_MORE, "The subsequence to find")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if input contains the subsequence")),
+ new FunctionSignature(
+ new QName("contains-subsequence", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if the input sequence contains a contiguous subsequence matching the supplied subsequence, using a custom comparison function.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("subsequence", Type.ITEM, Cardinality.ZERO_OR_MORE, "The subsequence to find"),
+ new FunctionParameterSequenceType("compare", Type.FUNCTION, Cardinality.ZERO_OR_ONE, "The comparison function")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if input contains the subsequence"))
+ };
+
+ public static final FunctionSignature[] FN_STARTS_WITH_SUBSEQUENCE = {
+ new FunctionSignature(
+ new QName("starts-with-subsequence", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if the input sequence starts with the supplied subsequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("subsequence", Type.ITEM, Cardinality.ZERO_OR_MORE, "The subsequence to match at start")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if input starts with the subsequence")),
+ new FunctionSignature(
+ new QName("starts-with-subsequence", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if the input sequence starts with the supplied subsequence, using a custom comparison function.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("subsequence", Type.ITEM, Cardinality.ZERO_OR_MORE, "The subsequence to match at start"),
+ new FunctionParameterSequenceType("compare", Type.FUNCTION, Cardinality.ZERO_OR_ONE, "The comparison function")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if input starts with the subsequence"))
+ };
+
+ public static final FunctionSignature[] FN_ENDS_WITH_SUBSEQUENCE = {
+ new FunctionSignature(
+ new QName("ends-with-subsequence", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if the input sequence ends with the supplied subsequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("subsequence", Type.ITEM, Cardinality.ZERO_OR_MORE, "The subsequence to match at end")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if input ends with the subsequence")),
+ new FunctionSignature(
+ new QName("ends-with-subsequence", Function.BUILTIN_FUNCTION_NS),
+ "Returns true if the input sequence ends with the supplied subsequence, using a custom comparison function.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("subsequence", Type.ITEM, Cardinality.ZERO_OR_MORE, "The subsequence to match at end"),
+ new FunctionParameterSequenceType("compare", Type.FUNCTION, Cardinality.ZERO_OR_ONE, "The comparison function")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if input ends with the subsequence"))
+ };
+
+ private AnalyzeContextInfo cachedContextInfo;
+
+ public FnSubsequenceMatching(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ cachedContextInfo = new AnalyzeContextInfo(contextInfo);
+ super.analyze(cachedContextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence input = args[0];
+ final Sequence subsequence = args[1];
+
+ // Empty subsequence always matches
+ if (subsequence.isEmpty()) {
+ return BooleanValue.TRUE;
+ }
+
+ final int inputLen = input.getItemCount();
+ final int subLen = subsequence.getItemCount();
+
+ // Input shorter than subsequence: can't match
+ if (inputLen < subLen) {
+ return BooleanValue.FALSE;
+ }
+
+ // Get optional compare function
+ FunctionReference compareRef = null;
+ if (args.length > 2 && !args[2].isEmpty()) {
+ compareRef = (FunctionReference) args[2].itemAt(0);
+ compareRef.analyze(cachedContextInfo);
+ // Validate arity: comparison function must accept exactly 2 arguments
+ final int arity = compareRef.getSignature().getArgumentCount();
+ if (arity != 2) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Comparison function must accept exactly 2 arguments, but has arity " + arity);
+ }
+ }
+
+ try {
+ // Materialize subsequence for random access
+ final List
- subItems = new ArrayList<>(subLen);
+ for (int i = 0; i < subLen; i++) {
+ subItems.add(subsequence.itemAt(i));
+ }
+
+ if (isCalledAs("starts-with-subsequence")) {
+ return BooleanValue.valueOf(matchesAt(input, subItems, 0, compareRef));
+ } else if (isCalledAs("ends-with-subsequence")) {
+ return BooleanValue.valueOf(matchesAt(input, subItems, inputLen - subLen, compareRef));
+ } else {
+ // contains-subsequence: try all starting positions
+ for (int start = 0; start <= inputLen - subLen; start++) {
+ if (matchesAt(input, subItems, start, compareRef)) {
+ return BooleanValue.TRUE;
+ }
+ }
+ return BooleanValue.FALSE;
+ }
+ } finally {
+ if (compareRef != null) {
+ compareRef.close();
+ }
+ }
+ }
+
+ private boolean matchesAt(final Sequence input, final List
- subItems, final int start,
+ final FunctionReference compareRef) throws XPathException {
+ for (int i = 0; i < subItems.size(); i++) {
+ final Item inputItem = input.itemAt(start + i);
+ final Item subItem = subItems.get(i);
+ if (compareRef != null) {
+ final Sequence result = compareRef.evalFunction(null, null,
+ new Sequence[]{inputItem.toSequence(), subItem.toSequence()});
+ // XQ4: comparison function must return xs:boolean
+ if (!result.isEmpty() && result.itemAt(0).getType() != Type.BOOLEAN) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Comparison function must return xs:boolean, but returned "
+ + Type.getTypeName(result.itemAt(0).getType()));
+ }
+ if (result.isEmpty() || !result.effectiveBooleanValue()) {
+ return false;
+ }
+ } else {
+ // Default: deep-equal semantics
+ if (!FunDeepEqual.deepEquals(inputItem, subItem, context.getDefaultCollator())) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSubsequenceWhere.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSubsequenceWhere.java
new file mode 100644
index 00000000000..03a3c20c025
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnSubsequenceWhere.java
@@ -0,0 +1,181 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.value.*;
+
+/**
+ * Implements XQuery 4.0 fn:subsequence-where.
+ *
+ * Returns items from $input starting from the first item where $from returns true,
+ * up to and including the first subsequent item where $to returns true.
+ *
+ * Also supports fn:subsequence-before and fn:subsequence-after as derived
+ * convenience functions.
+ */
+public class FnSubsequenceWhere extends BasicFunction {
+
+ public static final FunctionSignature[] FN_SUBSEQUENCE_WHERE = {
+ new FunctionSignature(
+ new QName("subsequence-where", Function.BUILTIN_FUNCTION_NS),
+ "Returns a contiguous subsequence defined by from/to predicates.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("from", Type.FUNCTION, Cardinality.ZERO_OR_ONE, "Predicate for start position"),
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "The selected subsequence")),
+ new FunctionSignature(
+ new QName("subsequence-where", Function.BUILTIN_FUNCTION_NS),
+ "Returns a contiguous subsequence defined by from/to predicates.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("from", Type.FUNCTION, Cardinality.ZERO_OR_ONE, "Predicate for start position"),
+ new FunctionParameterSequenceType("to", Type.FUNCTION, Cardinality.ZERO_OR_ONE, "Predicate for end position"),
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "The selected subsequence")),
+ };
+
+ private AnalyzeContextInfo cachedContextInfo;
+
+ public FnSubsequenceWhere(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ cachedContextInfo = new AnalyzeContextInfo(contextInfo);
+ super.analyze(cachedContextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence input = args[0];
+ if (input.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ // $from predicate (default: match first item)
+ FunctionReference fromRef = null;
+ if (args.length >= 2 && !args[1].isEmpty()) {
+ fromRef = (FunctionReference) args[1].itemAt(0);
+ fromRef.analyze(cachedContextInfo);
+ }
+
+ // $to predicate (default: no end match, include all remaining)
+ FunctionReference toRef = null;
+ if (args.length >= 3 && !args[2].isEmpty()) {
+ toRef = (FunctionReference) args[2].itemAt(0);
+ toRef.analyze(cachedContextInfo);
+ }
+
+ try {
+ final int len = input.getItemCount();
+
+ // Find start index: first item where $from returns true
+ int startIdx = -1;
+ if (fromRef == null) {
+ startIdx = 0; // default: start from first item
+ } else {
+ for (int i = 0; i < len; i++) {
+ if (callPredicate(fromRef, input.itemAt(i), i + 1)) {
+ startIdx = i;
+ break;
+ }
+ }
+ }
+
+ if (startIdx < 0) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ // Find end index: first item at or after start where $to returns true
+ int endIdx = len - 1; // default: include all to end
+ if (toRef != null) {
+ endIdx = -1;
+ for (int i = startIdx; i < len; i++) {
+ if (callPredicate(toRef, input.itemAt(i), i + 1)) {
+ endIdx = i;
+ break;
+ }
+ }
+ if (endIdx < 0) {
+ // No match for $to — per spec, include all remaining
+ endIdx = len - 1;
+ }
+ }
+
+ // Build result
+ final ValueSequence result = new ValueSequence(endIdx - startIdx + 1);
+ for (int i = startIdx; i <= endIdx; i++) {
+ result.add(input.itemAt(i));
+ }
+ return result;
+
+ } finally {
+ if (fromRef != null) {
+ fromRef.close();
+ }
+ if (toRef != null) {
+ toRef.close();
+ }
+ }
+ }
+
+ private boolean callPredicate(final FunctionReference ref, final Item item, final int position) throws XPathException {
+ final int arity = ref.getSignature().getArgumentCount();
+ final Sequence result;
+ if (arity == 0) {
+ result = ref.evalFunction(null, null, new Sequence[]{});
+ } else if (arity == 1) {
+ result = ref.evalFunction(null, null, new Sequence[]{item.toSequence()});
+ } else {
+ result = ref.evalFunction(null, null, new Sequence[]{item.toSequence(), new IntegerValue(this, position)});
+ }
+
+ if (result.isEmpty()) {
+ return false;
+ }
+
+ // Must be xs:boolean — EBV of other types is not allowed
+ final Item resultItem = result.itemAt(0);
+ if (resultItem.getType() == Type.BOOLEAN) {
+ return ((BooleanValue) resultItem).getValue();
+ }
+
+ // Check if it's a map (maps can be used as predicates)
+ if (resultItem instanceof org.exist.xquery.functions.map.AbstractMapType) {
+ // Map used as predicate: look up the item in the map
+ final org.exist.xquery.functions.map.AbstractMapType map =
+ (org.exist.xquery.functions.map.AbstractMapType) resultItem;
+ final Sequence mapResult = map.get((AtomicValue) item.atomize());
+ if (mapResult == null || mapResult.isEmpty()) {
+ return false;
+ }
+ return mapResult.effectiveBooleanValue();
+ }
+
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Predicate in subsequence-where must return xs:boolean, got " + Type.getTypeName(resultItem.getType()));
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnTransitiveClosure.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnTransitiveClosure.java
new file mode 100644
index 00000000000..1970470e7c1
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnTransitiveClosure.java
@@ -0,0 +1,143 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.*;
+import org.exist.xquery.value.*;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * Implements XQuery 4.0 fn:transitive-closure.
+ *
+ * Applies a step function repeatedly starting from an initial set of items,
+ * accumulating results until no new items are produced. Handles cycles
+ * through deduplication.
+ */
+public class FnTransitiveClosure extends BasicFunction {
+
+ private AnalyzeContextInfo cachedContextInfo;
+
+ public static final FunctionSignature FN_TRANSITIVE_CLOSURE = new FunctionSignature(
+ new QName("transitive-closure", Function.BUILTIN_FUNCTION_NS),
+ "Returns the transitive closure of applying $step to $input. " +
+ "The step function is applied repeatedly until no new items are produced.",
+ new SequenceType[]{
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The initial input sequence"),
+ new FunctionParameterSequenceType("step", Type.FUNCTION, Cardinality.EXACTLY_ONE, "The step function")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "The transitive closure"));
+
+ public FnTransitiveClosure(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ cachedContextInfo = new AnalyzeContextInfo(contextInfo);
+ super.analyze(cachedContextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence input = args[0];
+ if (input.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final FunctionReference stepRef = (FunctionReference) args[1].itemAt(0);
+ stepRef.analyze(cachedContextInfo);
+
+ try {
+ // Apply step to initial input to get first results
+ Sequence current = applyStep(stepRef, input);
+ if (current.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ // Track all seen items using identity (for nodes) or value (for atomics)
+ final ValueSequence allResults = new ValueSequence();
+ final Set
seen = new LinkedHashSet<>();
+
+ // Add initial step results
+ addNewItems(current, allResults, seen);
+
+ // Iterate until no new items are found
+ while (true) {
+ final Sequence nextStep = applyStep(stepRef, current);
+ if (nextStep.isEmpty()) {
+ break;
+ }
+
+ final ValueSequence newItems = new ValueSequence();
+ for (final SequenceIterator i = nextStep.iterate(); i.hasNext(); ) {
+ final Item item = i.nextItem();
+ final Object key = itemKey(item);
+ if (seen.add(key)) {
+ newItems.add(item);
+ allResults.add(item);
+ }
+ }
+
+ if (newItems.isEmpty()) {
+ break;
+ }
+ current = newItems;
+ }
+
+ return allResults;
+ } finally {
+ stepRef.close();
+ }
+ }
+
+ private Sequence applyStep(final FunctionReference stepRef, final Sequence input) throws XPathException {
+ final ValueSequence result = new ValueSequence();
+ for (final SequenceIterator i = input.iterate(); i.hasNext(); ) {
+ final Item item = i.nextItem();
+ final Sequence stepResult = stepRef.evalFunction(null, null, new Sequence[]{item.toSequence()});
+ result.addAll(stepResult);
+ }
+ return result;
+ }
+
+ private static void addNewItems(final Sequence seq, final ValueSequence target, final Set seen) throws XPathException {
+ for (final SequenceIterator i = seq.iterate(); i.hasNext(); ) {
+ final Item item = i.nextItem();
+ final Object key = itemKey(item);
+ if (seen.add(key)) {
+ target.add(item);
+ }
+ }
+ }
+
+ private static Object itemKey(final Item item) {
+ if (item instanceof NodeValue) {
+ // Use the node itself for identity-based deduplication
+ return ((NodeValue) item).getNode();
+ }
+ // For atomic values, use the value itself
+ return item;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnTypeAnnotation.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnTypeAnnotation.java
new file mode 100644
index 00000000000..16f50ff5b70
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnTypeAnnotation.java
@@ -0,0 +1,528 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.AbstractExpression;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionCall;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.UserDefinedFunction;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.BooleanValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.QNameValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+
+import org.exist.xquery.functions.map.MapType;
+import org.exist.xquery.value.ValueSequence;
+
+import javax.xml.XMLConstants;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Implements XQuery 4.0 fn:atomic-type-annotation and fn:node-type-annotation.
+ *
+ * Returns a schema-type-record (map) with function-valued entries: base-type, primitive-type,
+ * matches, constructor. The entire ancestor chain is pre-computed to avoid issues with
+ * nested function evaluation contexts.
+ */
+public class FnTypeAnnotation extends BasicFunction {
+
+ private static final String XS_NS = "http://www.w3.org/2001/XMLSchema";
+
+ /** List types: type → item type mapping */
+ private static final Map LIST_TYPES = Map.of(
+ Type.NMTOKENS, Type.NMTOKEN,
+ Type.IDREFS, Type.IDREF,
+ Type.ENTITIES, Type.ENTITY
+ );
+
+ /** Union types: type → member types mapping */
+ private static final Map UNION_TYPES = Map.of(
+ Type.NUMERIC, new int[]{Type.DOUBLE, Type.FLOAT, Type.DECIMAL}
+ );
+
+ public static final FunctionSignature FN_ATOMIC_TYPE_ANNOTATION = new FunctionSignature(
+ new QName("atomic-type-annotation", Function.BUILTIN_FUNCTION_NS),
+ "Returns a record describing the type annotation of an atomic value.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.ANY_ATOMIC_TYPE, Cardinality.EXACTLY_ONE, "The atomic value to inspect")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.EXACTLY_ONE, "schema-type-record"));
+
+ public static final FunctionSignature FN_NODE_TYPE_ANNOTATION = new FunctionSignature(
+ new QName("node-type-annotation", Function.BUILTIN_FUNCTION_NS),
+ "Returns a record describing the type annotation of an element or attribute node.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("node", Type.NODE, Cardinality.EXACTLY_ONE, "The element or attribute node to inspect")
+ },
+ new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.EXACTLY_ONE, "schema-type-record"));
+
+ public FnTypeAnnotation(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Item item = args[0].itemAt(0);
+
+ final int type;
+ final boolean isSimple;
+ if (isCalledAs("atomic-type-annotation")) {
+ type = item.getType();
+ isSimple = true;
+ } else {
+ // Non-schema-aware: elements → xs:untyped, attributes → xs:untypedAtomic
+ if (item.getType() == Type.ATTRIBUTE) {
+ type = Type.UNTYPED_ATOMIC;
+ isSimple = true;
+ } else {
+ type = Type.UNTYPED;
+ isSimple = false;
+ }
+ }
+
+ // Pre-compute the full ancestor chain bottom-up
+ return buildRecordChain(type, isSimple);
+ }
+
+ /**
+ * Build the complete record chain from the given type up to xs:anyType.
+ * Each record's base-type function returns the pre-built parent record.
+ *
+ * Uses the XML Schema type hierarchy (not the XPath item() hierarchy)
+ * since type annotations follow the schema derivation chain.
+ */
+ /**
+ * Build a full schema-type-record for a type, used by both
+ * fn:atomic-type-annotation and fn:schema-type.
+ */
+ MapType buildSchemaTypeRecord(final int type, final boolean isSimple) throws XPathException {
+ return buildRecordChain(type, isSimple);
+ }
+
+ private MapType buildRecordChain(final int type, final boolean isSimple) throws XPathException {
+ // Collect ancestor chain using XML Schema hierarchy
+ final List chain = new ArrayList<>();
+ chain.add(new int[]{type, isSimple ? 1 : 0});
+
+ int current = type;
+ while (current != Type.ANY_TYPE) {
+ final int parent = schemaBaseType(current);
+ if (parent == current) {
+ break;
+ }
+ final boolean parentSimple = isSchemaSimpleType(parent);
+ chain.add(new int[]{parent, parentSimple ? 1 : 0});
+ current = parent;
+ }
+
+ // Build records top-down (from anyType to target type)
+ // so each record can reference its pre-built parent record
+ MapType parentRecord = null;
+ for (int i = chain.size() - 1; i >= 0; i--) {
+ final int t = chain.get(i)[0];
+ final boolean simple = chain.get(i)[1] == 1;
+ parentRecord = buildSingleRecord(t, simple, parentRecord, type);
+ }
+
+ return parentRecord;
+ }
+
+ /**
+ * Get the XML Schema base type for a given type.
+ * Unlike Type.getSuperType which follows the XPath item() hierarchy,
+ * this follows the XML Schema derivation chain:
+ * anyAtomicType → anySimpleType → anyType (not item()).
+ */
+ private static int schemaBaseType(final int type) {
+ switch (type) {
+ case Type.ANY_TYPE:
+ return Type.ANY_TYPE; // root
+ case Type.UNTYPED:
+ return Type.ANY_TYPE;
+ case Type.ANY_SIMPLE_TYPE:
+ return Type.ANY_TYPE;
+ case Type.ANY_ATOMIC_TYPE:
+ return Type.ANY_SIMPLE_TYPE; // key fix: not item()
+ case Type.UNTYPED_ATOMIC:
+ return Type.ANY_ATOMIC_TYPE;
+ default:
+ // List types (NMTOKENS, IDREFS, ENTITIES) derive from anySimpleType
+ if (LIST_TYPES.containsKey(type)) {
+ return Type.ANY_SIMPLE_TYPE;
+ }
+ // Union types (NUMERIC, ERROR) derive from anySimpleType
+ if (UNION_TYPES.containsKey(type) || type == Type.ERROR) {
+ return Type.ANY_SIMPLE_TYPE;
+ }
+ // For other types, use the normal hierarchy but redirect through schema chain
+ final int parent = Type.getSuperType(type);
+ // If parent is ITEM, redirect to ANY_ATOMIC_TYPE (for atomic types) or ANY_TYPE
+ if (parent == Type.ITEM) {
+ return Type.ANY_ATOMIC_TYPE;
+ }
+ // NUMERIC subtypes (DOUBLE, FLOAT, DECIMAL) — in schema they derive from
+ // anyAtomicType, not from xs:numeric (which is a union, not a base type)
+ if (parent == Type.NUMERIC) {
+ return Type.ANY_ATOMIC_TYPE;
+ }
+ return parent;
+ }
+ }
+
+ /**
+ * Determine if a type is "simple" in the XML Schema sense.
+ */
+ private static boolean isSchemaSimpleType(final int type) {
+ if (type == Type.ANY_TYPE || type == Type.UNTYPED) {
+ return false;
+ }
+ if (type == Type.ANY_SIMPLE_TYPE || type == Type.ANY_ATOMIC_TYPE || type == Type.UNTYPED_ATOMIC) {
+ return true;
+ }
+ // List and union types are simple types
+ if (LIST_TYPES.containsKey(type) || UNION_TYPES.containsKey(type) || type == Type.ERROR) {
+ return true;
+ }
+ // Walk up to see if we eventually reach ANY_ATOMIC_TYPE or ANY_SIMPLE_TYPE
+ int current = type;
+ for (int i = 0; i < 20; i++) { // safety limit
+ final int parent = Type.getSuperType(current);
+ if (parent == current) break;
+ if (parent == Type.ANY_ATOMIC_TYPE || parent == Type.ANY_SIMPLE_TYPE) {
+ return true;
+ }
+ if (parent == Type.ITEM || parent == Type.ANY_TYPE) {
+ return false;
+ }
+ current = parent;
+ }
+ return false;
+ }
+
+ /**
+ * Build a single schema-type-record for a given type,
+ * with base-type returning the pre-built parent record.
+ *
+ * @param type the eXist type constant
+ * @param isSimple whether this is a simple type
+ * @param parentRecord pre-built parent record (or null for root)
+ * @param leafType the original leaf type (for primitive-type calculation)
+ */
+ private MapType buildSingleRecord(final int type, final boolean isSimple,
+ final MapType parentRecord, final int leafType) throws XPathException {
+ final MapType result = new MapType(this, context);
+
+ // name: xs:QName
+ final QName typeName = typeToQName(type);
+ if (typeName != null) {
+ result.add(new StringValue(this, "name"),
+ new QNameValue(this, context, typeName));
+ }
+
+ // is-simple: xs:boolean
+ result.add(new StringValue(this, "is-simple"), BooleanValue.valueOf(isSimple));
+
+ // variety: depends on the kind of type
+ final String variety = determineVariety(type, isSimple);
+ if (variety != null) {
+ result.add(new StringValue(this, "variety"), new StringValue(this, variety));
+ }
+
+ // base-type: function() as schema-type-record?
+ final Sequence baseTypeResult = parentRecord != null ? parentRecord : Sequence.EMPTY_SEQUENCE;
+ result.add(new StringValue(this, "base-type"), makeConstantFunction("base-type-" + type, 0, baseTypeResult));
+
+ // members: for list and union types, a function returning member type annotations
+ if (LIST_TYPES.containsKey(type)) {
+ // List type: members returns annotation for the item type
+ final int itemType = LIST_TYPES.get(type);
+ final MapType itemTypeRecord = buildRecordChain(itemType, true);
+ result.add(new StringValue(this, "members"),
+ makeConstantFunction("members-" + type, 0, itemTypeRecord));
+ } else if (UNION_TYPES.containsKey(type)) {
+ // Union type: members returns annotations for all member types
+ final int[] memberTypes = UNION_TYPES.get(type);
+ final ValueSequence memberRecords = new ValueSequence(memberTypes.length);
+ for (final int memberType : memberTypes) {
+ memberRecords.add(buildRecordChain(memberType, true));
+ }
+ result.add(new StringValue(this, "members"),
+ makeConstantFunction("members-" + type, 0, memberRecords));
+ }
+
+ // For atomic types: add primitive-type, matches, constructor
+ if (isSimple && isAtomicOrAtomicSubtype(type)) {
+ // primitive-type: find the primitive ancestor type and build its record
+ final int primitiveType = findPrimitiveType(type);
+ if (primitiveType != type) {
+ result.add(new StringValue(this, "primitive-type"),
+ makeConstantFunction("primitive-type-" + type, 0, buildPrimitiveRecord(primitiveType)));
+ } else {
+ // This IS the primitive type — return self
+ result.add(new StringValue(this, "primitive-type"),
+ makeConstantFunction("primitive-type-" + type, 0, result));
+ }
+ }
+
+ // matches: for atomic types and union types that have an atomic() representation
+ if (isSimple && (isAtomicOrAtomicSubtype(type) || UNION_TYPES.containsKey(type))) {
+ result.add(new StringValue(this, "matches"), makeMatchesFunction(type));
+ }
+
+ // constructor: for atomic types (except xs:QName and xs:NOTATION) and list/union types
+ if (isSimple && type != Type.ANY_SIMPLE_TYPE && type != Type.ANY_ATOMIC_TYPE) {
+ if (type != Type.QNAME && type != Type.NOTATION && type != Type.ERROR) {
+ result.add(new StringValue(this, "constructor"), makeConstructorFunction(type));
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Determine the variety of a type for the schema-type-record.
+ * Returns null for xs:anySimpleType (which has no variety per spec).
+ */
+ private static String determineVariety(final int type, final boolean isSimple) {
+ if (type == Type.ANY_SIMPLE_TYPE) {
+ return null; // xs:anySimpleType has no variety
+ }
+ if (LIST_TYPES.containsKey(type)) {
+ return "list";
+ }
+ if (UNION_TYPES.containsKey(type)) {
+ return "union";
+ }
+ if (!isSimple) {
+ return "mixed"; // xs:anyType, xs:untyped
+ }
+ return "atomic";
+ }
+
+ /**
+ * Check if a type is xs:anyAtomicType or a subtype of it.
+ * Excludes list types, union types, and non-simple types.
+ */
+ private static boolean isAtomicOrAtomicSubtype(final int type) {
+ if (type == Type.ANY_ATOMIC_TYPE || type == Type.UNTYPED_ATOMIC) {
+ return true;
+ }
+ if (LIST_TYPES.containsKey(type) || UNION_TYPES.containsKey(type)
+ || type == Type.ERROR || type == Type.ANY_SIMPLE_TYPE
+ || type == Type.ANY_TYPE || type == Type.UNTYPED) {
+ return false;
+ }
+ // Walk up to check if we reach ANY_ATOMIC_TYPE
+ int current = type;
+ for (int i = 0; i < 20; i++) {
+ final int parent = Type.getSuperType(current);
+ if (parent == current) break;
+ if (parent == Type.ANY_ATOMIC_TYPE) return true;
+ if (parent == Type.ITEM || parent == Type.ANY_TYPE || parent == Type.ANY_SIMPLE_TYPE) return false;
+ current = parent;
+ }
+ return false;
+ }
+
+ /**
+ * Build a minimal schema-type-record for the primitive type.
+ */
+ private MapType buildPrimitiveRecord(final int type) throws XPathException {
+ // Primitive types have base-type = anyAtomicType
+ // Build a simple chain: primitiveType → anyAtomicType → anySimpleType → anyType
+ return buildRecordChain(type, true);
+ }
+
+ /**
+ * Create a zero-arg function that returns a constant sequence.
+ */
+ private FunctionReference makeConstantFunction(final String name, final int arity, final Sequence value) throws XPathException {
+ final QName fnName = new QName(name, XMLConstants.NULL_NS_URI);
+ final FunctionSignature sig = new FunctionSignature(fnName, new SequenceType[0],
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "result"));
+ final UserDefinedFunction func = new UserDefinedFunction(context, sig);
+ func.setFunctionBody(new ConstantExpression(context, value));
+ final FunctionCall call = new FunctionCall(context, func);
+ call.setLocation(getLine(), getColumn());
+ return new FunctionReference(this, call);
+ }
+
+ private FunctionReference makeMatchesFunction(final int type) throws XPathException {
+ final QName fnName = new QName("matches-" + type, XMLConstants.NULL_NS_URI);
+ final QName paramName = new QName("value", XMLConstants.NULL_NS_URI);
+ final FunctionSignature sig = new FunctionSignature(fnName,
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.EXACTLY_ONE, "value to test")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if value matches"));
+ final UserDefinedFunction func = new UserDefinedFunction(context, sig);
+ func.addVariable(paramName);
+ func.setFunctionBody(new MatchesExpression(context, type, paramName));
+ final FunctionCall call = new FunctionCall(context, func);
+ call.setLocation(getLine(), getColumn());
+ return new FunctionReference(this, call);
+ }
+
+ private FunctionReference makeConstructorFunction(final int type) throws XPathException {
+ final QName fnName = new QName("constructor-" + type, XMLConstants.NULL_NS_URI);
+ final QName paramName = new QName("value", XMLConstants.NULL_NS_URI);
+ final FunctionSignature sig = new FunctionSignature(fnName,
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.EXACTLY_ONE, "value to cast")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.EXACTLY_ONE, "cast value"));
+ final UserDefinedFunction func = new UserDefinedFunction(context, sig);
+ func.addVariable(paramName);
+ func.setFunctionBody(new ConstructorExpression(context, type, paramName));
+ final FunctionCall call = new FunctionCall(context, func);
+ call.setLocation(getLine(), getColumn());
+ return new FunctionReference(this, call);
+ }
+
+ private static QName typeToQName(final int type) {
+ final String name = Type.getTypeName(type);
+ if (name == null) {
+ return null;
+ }
+ final String local;
+ if (name.startsWith("xs:")) {
+ local = name.substring(3);
+ } else {
+ local = name;
+ }
+ return new QName(local, XS_NS, "xs");
+ }
+
+ private static int findPrimitiveType(final int type) {
+ if (type == Type.ANY_ATOMIC_TYPE || type == Type.ANY_SIMPLE_TYPE || type == Type.ANY_TYPE) {
+ return type;
+ }
+ int current = type;
+ while (true) {
+ final int parent = Type.getSuperType(current);
+ if (parent == Type.ANY_ATOMIC_TYPE || parent == current) {
+ return current;
+ }
+ current = parent;
+ }
+ }
+
+
+ // === Inner expression classes ===
+
+ private static class ConstantExpression extends AbstractExpression {
+ private final Sequence value;
+
+ ConstantExpression(final XQueryContext context, final Sequence value) {
+ super(context);
+ this.value = value;
+ }
+
+ @Override
+ public Sequence eval(final Sequence contextSequence, final Item contextItem) {
+ return value;
+ }
+
+ @Override
+ public int returnsType() { return Type.ITEM; }
+ @Override
+ public void analyze(final org.exist.xquery.AnalyzeContextInfo contextInfo) {}
+ @Override
+ public void dump(final org.exist.xquery.util.ExpressionDumper dumper) { dumper.display("constant"); }
+ @Override
+ public String toString() { return "constant"; }
+ }
+
+ private static class MatchesExpression extends AbstractExpression {
+ private final int targetType;
+ private final QName paramName;
+
+ MatchesExpression(final XQueryContext context, final int targetType, final QName paramName) {
+ super(context);
+ this.targetType = targetType;
+ this.paramName = paramName;
+ }
+
+ @Override
+ public Sequence eval(final Sequence contextSequence, final Item contextItem) throws XPathException {
+ final Sequence value = context.resolveVariable(paramName).getValue();
+ if (value.isEmpty()) {
+ return BooleanValue.FALSE;
+ }
+ final Item item = value.itemAt(0);
+ return BooleanValue.valueOf(Type.subTypeOf(item.getType(), targetType));
+ }
+
+ @Override
+ public int returnsType() { return Type.BOOLEAN; }
+ @Override
+ public void analyze(final org.exist.xquery.AnalyzeContextInfo contextInfo) {}
+ @Override
+ public void dump(final org.exist.xquery.util.ExpressionDumper dumper) { dumper.display("matches()"); }
+ @Override
+ public String toString() { return "matches()"; }
+ }
+
+ private static class ConstructorExpression extends AbstractExpression {
+ private final int targetType;
+ private final QName paramName;
+
+ ConstructorExpression(final XQueryContext context, final int targetType, final QName paramName) {
+ super(context);
+ this.targetType = targetType;
+ this.paramName = paramName;
+ }
+
+ @Override
+ public Sequence eval(final Sequence contextSequence, final Item contextItem) throws XPathException {
+ final Sequence value = context.resolveVariable(paramName).getValue();
+ if (value.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ final Item item = value.itemAt(0);
+ return item.convertTo(targetType);
+ }
+
+ @Override
+ public int returnsType() { return targetType; }
+ @Override
+ public void analyze(final org.exist.xquery.AnalyzeContextInfo contextInfo) {}
+ @Override
+ public void dump(final org.exist.xquery.util.ExpressionDumper dumper) { dumper.display("constructor()"); }
+ @Override
+ public String toString() { return "constructor()"; }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnTypeOf.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnTypeOf.java
new file mode 100644
index 00000000000..0420378474a
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnTypeOf.java
@@ -0,0 +1,146 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.Item;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceIterator;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * Implements XQuery 4.0 fn:type-of.
+ *
+ * Returns a string representation of the type of the supplied value,
+ * matching the SequenceType grammar.
+ */
+public class FnTypeOf extends BasicFunction {
+
+ public static final FunctionSignature FN_TYPE_OF = new FunctionSignature(
+ new QName("type-of", Function.BUILTIN_FUNCTION_NS),
+ "Returns a string describing the type of the supplied value.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_MORE, "The value to inspect")
+ },
+ new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "string matching SequenceType grammar"));
+
+ public FnTypeOf(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final Sequence value = args[0];
+
+ if (value.isEmpty()) {
+ return new StringValue(this, "empty-sequence()");
+ }
+
+ // Collect distinct type strings, preserving order
+ final Set typeStrings = new LinkedHashSet<>();
+ for (final SequenceIterator i = value.iterate(); i.hasNext(); ) {
+ final Item item = i.nextItem();
+ typeStrings.add(itemTypeString(item));
+ }
+
+ final StringBuilder sb = new StringBuilder();
+ if (typeStrings.size() > 1) {
+ sb.append('(');
+ }
+ boolean first = true;
+ for (final String ts : typeStrings) {
+ if (!first) {
+ sb.append('|');
+ }
+ sb.append(ts);
+ first = false;
+ }
+ if (typeStrings.size() > 1) {
+ sb.append(')');
+ }
+
+ // Occurrence indicator
+ final int count = value.getItemCount();
+ if (count > 1) {
+ sb.append('+');
+ }
+
+ return new StringValue(this, sb.toString());
+ }
+
+ private String itemTypeString(final Item item) {
+ final int type = item.getType();
+
+ // Node types
+ switch (type) {
+ case Type.DOCUMENT:
+ return "document-node()";
+ case Type.ELEMENT:
+ return "element()";
+ case Type.ATTRIBUTE:
+ return "attribute()";
+ case Type.TEXT:
+ return "text()";
+ case Type.PROCESSING_INSTRUCTION:
+ return "processing-instruction()";
+ case Type.COMMENT:
+ return "comment()";
+ case Type.NAMESPACE:
+ return "namespace-node()";
+ }
+
+ // Function types
+ if (Type.subTypeOf(type, Type.ARRAY_ITEM)) {
+ return "array(*)";
+ }
+ if (Type.subTypeOf(type, Type.MAP_ITEM)) {
+ return "map(*)";
+ }
+ if (Type.subTypeOf(type, Type.FUNCTION)) {
+ return "fn(*)";
+ }
+
+ // Atomic types: getTypeName already includes the xs: prefix
+ if (Type.subTypeOf(type, Type.ANY_ATOMIC_TYPE)) {
+ final String typeName = Type.getTypeName(type);
+ if (typeName != null) {
+ return typeName;
+ }
+ return "xs:anyAtomicType";
+ }
+
+ return "item()";
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnUnixDateTime.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnUnixDateTime.java
new file mode 100644
index 00000000000..11f946e1531
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnUnixDateTime.java
@@ -0,0 +1,86 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Cardinality;
+import org.exist.xquery.Function;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.DateTimeValue;
+import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReturnSequenceType;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Implements XQuery 4.0 fn:unix-dateTime.
+ *
+ * fn:unix-dateTime($value as xs:nonNegativeInteger?) as xs:dateTimeStamp
+ * Converts milliseconds since Unix epoch to xs:dateTime with UTC timezone.
+ */
+public class FnUnixDateTime extends BasicFunction {
+
+ public static final FunctionSignature[] FN_UNIX_DATETIME = {
+ new FunctionSignature(
+ new QName("unix-dateTime", Function.BUILTIN_FUNCTION_NS),
+ "Converts Unix time in milliseconds to xs:dateTime in UTC.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("value", Type.INTEGER, Cardinality.ZERO_OR_ONE, "Unix time in milliseconds since epoch")
+ },
+ new FunctionReturnSequenceType(Type.DATE_TIME, Cardinality.EXACTLY_ONE, "the corresponding dateTime in UTC")),
+ new FunctionSignature(
+ new QName("unix-dateTime", Function.BUILTIN_FUNCTION_NS),
+ "Returns the Unix epoch (1970-01-01T00:00:00Z).",
+ new SequenceType[] {
+ },
+ new FunctionReturnSequenceType(Type.DATE_TIME, Cardinality.EXACTLY_ONE, "the Unix epoch"))
+ };
+
+ public FnUnixDateTime(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ long millis = 0;
+ if (args.length > 0 && !args[0].isEmpty()) {
+ millis = ((IntegerValue) args[0].itemAt(0)).getLong();
+ }
+
+ final ZonedDateTime zdt = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC);
+ // Format with explicit seconds and optional milliseconds
+ final long ms = millis % 1000;
+ final String pattern = (ms != 0) ? "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" : "yyyy-MM-dd'T'HH:mm:ss'Z'";
+ final String isoStr = DateTimeFormatter.ofPattern(pattern).format(zdt);
+ return new DateTimeValue(this, isoStr);
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FnUnparsedBinary.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnUnparsedBinary.java
new file mode 100644
index 00000000000..4da5d1b310d
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FnUnparsedBinary.java
@@ -0,0 +1,114 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.fn;
+
+import org.exist.dom.QName;
+import org.exist.source.FileSource;
+import org.exist.source.Source;
+import org.exist.source.SourceFactory;
+import org.exist.xquery.*;
+import org.exist.xquery.value.*;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+
+/**
+ * fn:unparsed-binary($uri as xs:string?) as xs:base64Binary?
+ * Loads binary content from a URI and returns it as xs:base64Binary.
+ */
+public class FnUnparsedBinary extends BasicFunction {
+
+ public static final FunctionSignature FN_UNPARSED_BINARY = new FunctionSignature(
+ new QName("unparsed-binary", Function.BUILTIN_FUNCTION_NS),
+ "Loads binary content from a URI and returns it as xs:base64Binary.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("uri", Type.STRING,
+ Cardinality.ZERO_OR_ONE, "The URI of the binary resource")
+ },
+ new FunctionReturnSequenceType(Type.BASE64_BINARY, Cardinality.ZERO_OR_ONE,
+ "The binary content"));
+
+ public FnUnparsedBinary(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ if (args[0].isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final String uriParam = args[0].getStringValue();
+
+ try {
+ URI uri = new URI(uriParam);
+
+ // Resolve relative URIs against file: base URI
+ boolean resolvedFromBaseUri = false;
+ if (!uri.isAbsolute()) {
+ final AnyURIValue baseXdmUri = context.getBaseURI();
+ if (baseXdmUri != null && !baseXdmUri.equals(AnyURIValue.EMPTY_URI)) {
+ String baseStr = baseXdmUri.toURI().toString();
+ if (baseStr.startsWith("file:")) {
+ final int lastSlash = baseStr.lastIndexOf('/');
+ if (lastSlash >= 0) {
+ baseStr = baseStr.substring(0, lastSlash + 1);
+ }
+ uri = new URI(baseStr).resolve(uri);
+ resolvedFromBaseUri = true;
+ }
+ }
+ }
+
+ final String resolvedUri = uri.toASCIIString();
+
+ // Handle file: URIs directly (only for resolved relative paths)
+ if (resolvedFromBaseUri && resolvedUri.startsWith("file:")) {
+ final String filePath = resolvedUri.replaceFirst("^file:(?://[^/]*)?", "");
+ final java.nio.file.Path path = java.nio.file.Paths.get(filePath);
+ if (java.nio.file.Files.isReadable(path)) {
+ try (final InputStream is = java.nio.file.Files.newInputStream(path)) {
+ return BinaryValueFromInputStream.getInstance(context,
+ new Base64BinaryValueType(), is, this);
+ }
+ }
+ throw new XPathException(this, ErrorCodes.FOUT1170,
+ "Could not find binary resource: " + uriParam);
+ }
+
+ // Use SourceFactory for other URIs
+ final Source source = SourceFactory.getSource(context.getBroker(), "", resolvedUri, false);
+ if (source == null) {
+ throw new XPathException(this, ErrorCodes.FOUT1170,
+ "Could not find binary resource: " + uriParam);
+ }
+ try (final InputStream is = source.getInputStream()) {
+ return BinaryValueFromInputStream.getInstance(context,
+ new Base64BinaryValueType(), is, this);
+ }
+ } catch (final IOException | URISyntaxException | org.exist.security.PermissionDeniedException e) {
+ throw new XPathException(this, ErrorCodes.FOUT1170, e.getMessage());
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunAnalyzeString.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunAnalyzeString.java
index 8fe035492a7..e9d8eb9764f 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunAnalyzeString.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunAnalyzeString.java
@@ -158,25 +158,46 @@ private void analyzeString(final MemTreeBuilder builder, final String input, Str
private void match(final MemTreeBuilder builder, final RegexIterator regexIterator) throws net.sf.saxon.trans.XPathException {
builder.startElement(QN_MATCH, null);
- regexIterator.processMatchingSubstring(new RegexIterator.MatchHandler() {
- @Override
- public void characters(final CharSequence s) {
- builder.characters(s);
- }
-
- @Override
- public void onGroupStart(final int groupNumber) throws net.sf.saxon.trans.XPathException {
- final AttributesImpl attributes = new AttributesImpl();
- attributes.addAttribute("", QN_NR.getLocalPart(), QN_NR.getLocalPart(), "int", Integer.toString(groupNumber));
-
- builder.startElement(QN_GROUP, attributes);
- }
-
- @Override
- public void onGroupEnd(final int groupNumber) throws net.sf.saxon.trans.XPathException {
- builder.endElement();
+ // Use reflection to avoid compile-time dependency on RegexIterator$MatchHandler,
+ // which is stripped from the XQTS runner assembly JAR by sbt's merge strategy.
+ // When running in the normal eXist server (or on the next branch with full Saxon),
+ // the proxy delegates to Saxon's own group traversal logic.
+ try {
+ final Class> handlerClass = Class.forName("net.sf.saxon.regex.RegexIterator$MatchHandler");
+ final Object handler = java.lang.reflect.Proxy.newProxyInstance(
+ handlerClass.getClassLoader(),
+ new Class>[]{ handlerClass },
+ (proxy, method, args) -> {
+ switch (method.getName()) {
+ case "characters":
+ builder.characters((CharSequence) args[0]);
+ break;
+ case "onGroupStart":
+ final AttributesImpl attrs = new AttributesImpl();
+ attrs.addAttribute("", QN_NR.getLocalPart(), QN_NR.getLocalPart(),
+ "int", Integer.toString((Integer) args[0]));
+ builder.startElement(QN_GROUP, attrs);
+ break;
+ case "onGroupEnd":
+ builder.endElement();
+ break;
+ }
+ return null;
+ });
+ final java.lang.reflect.Method processMethod = regexIterator.getClass().getMethod(
+ "processMatchingSubstring", handlerClass);
+ processMethod.invoke(regexIterator, handler);
+ } catch (final ClassNotFoundException e) {
+ // MatchHandler unavailable — output match text without group decomposition
+ builder.characters(regexIterator.getRegexGroup(0));
+ } catch (final java.lang.reflect.InvocationTargetException e) {
+ if (e.getCause() instanceof net.sf.saxon.trans.XPathException) {
+ throw (net.sf.saxon.trans.XPathException) e.getCause();
}
- });
+ builder.characters(regexIterator.getRegexGroup(0));
+ } catch (final Exception e) {
+ builder.characters(regexIterator.getRegexGroup(0));
+ }
builder.endElement();
}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunCompare.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunCompare.java
index d2cd6e102c7..2b547f8a8a3 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunCompare.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunCompare.java
@@ -32,14 +32,23 @@
import org.exist.xquery.Profiler;
import org.exist.xquery.XPathException;
import org.exist.xquery.XQueryContext;
+import org.exist.xquery.value.AbstractDateTimeValue;
+import org.exist.xquery.value.AtomicValue;
+import org.exist.xquery.value.DoubleValue;
+import org.exist.xquery.value.DurationValue;
+import org.exist.xquery.value.FloatValue;
import org.exist.xquery.value.FunctionReturnSequenceType;
import org.exist.xquery.value.FunctionParameterSequenceType;
import org.exist.xquery.value.IntegerValue;
import org.exist.xquery.value.Item;
+import org.exist.xquery.value.NumericValue;
import org.exist.xquery.value.Sequence;
import org.exist.xquery.value.SequenceType;
import org.exist.xquery.value.Type;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+
import javax.annotation.Nullable;
/**
@@ -52,44 +61,33 @@ public class FunCompare extends CollatingFunction {
public final static FunctionSignature[] signatures = {
new FunctionSignature (
new QName("compare", Function.BUILTIN_FUNCTION_NS),
- "Returns the collatable comparison between $string-1 and $string-2, using $collation-uri. " +
- "-1 if $string-1 is inferior to $string-2, 0 if $string-1 is equal " +
- "to $string-2, 1 if $string-1 is superior to $string-2. " +
- "If either comparand is the empty sequence, the empty sequence is " +
- "returned. " +
- "Please remember to specify the collation in the context or use, " +
- "the three argument version if you don't want the system default.",
+ "Returns -1, 0, or 1, depending on whether $value-1 is less than, equal to, " +
+ "or greater than $value-2. " +
+ "If either comparand is the empty sequence, the empty sequence is returned.",
new SequenceType[] {
- new FunctionParameterSequenceType("string-1", Type.STRING,
- Cardinality.ZERO_OR_ONE, "The first string"),
- new FunctionParameterSequenceType("string-2", Type.STRING,
- Cardinality.ZERO_OR_ONE, "The second string")
+ new FunctionParameterSequenceType("value-1", Type.ANY_ATOMIC_TYPE,
+ Cardinality.ZERO_OR_ONE, "The first value"),
+ new FunctionParameterSequenceType("value-2", Type.ANY_ATOMIC_TYPE,
+ Cardinality.ZERO_OR_ONE, "The second value")
},
new FunctionReturnSequenceType(Type.INTEGER, Cardinality.ZERO_OR_ONE,
- "-1 if $string-1 is inferior to $string-2, " +
- "0 if $string-1 is equal to $string-2, " +
- "1 if $string-1 is superior to $string-2. " +
- "If either comparand is the empty sequence, the empty sequence is returned.")),
+ "-1, 0, or 1 depending on comparison result")),
new FunctionSignature (
new QName("compare", Function.BUILTIN_FUNCTION_NS),
- "Returns the collatable comparison between $string-1 and $string-2, using $collation-uri. " +
- "-1 if $string-1 is inferior to $string-2, 0 if $string-1 is equal " +
- "to $string-2, 1 if $string-1 is superior to $string-2. " +
+ "Returns -1, 0, or 1, depending on whether $value-1 is less than, equal to, " +
+ "or greater than $value-2, using the specified collation. " +
"If either comparand is the empty sequence, the empty sequence is returned. " +
THIRD_REL_COLLATION_ARG_EXAMPLE,
new SequenceType[] {
- new FunctionParameterSequenceType("string-1", Type.STRING,
- Cardinality.ZERO_OR_ONE, "The first string"),
- new FunctionParameterSequenceType("string-2", Type.STRING,
- Cardinality.ZERO_OR_ONE, "The second string"),
+ new FunctionParameterSequenceType("value-1", Type.ANY_ATOMIC_TYPE,
+ Cardinality.ZERO_OR_ONE, "The first value"),
+ new FunctionParameterSequenceType("value-2", Type.ANY_ATOMIC_TYPE,
+ Cardinality.ZERO_OR_ONE, "The second value"),
new FunctionParameterSequenceType("collation-uri", Type.STRING,
Cardinality.EXACTLY_ONE, "The relative collation URI")
},
new FunctionReturnSequenceType(Type.INTEGER, Cardinality.ZERO_OR_ONE,
- "-1 if $string-1 is inferior to $string-2, " +
- "0 if $string-1 is equal to $string-2, " +
- "1 if $string-1 is superior to $string-2. " +
- "If either comparand is the empty sequence, the empty sequence is returned."))
+ "-1, 0, or 1 depending on comparison result"))
};
public FunCompare(XQueryContext context, FunctionSignature signature) {
@@ -123,14 +121,132 @@ public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathExc
return result;
}
- static int compare(final Item item1, final Item item2, @Nullable final Collator collator) throws XPathException {
- final int comparison = Collations.compare(collator, item1.getStringValue(), item2.getStringValue());
- if (comparison == Constants.EQUAL) {
+ public static int compare(final Item item1, final Item item2, @Nullable final Collator collator) throws XPathException {
+ final AtomicValue v1 = item1.atomize();
+ final AtomicValue v2 = item2.atomize();
+
+ // For string-like types, use collation-aware comparison
+ if (isStringLike(v1.getType()) && isStringLike(v2.getType())) {
+ return normalizeComparison(Collations.compare(collator, v1.getStringValue(), v2.getStringValue()));
+ }
+
+ // XQ4 numeric total order: compare by exact mathematical magnitude
+ if (v1 instanceof NumericValue && v2 instanceof NumericValue) {
+ return numericTotalOrder((NumericValue) v1, (NumericValue) v2);
+ }
+
+ // XQ4 duration total order: months first, then seconds
+ if (v1 instanceof DurationValue && v2 instanceof DurationValue) {
+ return durationTotalOrder((DurationValue) v1, (DurationValue) v2);
+ }
+
+ // XQ4 date/time total order: normalize to millis for types where
+ // XMLGregorianCalendar.compare() may return INDETERMINATE
+ if (v1 instanceof AbstractDateTimeValue && v2 instanceof AbstractDateTimeValue
+ && v1.getType() == v2.getType()) {
+ return dateTimeTotalOrder((AbstractDateTimeValue) v1, (AbstractDateTimeValue) v2);
+ }
+
+ // For other atomic types, use natural ordering via compareTo
+ return normalizeComparison(v1.compareTo(collator, v2));
+ }
+
+ /**
+ * XQ4 numeric total order for fn:compare.
+ * Float is promoted to double. NaN == NaN (and NaN < everything).
+ * -0.0 == +0.0. Doubles and decimals compared by exact mathematical magnitude.
+ */
+ static int numericTotalOrder(final NumericValue v1, final NumericValue v2) throws XPathException {
+ // Promote float to double
+ final double d1 = v1.getDouble();
+ final double d2 = v2.getDouble();
+
+ final boolean nan1 = Double.isNaN(d1);
+ final boolean nan2 = Double.isNaN(d2);
+
+ // NaN equals NaN, NaN < everything else
+ if (nan1 && nan2) {
return Constants.EQUAL;
- } else if (comparison < 0) {
+ }
+ if (nan1) {
return Constants.INFERIOR;
- } else {
+ }
+ if (nan2) {
return Constants.SUPERIOR;
}
+
+ // Handle infinities
+ if (Double.isInfinite(d1) || Double.isInfinite(d2)) {
+ if (d1 == d2) {
+ return Constants.EQUAL;
+ }
+ return d1 < d2 ? Constants.INFERIOR : Constants.SUPERIOR;
+ }
+
+ // -0.0 == +0.0
+ if (d1 == 0.0 && d2 == 0.0) {
+ return Constants.EQUAL;
+ }
+
+ // Compare by exact mathematical magnitude using BigDecimal
+ final BigDecimal bd1 = toBigDecimal(v1);
+ final BigDecimal bd2 = toBigDecimal(v2);
+ return normalizeComparison(bd1.compareTo(bd2));
+ }
+
+ private static BigDecimal toBigDecimal(final NumericValue v) throws XPathException {
+ if (v instanceof org.exist.xquery.value.DecimalValue) {
+ return ((org.exist.xquery.value.DecimalValue) v).getValue();
+ }
+ if (v instanceof IntegerValue) {
+ // Use string representation — getValue() truncates to long for big integers
+ return new BigDecimal(v.getStringValue());
+ }
+ // Double or Float — use exact decimal representation (no rounding)
+ return new BigDecimal(v.getDouble());
+ }
+
+ /**
+ * XQ4 duration total order for fn:compare.
+ * Compares months component first, then seconds component.
+ * This provides a total order even for xs:duration values where
+ * months and seconds are both present (which XMLGregorianCalendar
+ * considers INDETERMINATE).
+ */
+ static int durationTotalOrder(final DurationValue v1, final DurationValue v2) {
+ final BigInteger months1 = v1.monthsValueSigned();
+ final BigInteger months2 = v2.monthsValueSigned();
+ final int monthsCmp = months1.compareTo(months2);
+ if (monthsCmp != 0) {
+ return normalizeComparison(monthsCmp);
+ }
+ final BigDecimal seconds1 = v1.secondsValueSigned();
+ final BigDecimal seconds2 = v2.secondsValueSigned();
+ return normalizeComparison(seconds1.compareTo(seconds2));
+ }
+
+ /**
+ * XQ4 date/time total order for fn:compare.
+ * Uses getTimeInMillis() to normalize both values to a common
+ * representation, avoiding INDETERMINATE results from
+ * XMLGregorianCalendar.compare() on partial date/time types.
+ */
+ static int dateTimeTotalOrder(final AbstractDateTimeValue v1, final AbstractDateTimeValue v2) {
+ final long ms1 = v1.getTimeInMillis();
+ final long ms2 = v2.getTimeInMillis();
+ return normalizeComparison(Long.compare(ms1, ms2));
+ }
+
+ private static int normalizeComparison(final int cmp) {
+ if (cmp == 0) {
+ return Constants.EQUAL;
+ }
+ return cmp < 0 ? Constants.INFERIOR : Constants.SUPERIOR;
+ }
+
+ private static boolean isStringLike(final int type) {
+ return Type.subTypeOf(type, Type.STRING)
+ || type == Type.UNTYPED_ATOMIC
+ || Type.subTypeOf(type, Type.ANY_URI);
}
}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunDeepEqual.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunDeepEqual.java
index 6e6e0285dc2..8138c198a2c 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunDeepEqual.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunDeepEqual.java
@@ -35,6 +35,7 @@
import org.exist.xquery.Dependency;
import org.exist.xquery.Function;
import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.InlineFunction;
import org.exist.xquery.Profiler;
import org.exist.xquery.ValueComparison;
import org.exist.xquery.XPathException;
@@ -43,6 +44,7 @@
import org.exist.xquery.functions.map.AbstractMapType;
import org.exist.xquery.value.AtomicValue;
import org.exist.xquery.value.BooleanValue;
+import org.exist.xquery.value.FunctionReference;
import org.exist.xquery.value.FunctionReturnSequenceType;
import org.exist.xquery.value.FunctionParameterSequenceType;
import org.exist.xquery.value.Item;
@@ -55,6 +57,8 @@
import org.w3c.dom.Node;
import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.List;
/**
* Implements the fn:deep-equal library function.
@@ -225,6 +229,29 @@ public static int deepCompare(final Item item1, final Item item2, @Nullable fina
}
}
+ // XQ4: Function items compared by function-identity semantics
+ if (Type.subTypeOf(item1.getType(), Type.FUNCTION) || Type.subTypeOf(item2.getType(), Type.FUNCTION)) {
+ if (!Type.subTypeOf(item1.getType(), Type.FUNCTION) || !Type.subTypeOf(item2.getType(), Type.FUNCTION)) {
+ return Constants.INFERIOR;
+ }
+ if (item1 == item2) {
+ return Constants.EQUAL;
+ }
+ // Named functions with same name and arity are equal
+ if (item1 instanceof FunctionReference ref1 && item2 instanceof FunctionReference ref2) {
+ final org.exist.dom.QName name1 = ref1.getSignature().getName();
+ final org.exist.dom.QName name2 = ref2.getSignature().getName();
+ if (name1 != null && name2 != null
+ && name1 != InlineFunction.INLINE_FUNCTION_QNAME
+ && name2 != InlineFunction.INLINE_FUNCTION_QNAME
+ && name1.equals(name2)
+ && ref1.getSignature().getArgumentCount() == ref2.getSignature().getArgumentCount()) {
+ return Constants.EQUAL;
+ }
+ }
+ return Constants.INFERIOR;
+ }
+
final boolean item1IsAtomic = Type.subTypeOf(item1.getType(), Type.ANY_ATOMIC_TYPE);
final boolean item2IsAtomic = Type.subTypeOf(item2.getType(), Type.ANY_ATOMIC_TYPE);
if (item1IsAtomic || item2IsAtomic) {
@@ -370,44 +397,75 @@ private static int compareElements(final Node a, final Node b, @Nullable final C
}
private static int compareContents(Node a, Node b, @Nullable final Collator collator) {
- a = findNextTextOrElementNode(a.getFirstChild());
- b = findNextTextOrElementNode(b.getFirstChild());
- while (!(a == null || b == null)) {
- final int nodeTypeA = getEffectiveNodeType(a);
- final int nodeTypeB = getEffectiveNodeType(b);
- if (nodeTypeA != nodeTypeB) {
- return Constants.INFERIOR;
- }
- switch (nodeTypeA) {
- case Node.TEXT_NODE:
- final String nodeValueA = getNodeValue(a);
- final String nodeValueB = getNodeValue(b);
- final int textComparison = safeCompare(nodeValueA, nodeValueB, collator);
+ // XQ4: merge adjacent text nodes (split by ignored comments/PIs)
+ final List childrenA = mergeTextNodes(a);
+ final List childrenB = mergeTextNodes(b);
+
+ if (childrenA.size() != childrenB.size()) {
+ return childrenA.size() < childrenB.size() ? Constants.INFERIOR : Constants.SUPERIOR;
+ }
+
+ for (int i = 0; i < childrenA.size(); i++) {
+ final Object ca = childrenA.get(i);
+ final Object cb = childrenB.get(i);
+
+ if (ca instanceof String sa && cb instanceof String sb) {
+ final int textComparison = safeCompare(sa, sb, collator);
if (textComparison != Constants.EQUAL) {
return textComparison;
}
- break;
- case Node.ELEMENT_NODE:
- final int elementComparison = compareElements(a, b, collator);
- if (elementComparison != Constants.EQUAL) {
- return elementComparison;
+ } else if (ca instanceof Node na && cb instanceof Node nb) {
+ if (getEffectiveNodeType(na) != getEffectiveNodeType(nb)) {
+ return Constants.INFERIOR;
+ }
+ if (getEffectiveNodeType(na) != getEffectiveNodeType(nb)) {
+ return Constants.INFERIOR;
+ }
+ if (getEffectiveNodeType(na) == Node.ELEMENT_NODE) {
+ final int cmp = compareElements(na, nb, collator);
+ if (cmp != Constants.EQUAL) {
+ return cmp;
+ }
}
- break;
- default:
- throw new RuntimeException("unexpected node type " + nodeTypeA);
+ } else {
+ return Constants.INFERIOR;
}
- a = findNextTextOrElementNode(a.getNextSibling());
- b = findNextTextOrElementNode(b.getNextSibling());
}
+ return Constants.EQUAL;
+ }
- // NOTE(AR): intentional reference equality check
- if (a == b) {
- return Constants.EQUAL; // both null
- } else if (a == null) {
- return Constants.INFERIOR;
- } else {
- return Constants.SUPERIOR;
+ /**
+ * Collect significant children for deep-equal comparison.
+ * Per XQ3.1 spec §15.3.1: "children are compared after removing all
+ * comment and processing-instruction nodes" — but text nodes are
+ * NOT merged (elements with split text nodes differ from single text).
+ */
+ static List mergeTextNodes(final Node parent) {
+ final List result = new ArrayList<>();
+ StringBuilder textAccum = null;
+ Node child = parent.getFirstChild();
+ while (child != null) {
+ final int nodeType = getEffectiveNodeType(child);
+ if (nodeType == Node.TEXT_NODE) {
+ // XQ4: merge adjacent text nodes (split by ignored comments/PIs)
+ if (textAccum == null) {
+ textAccum = new StringBuilder();
+ }
+ textAccum.append(getNodeValue(child));
+ } else if (nodeType == Node.ELEMENT_NODE) {
+ if (textAccum != null) {
+ result.add(textAccum.toString());
+ textAccum = null;
+ }
+ result.add(child);
+ }
+ // Skip comments and PIs per spec — text continues to merge
+ child = child.getNextSibling();
}
+ if (textAccum != null) {
+ result.add(textAccum.toString());
+ }
+ return result;
}
private static String getNodeValue(final Node n) {
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunDoc.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunDoc.java
index 65b663f4848..ad8829c0d42 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunDoc.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunDoc.java
@@ -64,11 +64,27 @@ public class FunDoc extends Function {
"the document node of $document-uri")
);
+ // XQuery 4.0: fn:doc with options map
+ public final static FunctionSignature signatureWithOptions =
+ new FunctionSignature(
+ new QName("doc", Function.BUILTIN_FUNCTION_NS),
+ "Returns the document node of $document-uri with options. " +
+ XMLDBModule.ANY_URI,
+ new SequenceType[] {
+ new FunctionParameterSequenceType("document-uri", Type.STRING,
+ Cardinality.ZERO_OR_ONE, "The document URI"),
+ new FunctionParameterSequenceType("options", Type.MAP_ITEM,
+ Cardinality.EXACTLY_ONE, "Options map")
+ },
+ new FunctionReturnSequenceType(Type.DOCUMENT, Cardinality.ZERO_OR_ONE,
+ "the document node of $document-uri")
+ );
+
// fixit! - security warning
private UpdateListener listener = null;
- public FunDoc(XQueryContext context) {
- super(context, signature);
+ public FunDoc(XQueryContext context, FunctionSignature sig) {
+ super(context, sig);
}
public Sequence eval(Sequence contextSequence, Item contextItem)
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunDocAvailable.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunDocAvailable.java
index be44565be0a..e1f5591b4fa 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunDocAvailable.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunDocAvailable.java
@@ -62,8 +62,23 @@ public class FunDocAvailable extends Function {
new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE,
"true() if the document is available, false() otherwise"));
- public FunDocAvailable(final XQueryContext context) {
- super(context, signature);
+ // XQuery 4.0: fn:doc-available with options map
+ public static final FunctionSignature signatureWithOptions =
+ new FunctionSignature(
+ new QName("doc-available", Function.BUILTIN_FUNCTION_NS),
+ "Returns whether or not the document is available, with options. " +
+ XMLDBModule.ANY_URI,
+ new SequenceType[]{
+ new FunctionParameterSequenceType("document-uri", Type.STRING,
+ Cardinality.ZERO_OR_ONE, "The document URI"),
+ new FunctionParameterSequenceType("options", Type.MAP_ITEM,
+ Cardinality.EXACTLY_ONE, "Options map")
+ },
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE,
+ "true() if the document is available, false() otherwise"));
+
+ public FunDocAvailable(final XQueryContext context, final FunctionSignature sig) {
+ super(context, sig);
}
@Override
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunHeadTail.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunHeadTail.java
index c01a4110863..bd3949aca2a 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunHeadTail.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunHeadTail.java
@@ -35,27 +35,46 @@
import org.exist.xquery.value.Sequence;
import org.exist.xquery.value.SequenceType;
import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
public class FunHeadTail extends BasicFunction {
- public final static FunctionSignature[] signatures = {
- new FunctionSignature(
- new QName("head", Function.BUILTIN_FUNCTION_NS),
- "The function returns the value of the expression $arg[1], i.e. the first item in the " +
- "passed in sequence.",
- new SequenceType[] {
- new FunctionParameterSequenceType("arg", Type.ITEM, Cardinality.ZERO_OR_MORE, "")
- },
- new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_ONE, "the first item or the empty sequence")),
- new FunctionSignature(
- new QName("tail", Function.BUILTIN_FUNCTION_NS),
- "The function returns the value of the expression subsequence($sequence, 2), i.e. a new sequence containing " +
- "all items of the input sequence except the first.",
- new SequenceType[] {
- new FunctionParameterSequenceType("sequence", Type.ITEM, Cardinality.ZERO_OR_MORE, "The source sequence")
- },
- new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "the resulting sequence")) };
-
+ public final static FunctionSignature FN_HEAD = new FunctionSignature(
+ new QName("head", Function.BUILTIN_FUNCTION_NS),
+ "The function returns the value of the expression $arg[1], i.e. the first item in the " +
+ "passed in sequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("arg", Type.ITEM, Cardinality.ZERO_OR_MORE, "")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_ONE, "the first item or the empty sequence"));
+
+ public final static FunctionSignature FN_TAIL = new FunctionSignature(
+ new QName("tail", Function.BUILTIN_FUNCTION_NS),
+ "The function returns the value of the expression subsequence($sequence, 2), i.e. a new sequence containing " +
+ "all items of the input sequence except the first.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("sequence", Type.ITEM, Cardinality.ZERO_OR_MORE, "The source sequence")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "the resulting sequence"));
+
+ public final static FunctionSignature FN_FOOT = new FunctionSignature(
+ new QName("foot", Function.BUILTIN_FUNCTION_NS),
+ "Returns the last item in a sequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_ONE, "the last item or the empty sequence"));
+
+ public final static FunctionSignature FN_TRUNK = new FunctionSignature(
+ new QName("trunk", Function.BUILTIN_FUNCTION_NS),
+ "Returns all but the last item in a sequence.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("input", Type.ITEM, Cardinality.ZERO_OR_MORE, "The input sequence")
+ },
+ new FunctionReturnSequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE, "all items except the last"));
+
+ public final static FunctionSignature[] signatures = { FN_HEAD, FN_TAIL, FN_FOOT, FN_TRUNK };
+
public FunHeadTail(XQueryContext context, FunctionSignature signature) {
super(context, signature);
}
@@ -64,24 +83,36 @@ public FunHeadTail(XQueryContext context, FunctionSignature signature) {
public void analyze(AnalyzeContextInfo contextInfo) throws XPathException {
super.analyze(contextInfo);
if (getContext().getXQueryVersion()<30) {
- throw new XPathException(this, ErrorCodes.EXXQDY0003, "Function " +
+ throw new XPathException(this, ErrorCodes.EXXQDY0003, "Function " +
getSignature().getName() + " is only supported for xquery version \"3.0\" and later.");
}
}
-
+
@Override
public Sequence eval(Sequence[] args, Sequence contextSequence)
throws XPathException {
final Sequence seq = args[0];
- Sequence tmp;
if (seq.isEmpty()) {
- tmp = Sequence.EMPTY_SEQUENCE;
- } else if (isCalledAs("head")) {
- tmp = seq.itemAt(0).toSequence();
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ if (isCalledAs("head")) {
+ return seq.itemAt(0).toSequence();
+ } else if (isCalledAs("tail")) {
+ return seq.tail();
+ } else if (isCalledAs("foot")) {
+ return seq.itemAt(seq.getItemCount() - 1).toSequence();
} else {
- tmp = seq.tail();
+ // trunk: all items except the last
+ final int count = seq.getItemCount();
+ if (count <= 1) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ final ValueSequence result = new ValueSequence(count - 1);
+ for (int i = 0; i < count - 1; i++) {
+ result.add(seq.itemAt(i));
+ }
+ return result;
}
- return tmp;
}
}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunInScopePrefixes.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunInScopePrefixes.java
index a0a248dface..3ba1ec23f08 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunInScopePrefixes.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunInScopePrefixes.java
@@ -79,6 +79,10 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
final ValueSequence result = new ValueSequence();
for (final String prefix : prefixes.keySet()) {
+ // Per XQuery spec §14.2: "xmlns" must not be included in the result
+ if ("xmlns".equals(prefix)) {
+ continue;
+ }
//The predefined namespaces (e.g. "exist" for temporary nodes) could have been removed from the static context
if (!(context.getURIForPrefix(prefix) == null &&
("exist".equals(prefix) || "xs".equals(prefix) || "xsi".equals(prefix) ||
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunMatches.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunMatches.java
index 6f06bd772ce..c84fc8879af 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunMatches.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunMatches.java
@@ -63,7 +63,8 @@ public final class FunMatches extends Function implements Optimizable, IndexUseR
private static final FunctionParameterSequenceType FS_PARAM_INPUT = optParam("input", Type.STRING, "The input string");
private static final FunctionParameterSequenceType FS_PARAM_PATTERN = param("pattern", Type.STRING, "The pattern");
- private static final FunctionParameterSequenceType FS_PARAM_FLAGS = param("flags", Type.STRING, "The flags");
+ private static final FunctionParameterSequenceType FS_PARAM_FLAGS =
+ new FunctionParameterSequenceType("flags", Type.STRING, Cardinality.ZERO_OR_ONE, "The flags");
private static final String FS_MATCHES_NAME = "matches";
private static final String FS_DESCRIPTION =
@@ -138,7 +139,7 @@ public void setArguments(final List arguments) throws XPathException
if (arguments.size() >= 3) {
Expression arg = arguments.get(2);
- arg = new DynamicCardinalityCheck(context, Cardinality.EXACTLY_ONE, arg,
+ arg = new DynamicCardinalityCheck(context, Cardinality.ZERO_OR_ONE, arg,
new Error(Error.FUNC_PARAM_CARDINALITY, "3", getSignature()));
if (!Type.subTypeOf(arg.returnsType(), Type.ANY_ATOMIC_TYPE)) {
arg = new Atomize(context, arg);
@@ -212,7 +213,8 @@ public NodeSet preSelect(final Sequence contextSequence, final boolean useContex
final int flags;
if (getSignature().getArgumentCount() == 3) {
- final String flagsArg = getArgument(2).eval(contextSequence, null).getStringValue();
+ final Sequence flagsSeq = getArgument(2).eval(contextSequence, null);
+ final String flagsArg = flagsSeq.isEmpty() ? "" : flagsSeq.getStringValue();
flags = parseFlags(this, flagsArg);
} else {
flags = 0;
@@ -382,7 +384,8 @@ private Sequence evalWithIndex(final Sequence contextSequence, final Item contex
final int flags;
if (getSignature().getArgumentCount() == 3) {
- final String flagsArg = getArgument(2).eval(contextSequence, contextItem).getStringValue();
+ final Sequence flagsSeq = getArgument(2).eval(contextSequence, contextItem);
+ final String flagsArg = flagsSeq.isEmpty() ? "" : flagsSeq.getStringValue();
flags = parseFlags(this, flagsArg);
} else {
flags = 0;
@@ -497,7 +500,8 @@ private Sequence evalGeneric(final Sequence contextSequence, final Item contextI
final String xmlRegexFlags;
if (getSignature().getArgumentCount() == 3) {
- xmlRegexFlags = getArgument(2).eval(contextSequence, contextItem).getStringValue();
+ final Sequence flagsSeq = getArgument(2).eval(contextSequence, contextItem);
+ xmlRegexFlags = flagsSeq.isEmpty() ? "" : flagsSeq.getStringValue();
} else {
xmlRegexFlags = "";
}
@@ -512,7 +516,16 @@ private Sequence evalGeneric(final Sequence contextSequence, final Item contextI
}
- private boolean matchXmlRegex(final String string, final String pattern, final String flags) throws XPathException {
+ private boolean matchXmlRegex(String string, String pattern, String flags) throws XPathException {
+ // XQ4: 'c' flag — strip regex comments before compilation
+ final boolean hasCommentFlag = flags.indexOf('c') >= 0 && flags.indexOf('q') < 0;
+ if (flags.indexOf('c') >= 0) {
+ flags = flags.replace("c", "");
+ }
+ if (hasCommentFlag) {
+ pattern = FunReplace.stripRegexComments(pattern);
+ }
+
try {
List warnings = new ArrayList<>(1);
RegularExpression regex = context.getBroker().getBrokerPool()
@@ -526,6 +539,18 @@ private boolean matchXmlRegex(final String string, final String pattern, final S
return regex.containsMatch(string);
} catch (final net.sf.saxon.trans.XPathException e) {
+ // Fallback: if the pattern uses \p{Is} Unicode block names that
+ // Saxon doesn't recognize, convert to Java's \p{In} and use Java regex
+ if (pattern.contains("\\p{Is") || pattern.contains("\\P{Is")) {
+ try {
+ final String javaPattern = org.exist.xquery.regex.RegexUtil.translateRegexp(
+ this, pattern, flags.contains("x"), flags.contains("i"));
+ int javaFlags = org.exist.xquery.regex.RegexUtil.parseFlags(this, flags);
+ return Pattern.compile(javaPattern, javaFlags).matcher(string).find();
+ } catch (final XPathException | PatternSyntaxException ignored) {
+ // fallback failed, throw original Saxon error
+ }
+ }
switch (e.getErrorCodeLocalPart()) {
case "FORX0001" -> throw new XPathException(this, ErrorCodes.FORX0001, "Invalid regular expression: " + e.getMessage());
case "FORX0002" -> throw new XPathException(this, ErrorCodes.FORX0002, "Invalid regular expression: " + e.getMessage());
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunMax.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunMax.java
index 31fe3ee95b3..41e356645b1 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunMax.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunMax.java
@@ -23,8 +23,8 @@
import com.ibm.icu.text.Collator;
import org.exist.dom.QName;
-import org.exist.util.Collations;
import org.exist.xquery.Cardinality;
+import org.exist.xquery.Constants;
import org.exist.xquery.Dependency;
import org.exist.xquery.ErrorCodes;
import org.exist.xquery.Function;
@@ -33,191 +33,145 @@
import org.exist.xquery.XPathException;
import org.exist.xquery.XQueryContext;
import org.exist.xquery.value.AtomicValue;
-import org.exist.xquery.value.ComputableValue;
-import org.exist.xquery.value.DoubleValue;
import org.exist.xquery.value.DurationValue;
-import org.exist.xquery.value.FloatValue;
import org.exist.xquery.value.FunctionParameterSequenceType;
import org.exist.xquery.value.FunctionReturnSequenceType;
import org.exist.xquery.value.Item;
import org.exist.xquery.value.NumericValue;
-import org.exist.xquery.value.QNameValue;
import org.exist.xquery.value.Sequence;
import org.exist.xquery.value.SequenceIterator;
import org.exist.xquery.value.SequenceType;
import org.exist.xquery.value.Type;
/**
+ * Implementation of fn:max with XQuery 4.0 semantics.
+ * Uses fn:compare-based mutual comparability (XQ4 numeric total order,
+ * duration total order, date/time total order).
+ *
* @author Wolfgang Meier
*/
public class FunMax extends CollatingFunction {
- protected static final String FUNCTION_DESCRIPTION_COMMON_1 =
- "Selects an item from the input sequence $arg whose value is " +
- "greater than or equal to the value of every other item in the " +
- "input sequence. If there are two or more such items, then the " +
- "specific item whose value is returned is implementation dependent.\n\n" +
- "The following rules are applied to the input sequence:\n\n" +
- "- Values of type xs:untypedAtomic in $arg are cast to xs:double.\n" +
- "- Numeric and xs:anyURI values are converted to the least common " +
- "type that supports the 'ge' operator by a combination of type " +
- "promotion and subtype substitution. See Section B.1 Type " +
- "PromotionXP and Section B.2 Operator MappingXP.\n\n" +
- "The items in the resulting sequence may be reordered in an arbitrary " +
- "order. The resulting sequence is referred to below as the converted " +
- "sequence. This function returns an item from the converted sequence " +
- "rather than the input sequence.\n\n" +
- "If the converted sequence is empty, the empty sequence is returned.\n\n" +
- "All items in $arg must be numeric or derived from a single base type " +
- "for which the 'ge' operator is defined. In addition, the values in the " +
- "sequence must have a total order. If date/time values do not have a " +
- "timezone, they are considered to have the implicit timezone provided " +
- "by the dynamic context for purposes of comparison. Duration values " +
- "must either all be xs:yearMonthDuration values or must all be " +
- "xs:dayTimeDuration values.\n\n" +
- "If any of these conditions is not met, then a type error is raised [err:FORG0006].\n\n" +
- "If the converted sequence contains the value NaN, the value NaN is returned.\n\n" +
- "If the items in the value of $arg are of type xs:string or types " +
- "derived by restriction from xs:string, then the determination of " +
- "the item with the largest value is made according to the collation " +
- "that is used.";
- protected static final String FUNCTION_DESCRIPTION_2_PARAM =
- "If the type of the items in $arg is not xs:string " +
- "and $collation-uri is specified, the collation is ignored.\n\n";
- protected static final String FUNCTION_DESCRIPTION_COMMON_2 =
- "The collation used by the invocation of this function is " +
- "determined according to the rules in 7.3.1 Collations.";
-
-
- public final static FunctionSignature[] signatures = {
- new FunctionSignature(
- new QName("max", Function.BUILTIN_FUNCTION_NS),
- FUNCTION_DESCRIPTION_COMMON_1 +
- FUNCTION_DESCRIPTION_COMMON_2,
- new SequenceType[] {
- new FunctionParameterSequenceType("arg", Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_MORE, "The input sequence")
- },
- new FunctionReturnSequenceType(Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_ONE, "the max value")
- ),
- new FunctionSignature(
- new QName("max", Function.BUILTIN_FUNCTION_NS),
- FUNCTION_DESCRIPTION_COMMON_1 + FUNCTION_DESCRIPTION_2_PARAM +
- FUNCTION_DESCRIPTION_COMMON_2,
- new SequenceType[] {
- new FunctionParameterSequenceType("arg", Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_MORE, "The input sequence"),
- new FunctionParameterSequenceType("collation-uri", Type.STRING, Cardinality.EXACTLY_ONE, "The collation URI")
- },
- new FunctionReturnSequenceType(Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_ONE, "the max value")
- )
- };
-
- public FunMax(XQueryContext context, FunctionSignature signature) {
- super(context, signature);
- }
-
- /* (non-Javadoc)
- * @see org.exist.xquery.Expression#eval(org.exist.dom.persistent.DocumentSet, org.exist.xquery.value.Sequence, org.exist.xquery.value.Item)
- */
- public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathException {
+ public final static FunctionSignature[] signatures = {
+ new FunctionSignature(
+ new QName("max", Function.BUILTIN_FUNCTION_NS),
+ "Returns the maximum value from the input sequence, using XQ4 comparison semantics.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("values", Type.ANY_ATOMIC_TYPE,
+ Cardinality.ZERO_OR_MORE, "The input sequence")
+ },
+ new FunctionReturnSequenceType(Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_ONE,
+ "the maximum value")
+ ),
+ new FunctionSignature(
+ new QName("max", Function.BUILTIN_FUNCTION_NS),
+ "Returns the maximum value from the input sequence, using the specified collation.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("values", Type.ANY_ATOMIC_TYPE,
+ Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("collation", Type.STRING,
+ Cardinality.ZERO_OR_ONE, "The collation URI")
+ },
+ new FunctionReturnSequenceType(Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_ONE,
+ "the maximum value")
+ )
+ };
+
+ public FunMax(XQueryContext context, FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathException {
if (context.getProfiler().isEnabled()) {
- context.getProfiler().start(this);
- context.getProfiler().message(this, Profiler.DEPENDENCIES, "DEPENDENCIES", Dependency.getDependenciesName(this.getDependencies()));
+ context.getProfiler().start(this);
+ context.getProfiler().message(this, Profiler.DEPENDENCIES, "DEPENDENCIES",
+ Dependency.getDependenciesName(this.getDependencies()));
if (contextSequence != null)
- {context.getProfiler().message(this, Profiler.START_SEQUENCES, "CONTEXT SEQUENCE", contextSequence);}
+ {context.getProfiler().message(this, Profiler.START_SEQUENCES,
+ "CONTEXT SEQUENCE", contextSequence);}
if (contextItem != null)
- {context.getProfiler().message(this, Profiler.START_SEQUENCES, "CONTEXT ITEM", contextItem.toSequence());}
- }
-
+ {context.getProfiler().message(this, Profiler.START_SEQUENCES,
+ "CONTEXT ITEM", contextItem.toSequence());}
+ }
+
Sequence result;
- final Sequence arg = getArgument(0).eval(contextSequence, contextItem);
- if(arg.isEmpty())
- {result = Sequence.EMPTY_SEQUENCE;}
- else {
- boolean computableProcessing = false;
- //TODO : test if a range index is defined *iff* it is compatible with the collator
- final Collator collator = getCollator(contextSequence, contextItem, 2);
- final SequenceIterator iter = arg.unorderedIterator();
- AtomicValue max = null;
- while (iter.hasNext()) {
- final Item item = iter.nextItem();
-
- if (item instanceof QNameValue)
- {throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(item.getType()), arg);}
-
- AtomicValue value = item.atomize();
-
- //Duration values must either all be xs:yearMonthDuration values or must all be xs:dayTimeDuration values.
- if (Type.subTypeOf(value.getType(), Type.DURATION)) {
- value = ((DurationValue)value).wrap();
- if (value.getType() == Type.YEAR_MONTH_DURATION) {
- if (max != null && max.getType() != Type.YEAR_MONTH_DURATION)
- {throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(max.getType()) +
- " and " + Type.getTypeName(value.getType()), value);}
-
- } else if (value.getType() == Type.DAY_TIME_DURATION) {
- if (max != null && max.getType() != Type.DAY_TIME_DURATION)
- {throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(max.getType()) +
- " and " + Type.getTypeName(value.getType()), value);}
-
- } else
- {throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(value.getType()), value);}
-
- //Any value of type xdt:untypedAtomic is cast to xs:double
- } else if (value.getType() == Type.UNTYPED_ATOMIC)
- {value = value.convertTo(Type.DOUBLE);}
-
- if (max == null)
- {max = value;}
-
- else {
- if (Type.getCommonSuperType(max.getType(), value.getType()) == Type.ANY_ATOMIC_TYPE) {
- throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(max.getType()) +
- " and " + Type.getTypeName(value.getType()), max);
- }
- //Any value of type xdt:untypedAtomic is cast to xs:double
- if (value.getType() == Type.UNTYPED_ATOMIC)
- {value = value.convertTo(Type.DOUBLE);}
-
- //Numeric tests
- if (Type.subTypeOfUnion(value.getType(), Type.NUMERIC)) {
- //Don't mix comparisons
- if (!Type.subTypeOfUnion(max.getType(), Type.NUMERIC))
- {throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(max.getType()) +
- " and " + Type.getTypeName(value.getType()), max);}
- if (((NumericValue) value).isNaN()) {
- //Type NaN correctly
- value = value.promote(max);
- if (value.getType() == Type.FLOAT)
- {max = FloatValue.NaN;}
- else
- {max = DoubleValue.NaN;}
- //although result will be NaN, we need to continue on order to type correctly
- continue;
- } else
- {max = max.promote(value);}
- }
- //Ugly test
- if (max instanceof ComputableValue && value instanceof ComputableValue) {
- //Type value correctly
- value = value.promote(max);
- max = (ComputableValue) max.max(collator, value);
- computableProcessing = true;
- } else {
- if (computableProcessing)
- {throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(max.getType()) +
- " and " + Type.getTypeName(value.getType()), max);}
- if (Collations.compare(collator, value.getStringValue(), max.getStringValue()) > 0)
- {max = value;}
- }
+ final Sequence arg = getArgument(0).eval(contextSequence, contextItem);
+ if (arg.isEmpty()) {
+ result = Sequence.EMPTY_SEQUENCE;
+ } else {
+ final Collator collator = getOptionalCollator(contextSequence, contextItem);
+ result = findMax(arg, collator);
+ }
+
+ if (context.getProfiler().isEnabled())
+ {context.getProfiler().end(this, "", result);}
+
+ return result;
+ }
+
+ /**
+ * Get collator, handling empty sequence for XQ4 optional collation parameter.
+ */
+ private Collator getOptionalCollator(Sequence contextSequence, Item contextItem)
+ throws XPathException {
+ if (getArgumentCount() == 2) {
+ final Sequence collationSeq = getArgument(1).eval(contextSequence, contextItem);
+ if (!collationSeq.isEmpty()) {
+ final String collationURI = collationSeq.getStringValue();
+ return context.getCollator(collationURI, ErrorCodes.FOCH0002);
+ }
+ }
+ return context.getDefaultCollator();
+ }
+
+ private Sequence findMax(Sequence arg, Collator collator) throws XPathException {
+ final SequenceIterator iter = arg.unorderedIterator();
+ AtomicValue max = null;
+ boolean hasNaN = false;
+ AtomicValue nanValue = null;
+
+ while (iter.hasNext()) {
+ final Item item = iter.nextItem();
+ AtomicValue value = item.atomize();
+
+ // Cast untypedAtomic to double
+ if (value.getType() == Type.UNTYPED_ATOMIC) {
+ value = value.convertTo(Type.DOUBLE);
+ }
+
+ // Wrap duration subtypes
+ if (Type.subTypeOf(value.getType(), Type.DURATION)) {
+ value = ((DurationValue) value).wrap();
+ }
+
+ // Track NaN: if any value is NaN, result is NaN
+ if (value instanceof NumericValue && ((NumericValue) value).isNaN()) {
+ if (!hasNaN) {
+ hasNaN = true;
+ nanValue = value;
+ }
+ continue;
+ }
+
+ if (max == null) {
+ max = value;
+ } else {
+ try {
+ final int cmp = FunCompare.compare(value, max, collator);
+ if (cmp > 0) {
+ max = value;
+ }
+ } catch (final XPathException e) {
+ throw new XPathException(this, ErrorCodes.FORG0006,
+ "Cannot compare " + Type.getTypeName(max.getType()) +
+ " and " + Type.getTypeName(value.getType()), value);
}
- }
- result = max;
+ }
}
- if (context.getProfiler().isEnabled())
- {context.getProfiler().end(this, "", result);}
-
- return result;
-
- }
+ if (hasNaN) {
+ return nanValue;
+ }
+ return max;
+ }
}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunMin.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunMin.java
index c98ce39133a..10d58c59c4d 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunMin.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunMin.java
@@ -23,8 +23,8 @@
import com.ibm.icu.text.Collator;
import org.exist.dom.QName;
-import org.exist.util.Collations;
import org.exist.xquery.Cardinality;
+import org.exist.xquery.Constants;
import org.exist.xquery.Dependency;
import org.exist.xquery.ErrorCodes;
import org.exist.xquery.Function;
@@ -33,193 +33,145 @@
import org.exist.xquery.XPathException;
import org.exist.xquery.XQueryContext;
import org.exist.xquery.value.AtomicValue;
-import org.exist.xquery.value.ComputableValue;
-import org.exist.xquery.value.DoubleValue;
import org.exist.xquery.value.DurationValue;
-import org.exist.xquery.value.FloatValue;
import org.exist.xquery.value.FunctionParameterSequenceType;
import org.exist.xquery.value.FunctionReturnSequenceType;
import org.exist.xquery.value.Item;
import org.exist.xquery.value.NumericValue;
-import org.exist.xquery.value.QNameValue;
import org.exist.xquery.value.Sequence;
import org.exist.xquery.value.SequenceIterator;
import org.exist.xquery.value.SequenceType;
import org.exist.xquery.value.Type;
/**
+ * Implementation of fn:min with XQuery 4.0 semantics.
+ * Uses fn:compare-based mutual comparability (XQ4 numeric total order,
+ * duration total order, date/time total order).
+ *
* @author Wolfgang Meier
*/
public class FunMin extends CollatingFunction {
- protected static final String FUNCTION_DESCRIPTION_COMMON_1 =
-
- "Selects an item from the input sequence $arg whose value is " +
- "less than or equal to the value of every other item in the " +
- "input sequence. If there are two or more such items, then " +
- "the specific item whose value is returned is implementation dependent.\n\n" +
- "The following rules are applied to the input sequence:\n\n" +
- "- Values of type xs:untypedAtomic in $arg are cast to xs:double.\n" +
- "- Numeric and xs:anyURI values are converted to the least common " +
- "type that supports the 'le' operator by a combination of type promotion " +
- "and subtype substitution. See Section B.1 Type PromotionXP and " +
- "Section B.2 Operator MappingXP.\n\n" +
-
- "The items in the resulting sequence may be reordered in an arbitrary " +
- "order. The resulting sequence is referred to below as the converted " +
- "sequence. This function returns an item from the converted sequence " +
- "rather than the input sequence.\n\n" +
-
- "If the converted sequence is empty, the empty sequence is returned.\n\n" +
-
- "All items in $arg must be numeric or derived from a single base type " +
- "for which the 'le' operator is defined. In addition, the values in the " +
- "sequence must have a total order. If date/time values do not have a " +
- "timezone, they are considered to have the implicit timezone provided " +
- "by the dynamic context for the purpose of comparison. Duration values " +
- "must either all be xs:yearMonthDuration values or must all be " +
- "xs:dayTimeDuration values.\n\n" +
-
- "If any of these conditions is not met, a type error is raised [err:FORG0006].\n\n" +
-
- "If the converted sequence contains the value NaN, the value NaN is returned.\n\n" +
-
- "If the items in the value of $arg are of type xs:string or types derived " +
- "by restriction from xs:string, then the determination of the item with " +
- "the smallest value is made according to the collation that is used. ";
- protected static final String FUNCTION_DESCRIPTION_2_PARAM =
- "If the type of the items in $arg is not xs:string and $collation is " +
- "specified, the collation is ignored.\n\n";
- protected static final String FUNCTION_DESCRIPTION_COMMON_2 =
- "The collation used by the invocation of this function is determined " +
- "according to the rules in 7.3.1 Collations.";
-
- public final static FunctionSignature[] signatures = {
- new FunctionSignature(
- new QName("min", Function.BUILTIN_FUNCTION_NS),
- FUNCTION_DESCRIPTION_COMMON_1 +
- FUNCTION_DESCRIPTION_COMMON_2,
- new SequenceType[] { new FunctionParameterSequenceType("arg", Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_MORE, "The input sequence")},
- new FunctionReturnSequenceType(Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_ONE, "the minimum value")
- ),
- new FunctionSignature(
- new QName("min", Function.BUILTIN_FUNCTION_NS),
- FUNCTION_DESCRIPTION_COMMON_1 + FUNCTION_DESCRIPTION_2_PARAM +
- FUNCTION_DESCRIPTION_COMMON_2,
- new SequenceType[] {
- new FunctionParameterSequenceType("arg", Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_MORE, "The input sequence"),
- new FunctionParameterSequenceType("collation-uri", Type.STRING, Cardinality.EXACTLY_ONE, "The collation URI")
- },
- new FunctionReturnSequenceType(Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_ONE, "the minimum value")
- )
- };
-
- public FunMin(XQueryContext context, FunctionSignature signature) {
- super(context, signature);
- }
-
- /* (non-Javadoc)
- * @see org.exist.xquery.Expression#eval(org.exist.dom.persistent.DocumentSet, org.exist.xquery.value.Sequence, org.exist.xquery.value.Item)
- */
- public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathException {
+ public final static FunctionSignature[] signatures = {
+ new FunctionSignature(
+ new QName("min", Function.BUILTIN_FUNCTION_NS),
+ "Returns the minimum value from the input sequence, using XQ4 comparison semantics.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("values", Type.ANY_ATOMIC_TYPE,
+ Cardinality.ZERO_OR_MORE, "The input sequence")
+ },
+ new FunctionReturnSequenceType(Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_ONE,
+ "the minimum value")
+ ),
+ new FunctionSignature(
+ new QName("min", Function.BUILTIN_FUNCTION_NS),
+ "Returns the minimum value from the input sequence, using the specified collation.",
+ new SequenceType[] {
+ new FunctionParameterSequenceType("values", Type.ANY_ATOMIC_TYPE,
+ Cardinality.ZERO_OR_MORE, "The input sequence"),
+ new FunctionParameterSequenceType("collation", Type.STRING,
+ Cardinality.ZERO_OR_ONE, "The collation URI")
+ },
+ new FunctionReturnSequenceType(Type.ANY_ATOMIC_TYPE, Cardinality.ZERO_OR_ONE,
+ "the minimum value")
+ )
+ };
+
+ public FunMin(XQueryContext context, FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathException {
if (context.getProfiler().isEnabled()) {
- context.getProfiler().start(this);
- context.getProfiler().message(this, Profiler.DEPENDENCIES, "DEPENDENCIES", Dependency.getDependenciesName(this.getDependencies()));
+ context.getProfiler().start(this);
+ context.getProfiler().message(this, Profiler.DEPENDENCIES, "DEPENDENCIES",
+ Dependency.getDependenciesName(this.getDependencies()));
if (contextSequence != null)
- {context.getProfiler().message(this, Profiler.START_SEQUENCES, "CONTEXT SEQUENCE", contextSequence);}
+ {context.getProfiler().message(this, Profiler.START_SEQUENCES,
+ "CONTEXT SEQUENCE", contextSequence);}
if (contextItem != null)
- {context.getProfiler().message(this, Profiler.START_SEQUENCES, "CONTEXT ITEM", contextItem.toSequence());}
+ {context.getProfiler().message(this, Profiler.START_SEQUENCES,
+ "CONTEXT ITEM", contextItem.toSequence());}
}
-
- boolean computableProcessing = false;
+
Sequence result;
- final Sequence arg = getArgument(0).eval(contextSequence, contextItem);
- if (arg.isEmpty())
- {result = Sequence.EMPTY_SEQUENCE;}
- else {
- //TODO : test if a range index is defined *iff* it is compatible with the collator
- final Collator collator = getCollator(contextSequence, contextItem, 2);
- final SequenceIterator iter = arg.unorderedIterator();
- AtomicValue min = null;
- while (iter.hasNext()) {
- final Item item = iter.nextItem();
- if (item instanceof QNameValue)
- {throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(item.getType()), arg);}
- AtomicValue value = item.atomize();
-
- //Duration values must either all be xs:yearMonthDuration values or must all be xs:dayTimeDuration values.
- if (Type.subTypeOf(value.getType(), Type.DURATION)) {
- value = ((DurationValue)value).wrap();
- if (value.getType() == Type.YEAR_MONTH_DURATION) {
- if (min != null && min.getType() != Type.YEAR_MONTH_DURATION)
- {throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(min.getType()) +
- " and " + Type.getTypeName(value.getType()), value);}
-
- } else if (value.getType() == Type.DAY_TIME_DURATION) {
- if (min != null && min.getType() != Type.DAY_TIME_DURATION)
- {throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(min.getType()) +
- " and " + Type.getTypeName(value.getType()), value);}
-
- } else
- {throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(value.getType()), value);}
-
- //Any value of type xdt:untypedAtomic is cast to xs:double
- } else if (value.getType() == Type.UNTYPED_ATOMIC)
- {value = value.convertTo(Type.DOUBLE);}
-
- if (min == null)
- {min = value;}
- else {
- if (Type.getCommonSuperType(min.getType(), value.getType()) == Type.ANY_ATOMIC_TYPE) {
- throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(min.getType()) +
- " and " + Type.getTypeName(value.getType()), value);
- }
- //Any value of type xdt:untypedAtomic is cast to xs:double
- if (value.getType() == Type.ANY_ATOMIC_TYPE)
- {value = value.convertTo(Type.DOUBLE);}
- //Numeric tests
- if (Type.subTypeOfUnion(value.getType(), Type.NUMERIC)) {
- //Don't mix comparisons
- if (!Type.subTypeOfUnion(min.getType(), Type.NUMERIC))
- {throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(min.getType()) +
- " and " + Type.getTypeName(value.getType()), min);}
- if (((NumericValue) value).isNaN()) {
- //Type NaN correctly
- value = value.promote(min);
- if (value.getType() == Type.FLOAT)
- {min = FloatValue.NaN;}
- else
- {min = DoubleValue.NaN;}
- //although result will be NaN, we need to continue on order to type correctly
- continue;
- }
- min = min.promote(value);
- }
- //Ugly test
- if (value instanceof ComputableValue) {
- if (!(min instanceof ComputableValue))
- {throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(min.getType()) +
- " and " + Type.getTypeName(value.getType()), min);}
- //Type value correctly
- value = value.promote(min);
- min = min.min(collator, value);
- computableProcessing = true;
- } else {
- if (computableProcessing)
- {throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(min.getType()) +
- " and " + Type.getTypeName(value.getType()), value);}
- if (Collations.compare(collator, value.getStringValue(), min.getStringValue()) < 0)
- {min = value;}
- }
- }
- }
- result = min;
+ final Sequence arg = getArgument(0).eval(contextSequence, contextItem);
+ if (arg.isEmpty()) {
+ result = Sequence.EMPTY_SEQUENCE;
+ } else {
+ final Collator collator = getOptionalCollator(contextSequence, contextItem);
+ result = findMin(arg, collator);
+ }
+
+ if (context.getProfiler().isEnabled())
+ {context.getProfiler().end(this, "", result);}
+
+ return result;
+ }
+
+ /**
+ * Get collator, handling empty sequence for XQ4 optional collation parameter.
+ */
+ private Collator getOptionalCollator(Sequence contextSequence, Item contextItem)
+ throws XPathException {
+ if (getArgumentCount() == 2) {
+ final Sequence collationSeq = getArgument(1).eval(contextSequence, contextItem);
+ if (!collationSeq.isEmpty()) {
+ final String collationURI = collationSeq.getStringValue();
+ return context.getCollator(collationURI, ErrorCodes.FOCH0002);
+ }
}
-
- if (context.getProfiler().isEnabled())
- {context.getProfiler().end(this, "", result);}
-
- return result;
+ return context.getDefaultCollator();
}
+ private Sequence findMin(Sequence arg, Collator collator) throws XPathException {
+ final SequenceIterator iter = arg.unorderedIterator();
+ AtomicValue min = null;
+ boolean hasNaN = false;
+ AtomicValue nanValue = null;
+
+ while (iter.hasNext()) {
+ final Item item = iter.nextItem();
+ AtomicValue value = item.atomize();
+
+ // Cast untypedAtomic to double
+ if (value.getType() == Type.UNTYPED_ATOMIC) {
+ value = value.convertTo(Type.DOUBLE);
+ }
+
+ // Wrap duration subtypes
+ if (Type.subTypeOf(value.getType(), Type.DURATION)) {
+ value = ((DurationValue) value).wrap();
+ }
+
+ // Track NaN: if any value is NaN, result is NaN
+ if (value instanceof NumericValue && ((NumericValue) value).isNaN()) {
+ if (!hasNaN) {
+ hasNaN = true;
+ nanValue = value;
+ }
+ continue;
+ }
+
+ if (min == null) {
+ min = value;
+ } else {
+ try {
+ final int cmp = FunCompare.compare(value, min, collator);
+ if (cmp < 0) {
+ min = value;
+ }
+ } catch (final XPathException e) {
+ throw new XPathException(this, ErrorCodes.FORG0006,
+ "Cannot compare " + Type.getTypeName(min.getType()) +
+ " and " + Type.getTypeName(value.getType()), value);
+ }
+ }
+ }
+
+ if (hasNaN) {
+ return nanValue;
+ }
+ return min;
+ }
}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunPath.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunPath.java
index 5d05e48535e..8544c8230ff 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunPath.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunPath.java
@@ -48,7 +48,11 @@ public class FunPath extends BasicFunction {
public static final FunctionSignature[] FS_PATH_SIGNATURES = {
functionSignature(FunPath.FN_PATH_NAME, FunPath.FN_PATH_DESCRIPTION, FunPath.FN_PATH_RETURN),
functionSignature(FunPath.FN_PATH_NAME, FunPath.FN_PATH_DESCRIPTION, FunPath.FN_PATH_RETURN,
- new FunctionParameterSequenceType("node", Type.NODE, Cardinality.ZERO_OR_ONE, "The node for which to calculate a path expression"))
+ new FunctionParameterSequenceType("node", Type.NODE, Cardinality.ZERO_OR_ONE, "The node for which to calculate a path expression")),
+ // XQuery 4.0: fn:path with options map
+ functionSignature(FunPath.FN_PATH_NAME, FunPath.FN_PATH_DESCRIPTION, FunPath.FN_PATH_RETURN,
+ new FunctionParameterSequenceType("node", Type.NODE, Cardinality.ZERO_OR_ONE, "The node for which to calculate a path expression"),
+ new FunctionParameterSequenceType("options", Type.MAP_ITEM, Cardinality.EXACTLY_ONE, "Options map (e.g., format)"))
};
public FunPath(final XQueryContext context, final FunctionSignature signature) {
@@ -66,6 +70,8 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro
sequence = Objects.requireNonNullElse(contextSequence, Sequence.EMPTY_SEQUENCE);
} else {
sequence = args[0];
+ // XQuery 4.0: 2-arg fn:path($node, $options) — options map accepted
+ // but currently only the default EQName format is supported
}
if (sequence.isEmpty()) {
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunReplace.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunReplace.java
index 6dea523469a..c70b2959a12 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunReplace.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunReplace.java
@@ -23,6 +23,8 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import net.sf.saxon.Configuration;
import net.sf.saxon.functions.Replace;
@@ -30,9 +32,12 @@
import org.exist.dom.QName;
import org.exist.xquery.*;
import org.exist.xquery.value.FunctionParameterSequenceType;
+import org.exist.xquery.value.FunctionReference;
+import org.exist.xquery.value.Item;
import org.exist.xquery.value.Sequence;
import org.exist.xquery.value.StringValue;
import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
import static org.exist.xquery.FunctionDSL.*;
import static org.exist.xquery.regex.RegexUtil.*;
@@ -72,7 +77,9 @@ public class FunReplace extends BasicFunction {
private static final FunctionParameterSequenceType FS_TOKENIZE_PARAM_INPUT = optParam("input", Type.STRING, "The input string");
private static final FunctionParameterSequenceType FS_TOKENIZE_PARAM_PATTERN = param("pattern", Type.STRING, "The pattern to match");
- private static final FunctionParameterSequenceType FS_TOKENIZE_PARAM_REPLACEMENT = param("replacement", Type.STRING, "The string to replace the pattern with");
+ private static final FunctionParameterSequenceType FS_TOKENIZE_PARAM_REPLACEMENT =
+ new FunctionParameterSequenceType("replacement", Type.ITEM, Cardinality.ZERO_OR_ONE,
+ "The replacement string, function, or empty sequence");
static final FunctionSignature [] FS_REPLACE = functionSignatures(
FS_REPLACE_NAME,
@@ -88,7 +95,7 @@ public class FunReplace extends BasicFunction {
FS_TOKENIZE_PARAM_INPUT,
FS_TOKENIZE_PARAM_PATTERN,
FS_TOKENIZE_PARAM_REPLACEMENT,
- param("flags", Type.STRING, Cardinality.EXACTLY_ONE, "The flags")
+ param("flags", Type.STRING, Cardinality.ZERO_OR_ONE, "The flags")
)
)
);
@@ -104,36 +111,71 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro
if (stringArg.isEmpty()) {
result = StringValue.EMPTY_STRING;
} else {
- final String flags;
- if (args.length == 4) {
+ String flags;
+ if (args.length == 4 && !args[3].isEmpty()) {
flags = args[3].itemAt(0).getStringValue();
} else {
flags = "";
}
+
+ // XQ4: 'c' flag — strip regex comments (#...#) before compilation
+ // When 'q' (literal) flag is present, 'c' is ignored
+ final boolean hasCommentFlag = flags.indexOf('c') >= 0 && flags.indexOf('q') < 0;
+ if (flags.indexOf('c') >= 0) {
+ flags = flags.replace("c", "");
+ }
+
final String string = stringArg.getStringValue();
- final String pattern = args[1].itemAt(0).getStringValue();
- final String replace = args[2].itemAt(0).getStringValue();
+ String pattern = args[1].itemAt(0).getStringValue();
- final Configuration config = context.getBroker().getBrokerPool().getSaxonConfiguration();
+ if (hasCommentFlag) {
+ pattern = stripRegexComments(pattern);
+ }
+ // XQ4: 3rd arg can be empty sequence (treated as empty string) or a function
+ final Sequence replacementArg = args[2];
+ final boolean isFunctionReplacement = !replacementArg.isEmpty()
+ && Type.subTypeOf(replacementArg.itemAt(0).getType(), Type.FUNCTION);
+ final String replace;
+ if (isFunctionReplacement) {
+ replace = null; // handled below
+ } else if (replacementArg.isEmpty()) {
+ replace = "";
+ } else {
+ replace = replacementArg.itemAt(0).getStringValue();
+ }
+
+ final Configuration config = context.getBroker().getBrokerPool().getSaxonConfiguration();
final List warnings = new ArrayList<>(1);
try {
final RegularExpression regularExpression = config.compileRegularExpression(pattern, flags, "XP30", warnings);
- if (regularExpression.matches("")) {
+ final boolean canMatchEmpty = regularExpression.matches("");
+ final boolean allowEmptyMatch = flags.contains("!");
+
+ // XQ 3.1: FORX0003 if regex can match empty string
+ // XQ 4.0: empty-matching regex allowed only with the ! flag
+ if (canMatchEmpty && (context.getXQueryVersion() < 40 || !allowEmptyMatch) && !isFunctionReplacement) {
throw new XPathException(this, ErrorCodes.FORX0003, "regular expression could match empty string");
}
- //TODO(AR) cache the regular expression... might be possible through Saxon config
-
- if (!hasLiteral(flags)) {
- final String msg = Replace.checkReplacement(replace);
- if (msg != null) {
- throw new XPathException(this, ErrorCodes.FORX0004, msg);
+ if (isFunctionReplacement) {
+ result = evalFunctionReplacement(string, pattern, flags,
+ (FunctionReference) replacementArg.itemAt(0));
+ } else if (canMatchEmpty && allowEmptyMatch) {
+ // XQ4: empty-matching regex allowed with ! flag — use Java regex fallback
+ // since Saxon's replace() doesn't handle empty matches well
+ result = evalEmptyMatchReplace(string, pattern, replace, flags);
+ } else {
+ if (!hasLiteral(flags)) {
+ final String msg = Replace.checkReplacement(replace);
+ if (msg != null) {
+ throw new XPathException(this, ErrorCodes.FORX0004, msg);
+ }
}
+ final CharSequence res = regularExpression.replace(string, replace);
+ result = new StringValue(this, res.toString());
}
- final CharSequence res = regularExpression.replace(string, replace);
- result = new StringValue(this, res.toString());
} catch (final net.sf.saxon.trans.XPathException e) {
switch (e.getErrorCodeLocalPart()) {
@@ -145,7 +187,183 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro
}
}
}
-
+
return result;
}
+
+ /**
+ * XQ4: Handle replacement when the regex can match the empty string.
+ * Uses Java regex with XPath-to-Java translation for proper empty-match handling.
+ */
+ private Sequence evalEmptyMatchReplace(final String input, final String pattern,
+ final String replace, final String flags) throws XPathException {
+ final String javaPattern = translateRegexp(
+ this, pattern, hasIgnoreWhitespace(flags), hasCaseInsensitive(flags));
+ final int javaFlags = parseFlags(this, flags);
+ final Pattern compiled = Pattern.compile(javaPattern, javaFlags);
+ final Matcher matcher = compiled.matcher(input);
+
+ final StringBuilder sb = new StringBuilder();
+ int lastEnd = 0;
+ while (matcher.find()) {
+ sb.append(input, lastEnd, matcher.start());
+
+ // Apply XPath-style replacement ($0, $1, etc.)
+ sb.append(applyXPathReplacement(replace, matcher));
+
+ lastEnd = matcher.end();
+
+ // Advance past empty match to prevent infinite loop
+ if (matcher.start() == matcher.end()) {
+ if (lastEnd < input.length()) {
+ sb.append(input.charAt(lastEnd));
+ lastEnd++;
+ matcher.region(lastEnd, input.length());
+ } else {
+ break;
+ }
+ }
+ }
+ sb.append(input, lastEnd, input.length());
+ return new StringValue(this, sb.toString());
+ }
+
+ /**
+ * Apply XPath-style replacement string ($0, $1, etc.) using a Java Matcher.
+ */
+ private static String applyXPathReplacement(final String replacement, final Matcher matcher) {
+ final StringBuilder result = new StringBuilder();
+ for (int i = 0; i < replacement.length(); i++) {
+ final char ch = replacement.charAt(i);
+ if (ch == '$' && i + 1 < replacement.length()) {
+ i++;
+ int groupNum = 0;
+ boolean hasDigit = false;
+ while (i < replacement.length() && Character.isDigit(replacement.charAt(i))) {
+ groupNum = groupNum * 10 + (replacement.charAt(i) - '0');
+ hasDigit = true;
+ i++;
+ }
+ i--; // back up one
+ if (hasDigit && groupNum <= matcher.groupCount()) {
+ final String g = matcher.group(groupNum);
+ if (g != null) {
+ result.append(g);
+ }
+ } else if (hasDigit) {
+ // Group doesn't exist, output empty
+ }
+ } else if (ch == '\\' && i + 1 < replacement.length()) {
+ i++;
+ result.append(replacement.charAt(i));
+ } else {
+ result.append(ch);
+ }
+ }
+ return result.toString();
+ }
+
+ /**
+ * XQ4: Evaluate fn:replace with a function replacement parameter.
+ * The function receives (match, groups*) and returns the replacement string.
+ */
+ private Sequence evalFunctionReplacement(final String input, final String pattern,
+ final String flags, final FunctionReference func) throws XPathException {
+ // Use Java regex for function replacement since Saxon's replace() only accepts strings
+ final String javaPattern = translateRegexp(
+ this, pattern, hasIgnoreWhitespace(flags), hasCaseInsensitive(flags));
+ int javaFlags = parseFlags(this, flags);
+ final Pattern compiled = Pattern.compile(javaPattern, javaFlags);
+ final Matcher matcher = compiled.matcher(input);
+
+ final StringBuilder sb = new StringBuilder();
+ int lastEnd = 0;
+ while (matcher.find()) {
+ sb.append(input, lastEnd, matcher.start());
+
+ // Build arguments: (match, group1, group2, ...)
+ final int groupCount = matcher.groupCount();
+ final Sequence[] funcArgs = new Sequence[2];
+ funcArgs[0] = new StringValue(this, matcher.group());
+ final ValueSequence groups = new ValueSequence(groupCount);
+ for (int i = 1; i <= groupCount; i++) {
+ final String g = matcher.group(i);
+ groups.add(g != null ? new StringValue(this, g) : StringValue.EMPTY_STRING);
+ }
+ funcArgs[1] = groups;
+
+ final Sequence replacement = func.evalFunction(null, null, funcArgs);
+ if (!replacement.isEmpty()) {
+ sb.append(replacement.getStringValue());
+ }
+
+ lastEnd = matcher.end();
+
+ // Prevent infinite loop on empty match
+ if (matcher.start() == matcher.end()) {
+ if (lastEnd < input.length()) {
+ sb.append(input.charAt(lastEnd));
+ lastEnd++;
+ // Reset matcher position
+ matcher.region(lastEnd, input.length());
+ } else {
+ break;
+ }
+ }
+ }
+ sb.append(input, lastEnd, input.length());
+ return new StringValue(this, sb.toString());
+ }
+
+ /**
+ * XQ4: Strip regex comments (c flag).
+ * Removes text between # markers: #comment# becomes empty.
+ * A # at end of pattern (no closing #) is treated as end-of-line comment.
+ * Escaped \# is preserved.
+ */
+ static String stripRegexComments(final String pattern) {
+ final StringBuilder result = new StringBuilder(pattern.length());
+ boolean inComment = false;
+ boolean inCharClass = false;
+ for (int i = 0; i < pattern.length(); i++) {
+ final char ch = pattern.charAt(i);
+ if (ch == '\\' && i + 1 < pattern.length()) {
+ final char next = pattern.charAt(i + 1);
+ if (!inComment) {
+ if (next == '#') {
+ // \# in c-flag mode is a literal # — output just #
+ result.append('#');
+ } else {
+ result.append(ch);
+ result.append(next);
+ }
+ }
+ i++; // skip escaped character
+ } else if (inCharClass) {
+ // Inside [...] character class, # is literal
+ if (ch == ']') {
+ inCharClass = false;
+ }
+ if (!inComment) {
+ result.append(ch);
+ }
+ } else if (ch == '[' && !inComment) {
+ inCharClass = true;
+ result.append(ch);
+ } else if (ch == '#' && !inCharClass) {
+ inComment = !inComment;
+ } else if (!inComment) {
+ result.append(ch);
+ }
+ }
+ return result.toString();
+ }
+
+ private static boolean hasCaseInsensitive(final String flags) {
+ return flags.contains("i");
+ }
+
+ private static boolean hasIgnoreWhitespace(final String flags) {
+ return flags.contains("x");
+ }
}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunRound.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunRound.java
index 4ad0bb8cf7b..2b4286f5c15 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunRound.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunRound.java
@@ -68,7 +68,11 @@ public class FunRound extends FunRoundBase {
optParam("arg", Type.NUMERIC, "The input number")),
functionSignature(FN_NAME, FunRound.description, FunRound.returnType,
optParam("arg", Type.NUMERIC, "The input number"),
- optParam("precision", Type.INTEGER, "The input number"))
+ optParam("precision", Type.INTEGER, "The precision")),
+ functionSignature(FN_NAME, FunRound.description, FunRound.returnType,
+ optParam("arg", Type.NUMERIC, "The input number"),
+ optParam("precision", Type.INTEGER, "The precision"),
+ optParam("mode", Type.STRING, "The rounding mode"))
};
public FunRound(final XQueryContext context, final FunctionSignature signature) {
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunRoundBase.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunRoundBase.java
index ba7b1050a3b..85080cd0e17 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunRoundBase.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunRoundBase.java
@@ -25,6 +25,7 @@
import org.exist.xquery.value.*;
import java.math.RoundingMode;
+import java.util.Map;
import java.util.Objects;
/**
@@ -45,6 +46,18 @@ public int returnsType() {
abstract protected RoundingMode getFunctionRoundingMode(NumericValue value);
+ private static final Map ROUNDING_MODE_MAP = Map.of(
+ "floor", "FLOOR",
+ "ceiling", "CEILING",
+ "toward-zero", "DOWN",
+ "away-from-zero", "UP",
+ "half-to-floor", "HALF_FLOOR",
+ "half-to-ceiling", "HALF_CEILING",
+ "half-toward-zero", "HALF_DOWN",
+ "half-away-from-zero", "HALF_UP",
+ "half-to-even", "HALF_EVEN"
+ );
+
@Override
public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
@@ -60,9 +73,15 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro
value = (NumericValue) item.convertTo(Type.NUMERIC);
}
- final RoundingMode roundingMode = getFunctionRoundingMode(value);
+ // Determine rounding mode: 3-arg form overrides the function default
+ final RoundingMode roundingMode;
+ if (args.length > 2 && !args[2].isEmpty()) {
+ roundingMode = parseRoundingMode(args[2].getStringValue(), value);
+ } else {
+ roundingMode = getFunctionRoundingMode(value);
+ }
- if (args.length > 1) {
+ if (args.length > 1 && !args[1].isEmpty()) {
final Item precisionItem = args[1].itemAt(0);
if (precisionItem instanceof IntegerValue precision) {
return convertValue(precision, value, roundingMode, this);
@@ -72,6 +91,27 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro
return convertValue(IntegerValue.ZERO, value, roundingMode, this);
}
+ private RoundingMode parseRoundingMode(final String mode, final NumericValue value) throws XPathException {
+ // XQ4 rounding modes that map directly to Java RoundingMode
+ switch (mode) {
+ case "floor": return RoundingMode.FLOOR;
+ case "ceiling": return RoundingMode.CEILING;
+ case "toward-zero": return RoundingMode.DOWN;
+ case "away-from-zero": return RoundingMode.UP;
+ case "half-to-even": return RoundingMode.HALF_EVEN;
+ case "half-away-from-zero": return RoundingMode.HALF_UP;
+ case "half-toward-zero": return RoundingMode.HALF_DOWN;
+ // half-to-floor and half-to-ceiling need special handling based on sign
+ case "half-to-floor":
+ return value.isNegative() ? RoundingMode.HALF_UP : RoundingMode.HALF_DOWN;
+ case "half-to-ceiling":
+ return value.isNegative() ? RoundingMode.HALF_DOWN : RoundingMode.HALF_UP;
+ default:
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Unknown rounding mode: '" + mode + "'");
+ }
+ }
+
/**
* Apply necessary conversions to/from decimal to perform rounding in decimal
*
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunTokenize.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunTokenize.java
index f31b8b645f0..41e245e44cc 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunTokenize.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunTokenize.java
@@ -45,7 +45,8 @@ public class FunTokenize extends BasicFunction {
private static final QName FS_TOKENIZE_NAME = new QName("tokenize", Function.BUILTIN_FUNCTION_NS);
private final static FunctionParameterSequenceType FS_TOKENIZE_PARAM_INPUT = optParam("input", Type.STRING, "The input string");
- private final static FunctionParameterSequenceType FS_TOKENIZE_PARAM_PATTERN = param("pattern", Type.STRING, "The tokenization pattern");
+ private final static FunctionParameterSequenceType FS_TOKENIZE_PARAM_PATTERN =
+ new FunctionParameterSequenceType("pattern", Type.STRING, Cardinality.ZERO_OR_ONE, "The tokenization pattern");
public final static FunctionSignature[] FS_TOKENIZE = functionSignatures(
FS_TOKENIZE_NAME,
@@ -62,7 +63,7 @@ public class FunTokenize extends BasicFunction {
arity(
FS_TOKENIZE_PARAM_INPUT,
FS_TOKENIZE_PARAM_PATTERN,
- param("flags", Type.STRING,"The flags")
+ new FunctionParameterSequenceType("flags", Type.STRING, Cardinality.ZERO_OR_ONE, "The flags")
)
)
);
@@ -82,47 +83,112 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro
if (string.isEmpty()) {
result = Sequence.EMPTY_SEQUENCE;
} else {
- final int flags;
- if (args.length == 3) {
- flags = parseFlags(this, args[2].itemAt(0).getStringValue());
- } else {
- flags = 0;
- }
+ // XQ4: pattern can be empty sequence — treat as 1-arg whitespace form
+ final boolean useWhitespaceTokenization = args.length == 1
+ || (args.length >= 2 && args[1].isEmpty());
- final String pattern;
- if (args.length == 1) {
- pattern = " ";
+ if (useWhitespaceTokenization) {
string = FunNormalizeSpace.normalize(string);
+ if (string.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+ final String[] tokens = string.split(" ");
+ result = new ValueSequence();
+ for (final String token : tokens) {
+ result.add(new StringValue(this, token));
+ }
} else {
- if(hasLiteral(flags)) {
- // no need to change anything
- pattern = args[1].itemAt(0).getStringValue();
+ // XQ4: flags can be empty sequence
+ String flagsStr = "";
+ if (args.length == 3 && !args[2].isEmpty()) {
+ flagsStr = args[2].itemAt(0).getStringValue();
+ }
+
+ // XQ4: 'c' flag — strip regex comments
+ final boolean hasCommentFlag = flagsStr.indexOf('c') >= 0 && flagsStr.indexOf('q') < 0;
+ if (flagsStr.indexOf('c') >= 0) {
+ flagsStr = flagsStr.replace("c", "");
+ }
+ final int flags = parseFlags(this, flagsStr);
+
+ String rawPattern = args[1].itemAt(0).getStringValue();
+ if (hasCommentFlag) {
+ rawPattern = FunReplace.stripRegexComments(rawPattern);
+ }
+ final String pattern;
+ if (hasLiteral(flags)) {
+ pattern = rawPattern;
} else {
final boolean ignoreWhitespace = hasIgnoreWhitespace(flags);
final boolean caseBlind = hasCaseInsensitive(flags);
- pattern = translateRegexp(this, args[1].itemAt(0).getStringValue(), ignoreWhitespace, caseBlind);
+ pattern = translateRegexp(this, rawPattern, ignoreWhitespace, caseBlind);
}
- }
- try {
- final Pattern pat = PatternFactory.getInstance().getPattern(pattern, flags);
- if (pat.matcher("").matches()) {
- throw new XPathException(this, ErrorCodes.FORX0003, "regular expression could match empty string");
+ try {
+ final Pattern pat = PatternFactory.getInstance().getPattern(pattern, flags);
+
+ if (pat.matcher("").matches()) {
+ // XQ4 with ! flag: empty-matching allowed — tokenize between each character
+ // XQ 3.1 or XQ4 without ! flag: FORX0003
+ if (context.getXQueryVersion() >= 40 && flagsStr.contains("!")) {
+ result = tokenizeEmptyMatch(string, pat);
+ } else {
+ throw new XPathException(this, ErrorCodes.FORX0003, "regular expression could match empty string");
+ }
+ } else {
+ final String[] tokens = pat.split(string, -1);
+ result = new ValueSequence();
+ for (final String token : tokens) {
+ result.add(new StringValue(this, token));
+ }
+ }
+
+ } catch (final PatternSyntaxException e) {
+ throw new XPathException(this, ErrorCodes.FORX0001, "Invalid regular expression: " + e.getMessage(), new StringValue(this, pattern), e);
}
+ }
+ }
+ }
- final String[] tokens = pat.split(string, -1);
- result = new ValueSequence();
+ return result;
+ }
- for (final String token : tokens) {
- result.add(new StringValue(this, token));
- }
+ /**
+ * XQ4: Handle tokenization when the regex matches the empty string.
+ * Per spec: zero-length matches at start/end of string do not produce
+ * leading/trailing empty tokens. Empty matches advance past one character.
+ */
+ private Sequence tokenizeEmptyMatch(final String input, final Pattern pat) throws XPathException {
+ final ValueSequence result = new ValueSequence();
+ final java.util.regex.Matcher matcher = pat.matcher(input);
+ int lastEnd = 0;
+ while (matcher.find()) {
+ final boolean isEmpty = matcher.start() == matcher.end();
+
+ // Skip zero-length match at end of string
+ if (isEmpty && matcher.start() >= input.length()) {
+ break;
+ }
+
+ // Add token: text from end of last match to start of this match
+ result.add(new StringValue(this, input.substring(lastEnd, matcher.start())));
+ lastEnd = matcher.end();
- } catch (final PatternSyntaxException e) {
- throw new XPathException(this, ErrorCodes.FORX0001, "Invalid regular expression: " + e.getMessage(), new StringValue(this, pattern), e);
+ // For empty match, advance matcher past one character to prevent infinite loop.
+ // The skipped character becomes part of the next token (not consumed).
+ if (isEmpty) {
+ final int nextPos = lastEnd + 1;
+ if (nextPos <= input.length()) {
+ matcher.region(nextPos, input.length());
+ } else {
+ break;
}
}
}
-
+ // Add trailing token
+ if (lastEnd <= input.length()) {
+ result.add(new StringValue(this, input.substring(lastEnd)));
+ }
return result;
}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunUnparsedText.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunUnparsedText.java
index e4e134e9919..e1062936021 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunUnparsedText.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunUnparsedText.java
@@ -37,6 +37,7 @@
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
+import java.nio.charset.UnsupportedCharsetException;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.exist.xquery.FunctionDSL.*;
@@ -87,7 +88,7 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
} else if (isCalledAs("unparsed-text-available")) {
return BooleanValue.valueOf(contentAvailable(href, encoding));
} else {
- return new StringValue(this, readContent(href, encoding));
+ return new StringValue(this, stripBOM(readContent(href, encoding)));
}
}
return Sequence.EMPTY_SEQUENCE;
@@ -96,7 +97,7 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
private boolean contentAvailable(final String uri, final String encoding) {
final Charset charset;
try {
- charset = encoding != null ? Charset.forName(encoding) : UTF_8;
+ charset = encoding != null ? resolveCharset(encoding) : UTF_8;
} catch (final IllegalArgumentException e) {
return false;
}
@@ -120,7 +121,7 @@ private boolean contentAvailable(final String uri, final String encoding) {
private String readContent(final String uri, final String encoding) throws XPathException {
final Charset charset;
try {
- charset = encoding != null ? Charset.forName(encoding) : UTF_8;
+ charset = encoding != null ? resolveCharset(encoding) : UTF_8;
} catch (final IllegalArgumentException e) {
throw new XPathException(this, ErrorCodes.FOUT1190, e.getMessage());
}
@@ -175,7 +176,12 @@ private Sequence readLines(final String uriParam, final String encoding) throws
try (final BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, charset))) {
String line;
+ boolean firstLine = true;
while ((line = reader.readLine()) != null) {
+ if (firstLine) {
+ line = stripBOM(line);
+ firstLine = false;
+ }
result.add(new StringValue(this, line));
}
}
@@ -199,7 +205,7 @@ private Charset getCharset(final String encoding, final Source source) throws XP
}
} else {
try {
- charset = Charset.forName(encoding);
+ charset = resolveCharset(encoding);
} catch (final IllegalArgumentException e) {
throw new XPathException(this, ErrorCodes.FOUT1190, e.getMessage());
}
@@ -207,14 +213,70 @@ private Charset getCharset(final String encoding, final Source source) throws XP
return charset;
}
+ /**
+ * Resolve a charset name, mapping common aliases that Java doesn't recognize.
+ */
+ /**
+ * Strip the Unicode BOM (U+FEFF) from the beginning of a string.
+ * Per XQuery spec: "If the text resource has a BOM, the BOM is excluded from the result."
+ */
+ private static String stripBOM(final String s) {
+ if (s != null && !s.isEmpty() && s.charAt(0) == '\uFEFF') {
+ return s.substring(1);
+ }
+ return s;
+ }
+
+ private static Charset resolveCharset(final String encoding) {
+ try {
+ return Charset.forName(encoding);
+ } catch (final UnsupportedCharsetException e) {
+ if ("iso-8859".equalsIgnoreCase(encoding)) {
+ return Charset.forName("iso-8859-1");
+ }
+ throw e;
+ }
+ }
+
private Source getSource(final String uriParam) throws XPathException {
try {
- final URI uri = new URI(uriParam);
+ URI uri = new URI(uriParam);
if (uri.getFragment() != null) {
throw new XPathException(this, ErrorCodes.FOUT1170, "href argument may not contain fragment identifier");
}
- final Source source = SourceFactory.getSource(context.getBroker(), "", uri.toASCIIString(), false);
+ // Resolve relative URIs against file: base URI directory
+ boolean resolvedFromBaseUri = false;
+ if (!uri.isAbsolute()) {
+ final AnyURIValue baseXdmUri = context.getBaseURI();
+ if (baseXdmUri != null && !baseXdmUri.equals(AnyURIValue.EMPTY_URI)) {
+ String baseStr = baseXdmUri.toURI().toString();
+ if (baseStr.startsWith("file:")) {
+ final int lastSlash = baseStr.lastIndexOf('/');
+ if (lastSlash >= 0) {
+ baseStr = baseStr.substring(0, lastSlash + 1);
+ }
+ uri = new URI(baseStr).resolve(uri);
+ resolvedFromBaseUri = true;
+ }
+ }
+ }
+
+ final String resolvedUri = uri.toASCIIString();
+
+ // Only use direct file: access for URIs resolved from a relative path
+ // against a file: base URI. Absolute file: URIs (e.g., file:///etc/passwd)
+ // must go through SourceFactory which enforces security checks.
+ if (resolvedFromBaseUri && resolvedUri.startsWith("file:")) {
+ final String filePath = resolvedUri.replaceFirst("^file:(?://[^/]*)?", "");
+ final java.nio.file.Path path = java.nio.file.Paths.get(filePath);
+ if (java.nio.file.Files.isReadable(path)) {
+ return new FileSource(path, false);
+ }
+ throw new XPathException(this, ErrorCodes.FOUT1170, "Could not find source for: " + uriParam);
+ }
+
+ final Source source = SourceFactory.getSource(context.getBroker(), "", resolvedUri, false);
if (source == null) {
throw new XPathException(this, ErrorCodes.FOUT1170, "Could not find source for: " + uriParam);
}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/JSON.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/JSON.java
index 42912e0569d..8f8a75d716e 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/JSON.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/JSON.java
@@ -64,7 +64,7 @@ public class JSON extends BasicFunction {
),
arity(
FS_PARAM_JSON_TEXT,
- param("options", Type.MAP_ITEM, "Parsing options")
+ optParam("options", Type.MAP_ITEM, "Parsing options")
)
)
);
@@ -121,18 +121,71 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
context.getXQueryVersion());
}
// 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);
+
+ // Validate deprecated options → XPTY0004
+ final Sequence validateOpt = options.get(new StringValue("validate"));
+ if (validateOpt != null && validateOpt.hasOne()) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "The 'validate' option is not supported");
+ }
+ // XQuery 4.0: 'spec' option controls JSON spec version (RFC7159, ECMA-404, etc.)
+ // Accepted but not yet enforced — we always parse per RFC 7159
+ final Sequence specOpt = options.get(new StringValue("spec"));
+ // (no validation needed — all spec values are accepted)
+
+ // Validate liberal option — must be boolean
final Sequence liberalOpt = options.get(new StringValue(OPTION_LIBERAL));
- if (liberalOpt.hasOne()) {
- liberal = liberalOpt.itemAt(0).convertTo(Type.BOOLEAN).effectiveBooleanValue();
+ if (liberalOpt != null && liberalOpt.hasOne()) {
+ final Item liberalItem = liberalOpt.itemAt(0);
+ if (liberalItem.getType() != Type.BOOLEAN) {
+ // Try to convert; if the value is a non-boolean string, reject
+ if (Type.subTypeOf(liberalItem.getType(), Type.STRING)) {
+ final String val = liberalItem.getStringValue();
+ if (!"true".equals(val) && !"false".equals(val) && !"1".equals(val) && !"0".equals(val)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Option 'liberal' must be a boolean, got: " + val);
+ }
+ }
+ liberal = liberalItem.convertTo(Type.BOOLEAN).effectiveBooleanValue();
+ } else {
+ liberal = liberalItem.convertTo(Type.BOOLEAN).effectiveBooleanValue();
+ }
}
+
+ // Validate duplicates option
final Sequence duplicateOpt = options.get(new StringValue(OPTION_DUPLICATES));
- if (duplicateOpt.hasOne()) {
+ if (duplicateOpt != null && duplicateOpt.hasOne()) {
handleDuplicates = duplicateOpt.itemAt(0).getStringValue();
+ if (!OPTION_DUPLICATES_USE_FIRST.equals(handleDuplicates)
+ && !OPTION_DUPLICATES_USE_LAST.equals(handleDuplicates)
+ && !OPTION_DUPLICATES_REJECT.equals(handleDuplicates)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Invalid value for 'duplicates' option: " + handleDuplicates);
+ }
+ }
+
+ // Validate fallback option — must be a function with arity 1
+ final Sequence fallbackOpt = options.get(new StringValue("fallback"));
+ if (fallbackOpt != null && fallbackOpt.hasOne()) {
+ final Item fallbackItem = fallbackOpt.itemAt(0);
+ if (!(fallbackItem instanceof FunctionReference)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Option 'fallback' must be a function, got: " + Type.getTypeName(fallbackItem.getType()));
+ }
+ }
+
+ // Validate number-parser option — must be a function
+ final Sequence numberParserOpt = options.get(new StringValue("number-parser"));
+ if (numberParserOpt != null && numberParserOpt.hasOne()) {
+ final Item npItem = numberParserOpt.itemAt(0);
+ if (!(npItem instanceof FunctionReference)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Option 'number-parser' must be a function, got: " + Type.getTypeName(npItem.getType()));
+ }
}
}
@@ -212,24 +265,94 @@ private Sequence parseResource(Sequence href, String handleDuplicates, JsonFacto
}
try {
String url = href.getStringValue();
+
+ // Check dynamically available text resources first (XQTS runner registers these)
+ try (final java.io.Reader dynReader = context.getDynamicallyAvailableTextResource(
+ url, java.nio.charset.StandardCharsets.UTF_8)) {
+ if (dynReader != null) {
+ final StringBuilder sb = new StringBuilder();
+ final char[] buf = new char[4096];
+ int read;
+ while ((read = dynReader.read(buf)) > 0) {
+ sb.append(buf, 0, read);
+ }
+ try (final JsonParser parser = factory.createParser(sb.toString())) {
+ final Item result = readValue(context, parser, handleDuplicates);
+ return result == null ? Sequence.EMPTY_SEQUENCE : result.toSequence();
+ } catch (final java.io.IOException jsonErr) {
+ throw new XPathException(this, ErrorCodes.FOJS0001, jsonErr.getMessage());
+ }
+ }
+ } catch (final java.io.IOException e) {
+ // Not a dynamic resource, fall through to URL resolution
+ }
+ boolean resolvedFromBaseUri = false;
if (url.indexOf(':') == Constants.STRING_NOT_FOUND) {
- url = XmldbURI.EMBEDDED_SERVER_URI_PREFIX + url;
+ // Relative URI: resolve against static base URI
+ final String resolved = resolveAgainstBaseUri(url);
+ if (resolved != null && resolved.startsWith("file:")) {
+ url = resolved;
+ resolvedFromBaseUri = true;
+ } else {
+ url = XmldbURI.EMBEDDED_SERVER_URI_PREFIX + url;
+ }
+ }
+ // Only use direct file: access for URIs resolved from a relative path.
+ // Absolute file: URIs go through SourceFactory for security.
+ if (resolvedFromBaseUri && url.startsWith("file:")) {
+ // Extract path from file: URI: file:/path, file://host/path, file:///path
+ final String filePath = url.replaceFirst("^file:(?://[^/]*)?", "");
+ final java.nio.file.Path path = java.nio.file.Paths.get(filePath);
+ if (java.nio.file.Files.isReadable(path)) {
+ try (final InputStream is = java.nio.file.Files.newInputStream(path)) {
+ try (final JsonParser parser = factory.createParser(is)) {
+ final Item result = readValue(context, parser, handleDuplicates);
+ return result == null ? Sequence.EMPTY_SEQUENCE : result.toSequence();
+ } catch (final IOException jsonErr) {
+ // JSON parsing error, not file I/O
+ throw new XPathException(this, ErrorCodes.FOJS0001, jsonErr.getMessage());
+ }
+ }
+ }
+ throw new XPathException(this, ErrorCodes.FOUT1170, "failed to load json doc from file: " + filePath);
}
+
final Source source = SourceFactory.getSource(context.getBroker(), "", url, false);
if (source == null) {
throw new XPathException(this, ErrorCodes.FOUT1170, "failed to load json doc from URI " + url);
}
- try (final InputStream is = source.getInputStream();
- final JsonParser parser = factory.createParser(is)) {
-
- final Item result = readValue(context, parser, handleDuplicates);
- return result == null ? Sequence.EMPTY_SEQUENCE : result.toSequence();
+ try (final InputStream is = source.getInputStream()) {
+ try (final JsonParser parser = factory.createParser(is)) {
+ final Item result = readValue(context, parser, handleDuplicates);
+ return result == null ? Sequence.EMPTY_SEQUENCE : result.toSequence();
+ } catch (final IOException jsonErr) {
+ throw new XPathException(this, ErrorCodes.FOJS0001, jsonErr.getMessage());
+ }
}
} catch (IOException | PermissionDeniedException e) {
throw new XPathException(this, ErrorCodes.FOUT1170, e.getMessage());
}
}
+ private String resolveAgainstBaseUri(final String relativePath) {
+ try {
+ final AnyURIValue baseXdmUri = context.getBaseURI();
+ if (baseXdmUri != null && !baseXdmUri.equals(AnyURIValue.EMPTY_URI)) {
+ String baseStr = baseXdmUri.toURI().toString();
+ // Strip filename to get directory URI
+ final int lastSlash = baseStr.lastIndexOf('/');
+ if (lastSlash >= 0) {
+ baseStr = baseStr.substring(0, lastSlash + 1);
+ }
+ final java.net.URI baseUri = new java.net.URI(baseStr);
+ return baseUri.resolve(relativePath).toString();
+ }
+ } catch (final java.net.URISyntaxException | XPathException e) {
+ // fall through
+ }
+ return null;
+ }
+
/**
* Generate an XDM from the tokens delivered by the JSON parser.
*
@@ -267,10 +390,18 @@ private static Item readValue(XQueryContext context, JsonParser parser, Item par
next = BooleanValue.TRUE;
break;
case VALUE_NUMBER_FLOAT:
- case VALUE_NUMBER_INT:
- // according to spec, all numbers are converted to double
+ // JSON fractional numbers → xs:double
next = new StringValue(parser.getText()).convertTo(Type.DOUBLE);
break;
+ case VALUE_NUMBER_INT:
+ // XQuery 4.0: JSON integers → xs:integer (was xs:double in 3.1)
+ try {
+ next = new IntegerValue(parser.getLongValue());
+ } catch (final Exception e) {
+ // Fallback to double for very large integers
+ next = new StringValue(parser.getText()).convertTo(Type.DOUBLE);
+ }
+ break;
case VALUE_NULL:
next = null;
break;
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/LoadXQueryModule.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/LoadXQueryModule.java
index f2d409ebeb9..70a3d2bb678 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/LoadXQueryModule.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/LoadXQueryModule.java
@@ -27,13 +27,18 @@
import io.lacuna.bifurcan.Map;
import io.lacuna.bifurcan.Maps;
import org.exist.dom.QName;
+import org.exist.source.StringSource;
import org.exist.xquery.*;
import org.exist.xquery.Module;
import org.exist.xquery.functions.map.AbstractMapType;
import org.exist.xquery.functions.map.MapType;
import org.exist.xquery.parser.XQueryAST;
+import org.exist.xquery.parser.XQueryLexer;
+import org.exist.xquery.parser.XQueryParser;
+import org.exist.xquery.parser.XQueryTreeParser;
import org.exist.xquery.value.*;
+import java.io.Reader;
import java.util.*;
import static org.exist.xquery.functions.map.MapType.newLinearMap;
@@ -98,6 +103,7 @@ public class LoadXQueryModule extends BasicFunction {
public final static StringValue OPTIONS_VARIABLES = new StringValue("variables");
public final static StringValue OPTIONS_CONTEXT_ITEM = new StringValue("context-item");
public final static StringValue OPTIONS_VENDOR = new StringValue("vendor-options");
+ public final static StringValue OPTIONS_CONTENT = new StringValue("content");
public final static StringValue RESULT_FUNCTIONS = new StringValue("functions");
public final static StringValue RESULT_VARIABLES = new StringValue("variables");
@@ -116,6 +122,7 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
String xqVersion = getXQueryVersion(context.getXQueryVersion());
AbstractMapType externalVars = new MapType(this, context);
Sequence contextItem = Sequence.EMPTY_SEQUENCE;
+ String contentSource = null;
// evaluate options
if (getArgumentCount() == 2) {
@@ -144,6 +151,12 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
throw new XPathException(this, ErrorCodes.XPTY0004, "Option 'context-item' must contain zero or one " +
"items");
}
+
+ // XQ4: content option — compile module from provided source string
+ final Sequence contentOption = map.get(OPTIONS_CONTENT);
+ if (!contentOption.isEmpty()) {
+ contentSource = contentOption.getStringValue();
+ }
}
// create temporary context so main context is not polluted
@@ -154,15 +167,21 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
tempContext.prepareForExecution();
Module[] loadedModules = null;
- try {
- loadedModules = tempContext.importModule(targetNamespace, null, locationHints);
- } catch (final XPathException e) {
- if (e.getErrorCode() == ErrorCodes.XQST0059) {
- // importModule may throw exception if no location is given and module cannot be resolved
- throw new XPathException(this, ErrorCodes.FOQM0002, "Module with URI " + targetNamespace + " not found");
+ if (contentSource != null) {
+ // XQ4: compile module from content string
+ final ExternalModule contentModule = compileModuleFromContent(
+ targetNamespace, contentSource, tempContext);
+ loadedModules = new Module[] { contentModule };
+ } else {
+ try {
+ loadedModules = tempContext.importModule(targetNamespace, null, locationHints);
+ } catch (final XPathException e) {
+ if (e.getErrorCode() == ErrorCodes.XQST0059) {
+ throw new XPathException(this, ErrorCodes.FOQM0002, "Module with URI " + targetNamespace + " not found");
+ }
+ throw new XPathException(this, ErrorCodes.FOQM0003, "Error found when importing module: " + e.getMessage());
}
- throw new XPathException(this, ErrorCodes.FOQM0003, "Error found when importing module: " + e.getMessage());
}
// not found, raise error
@@ -284,6 +303,36 @@ public static void addFunctionRefsFromModule(final Expression parent, final XQue
}
}
+ /**
+ * XQ4: Compile a library module from a content string.
+ * Uses XQueryContext.compileModuleFromSource() which handles all the
+ * parsing, AST walking, and module registration.
+ */
+ private ExternalModule compileModuleFromContent(final String targetNamespace,
+ final String content, final XQueryContext tempContext) throws XPathException {
+ final StringSource source = new StringSource(content);
+ try {
+ final ExternalModule module = tempContext.compileModuleFromSource(targetNamespace, source);
+ if (module == null) {
+ throw new XPathException(this, ErrorCodes.FOQM0005,
+ "Content string is not a library module");
+ }
+ // Verify the module's namespace matches the target
+ if (!module.getNamespaceURI().equals(targetNamespace)) {
+ throw new XPathException(this, ErrorCodes.FOQM0001,
+ "Module namespace '" + module.getNamespaceURI() +
+ "' does not match target namespace '" + targetNamespace + "'");
+ }
+ return module;
+ } catch (final XPathException e) {
+ if (e.getErrorCode() == ErrorCodes.FOQM0001 || e.getErrorCode() == ErrorCodes.FOQM0005) {
+ throw e;
+ }
+ throw new XPathException(this, ErrorCodes.FOQM0003,
+ "Error compiling module content: " + e.getMessage(), e);
+ }
+ }
+
private static String getXQueryVersion(final int version) {
return String.valueOf(version / 10) + '.' + version % 10;
}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/ParsingFunctions.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/ParsingFunctions.java
index 48a8353d83c..0ebd4188059 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/fn/ParsingFunctions.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/ParsingFunctions.java
@@ -49,6 +49,9 @@ public class ParsingFunctions extends BasicFunction {
protected static final Logger logger = LogManager.getLogger(ParsingFunctions.class);
+ protected static final FunctionParameterSequenceType OPTIONS_PARAMETER = new FunctionParameterSequenceType(
+ "options", Type.MAP_ITEM, Cardinality.EXACTLY_ONE, "Options map");
+
public final static FunctionSignature[] signatures = {
new FunctionSignature(
new QName("parse-xml", Function.BUILTIN_FUNCTION_NS),
@@ -64,6 +67,22 @@ public class ParsingFunctions extends BasicFunction {
+ "Returns the document node with the parsed document fragment.",
new SequenceType[] { TO_BE_PARSED_PARAMETER },
PARSE_RESULT_TYPE
+ ),
+ // XQuery 4.0: fn:parse-xml with options map
+ new FunctionSignature(
+ new QName("parse-xml", Function.BUILTIN_FUNCTION_NS),
+ "Parse an XML document with options. "
+ + "Returns the document node with the parsed document.",
+ new SequenceType[] { TO_BE_PARSED_PARAMETER, OPTIONS_PARAMETER },
+ PARSE_RESULT_TYPE
+ ),
+ // XQuery 4.0: fn:parse-xml-fragment with options map
+ new FunctionSignature(
+ new QName("parse-xml-fragment", Function.BUILTIN_FUNCTION_NS),
+ "Parse an XML fragment with options. "
+ + "Returns the document node with the parsed document fragment.",
+ new SequenceType[] { TO_BE_PARSED_PARAMETER, OPTIONS_PARAMETER },
+ PARSE_RESULT_TYPE
)
};
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/map/MapFunction.java b/exist-core/src/main/java/org/exist/xquery/functions/map/MapFunction.java
index 029caa0cdee..c3caf9c2836 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/map/MapFunction.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/map/MapFunction.java
@@ -41,6 +41,7 @@
import org.exist.xquery.value.Sequence;
import org.exist.xquery.value.StringValue;
import org.exist.xquery.value.Type;
+import org.exist.xquery.value.SequenceIterator;
import org.exist.xquery.value.ValueSequence;
import java.util.ArrayList;
@@ -55,6 +56,8 @@
import static org.exist.xquery.FunctionDSL.param;
import static org.exist.xquery.FunctionDSL.params;
import static org.exist.xquery.FunctionDSL.returns;
+import static org.exist.xquery.FunctionDSL.returnsMany;
+import static org.exist.xquery.FunctionDSL.returnsOptMany;
import static org.exist.xquery.functions.map.MapModule.functionSignature;
/**
@@ -169,6 +172,86 @@ public class MapFunction extends BasicFunction {
)
);
+ // --- XQuery 4.0 map functions ---
+ public static final FunctionSignature[] FNS_EMPTY = {
+ functionSignature(
+ Fn.EMPTY.fname,
+ "Returns an empty map.",
+ RETURN_MAP
+ ),
+ functionSignature(
+ Fn.EMPTY.fname,
+ "Returns true if the map is empty.",
+ new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, "true if the map is empty"),
+ PARAM_INPUT_MAP
+ )
+ };
+
+ public static final FunctionSignature FNS_GET_DEFAULT = functionSignature(
+ Fn.GET_DEFAULT.fname,
+ "Returns the value associated with a key, or a default value if the key is not present.",
+ RETURN_OPT_MANY_ITEM,
+ PARAM_INPUT_MAP,
+ PARAM_KEY,
+ optManyParam("default", Type.ITEM, "The default value")
+ );
+
+ public static final FunctionSignature BUILD_1 = functionSignature(
+ Fn.BUILD.fname,
+ "Builds a map from a sequence of items, using a key function.",
+ RETURN_MAP,
+ optManyParam("input", Type.ITEM, "The input sequence"),
+ funParam("key", params(optManyParam("item", Type.ITEM, "the item")),
+ returns(Type.ANY_ATOMIC_TYPE, Cardinality.EXACTLY_ONE), "The key function")
+ );
+
+ public static final FunctionSignature BUILD_2 = functionSignature(
+ Fn.BUILD.fname,
+ "Builds a map from a sequence of items, using key and value functions.",
+ RETURN_MAP,
+ optManyParam("input", Type.ITEM, "The input sequence"),
+ funParam("key", params(optManyParam("item", Type.ITEM, "the item")),
+ returns(Type.ANY_ATOMIC_TYPE, Cardinality.EXACTLY_ONE), "The key function"),
+ funParam("value", params(optManyParam("item", Type.ITEM, "the item")),
+ returnsOptMany(Type.ITEM), "The value function")
+ );
+
+ public static final FunctionSignature ITEMS = functionSignature(
+ Fn.ITEMS.fname,
+ "Returns a sequence of maps, each with 'key' and 'value' entries.",
+ returnsOptMany(Type.MAP_ITEM, "A sequence of key-value pair maps"),
+ PARAM_INPUT_MAP
+ );
+
+ public static final FunctionSignature ENTRIES = functionSignature(
+ Fn.ENTRIES.fname,
+ "Returns a sequence of maps, each with 'key' and 'value' entries.",
+ returnsOptMany(Type.MAP_ITEM, "A sequence of key-value pair maps"),
+ PARAM_INPUT_MAP
+ );
+
+ public static final FunctionSignature FILTER_SIG = functionSignature(
+ Fn.FILTER.fname,
+ "Returns a map containing only entries matching the predicate.",
+ RETURN_MAP,
+ PARAM_INPUT_MAP,
+ funParam("predicate",
+ params(param("key", Type.ANY_ATOMIC_TYPE, "the key"),
+ optManyParam("value", Type.ITEM, "the value")),
+ returns(Type.BOOLEAN, Cardinality.EXACTLY_ONE), "The filter predicate")
+ );
+
+ public static final FunctionSignature KEYS_WHERE = functionSignature(
+ Fn.KEYS_WHERE.fname,
+ "Returns keys whose entries match the predicate.",
+ returnsOptMany(Type.ANY_ATOMIC_TYPE, "The matching keys"),
+ PARAM_INPUT_MAP,
+ funParam("predicate",
+ params(param("key", Type.ANY_ATOMIC_TYPE, "the key"),
+ optManyParam("value", Type.ITEM, "the value")),
+ returns(Type.BOOLEAN, Cardinality.EXACTLY_ONE), "The filter predicate")
+ );
+
private AnalyzeContextInfo cachedContextInfo;
public MapFunction(final XQueryContext context, final FunctionSignature signature) {
@@ -239,6 +322,25 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro
case ENTRY -> entry(args);
case REMOVE -> remove(args);
case FOR_EACH -> forEach(args);
+ case EMPTY -> {
+ if (args.length > 0 && !args[0].isEmpty()) {
+ // 1-arity: map:empty($map) — check if map is empty
+ final AbstractMapType map = (AbstractMapType) args[0].itemAt(0);
+ yield BooleanValue.valueOf(map.size() == 0);
+ } else if (args.length > 0 && args[0].isEmpty()) {
+ // empty sequence passed — type error
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Expected map(*) but got empty sequence");
+ } else {
+ // 0-arity: map:empty() — return empty map
+ yield new MapType(this, this.context);
+ }
+ }
+ case GET_DEFAULT -> getDefault(args);
+ case BUILD -> build(args);
+ case ITEMS, ENTRIES -> items(args);
+ case FILTER -> filter(args);
+ case KEYS_WHERE -> keysWhere(args);
};
}
@@ -433,6 +535,79 @@ static MapFunction.DuplicateMergeStrategy get(String key) {
}
}
+ private Sequence getDefault(final Sequence[] args) throws XPathException {
+ final AbstractMapType map = (AbstractMapType) args[0].itemAt(0);
+ final AtomicValue key = (AtomicValue) args[1].itemAt(0);
+ final Sequence value = map.get(key);
+ if (value == null || value.isEmpty()) {
+ return args[2];
+ }
+ return value;
+ }
+
+ private Sequence build(final Sequence[] args) throws XPathException {
+ final Sequence input = args[0];
+ final FunctionReference keyFn = (FunctionReference) args[1].itemAt(0);
+ keyFn.analyze(cachedContextInfo);
+ final FunctionReference valueFn = args.length > 2 ?
+ (FunctionReference) args[2].itemAt(0) : null;
+ if (valueFn != null) { valueFn.analyze(cachedContextInfo); }
+
+ final MapType result = new MapType(this, context);
+ for (final SequenceIterator i = input.iterate(); i.hasNext(); ) {
+ final Item item = i.nextItem();
+ final Sequence itemSeq = item.toSequence();
+ final AtomicValue key = (AtomicValue) keyFn.evalFunction(null, null,
+ new Sequence[]{itemSeq}).itemAt(0);
+ final Sequence value = valueFn != null ?
+ valueFn.evalFunction(null, null, new Sequence[]{itemSeq}) : itemSeq;
+ result.add(key, value);
+ }
+ return result;
+ }
+
+ private Sequence items(final Sequence[] args) throws XPathException {
+ final AbstractMapType map = (AbstractMapType) args[0].itemAt(0);
+ final ValueSequence result = new ValueSequence();
+ for (final IEntry entry : map) {
+ final MapType entryMap = new MapType(this, context);
+ entryMap.add(new StringValue("key"), entry.key().toSequence());
+ entryMap.add(new StringValue("value"), entry.value());
+ result.add(entryMap);
+ }
+ return result;
+ }
+
+ private Sequence filter(final Sequence[] args) throws XPathException {
+ final AbstractMapType map = (AbstractMapType) args[0].itemAt(0);
+ final FunctionReference pred = (FunctionReference) args[1].itemAt(0);
+ pred.analyze(cachedContextInfo);
+ final MapType result = new MapType(this, context);
+ for (final IEntry entry : map) {
+ final Sequence testResult = pred.evalFunction(null, null,
+ new Sequence[]{entry.key().toSequence(), entry.value()});
+ if (testResult.effectiveBooleanValue()) {
+ result.add(entry.key(), entry.value());
+ }
+ }
+ return result;
+ }
+
+ private Sequence keysWhere(final Sequence[] args) throws XPathException {
+ final AbstractMapType map = (AbstractMapType) args[0].itemAt(0);
+ final FunctionReference pred = (FunctionReference) args[1].itemAt(0);
+ pred.analyze(cachedContextInfo);
+ final ValueSequence result = new ValueSequence();
+ for (final IEntry entry : map) {
+ final Sequence testResult = pred.evalFunction(null, null,
+ new Sequence[]{entry.key().toSequence(), entry.value()});
+ if (testResult.effectiveBooleanValue()) {
+ result.add(entry.key());
+ }
+ }
+ return result;
+ }
+
private enum Fn {
SIZE("size"),
ENTRY("entry"),
@@ -443,7 +618,15 @@ private enum Fn {
KEYS("keys"),
REMOVE("remove"),
FOR_EACH("for-each"),
- FIND("find");
+ FIND("find"),
+ // --- XQuery 4.0 ---
+ EMPTY("empty"),
+ GET_DEFAULT("get-default"),
+ BUILD("build"),
+ ITEMS("items"),
+ ENTRIES("entries"),
+ FILTER("filter"),
+ KEYS_WHERE("keys-where");
final static Map fnMap = new HashMap<>();
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/map/MapModule.java b/exist-core/src/main/java/org/exist/xquery/functions/map/MapModule.java
index 0eec5b52553..4a79768f1c9 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/map/MapModule.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/map/MapModule.java
@@ -55,7 +55,17 @@ public class MapModule extends AbstractInternalModule {
MapFunction.PUT,
MapFunction.ENTRY,
MapFunction.REMOVE,
- MapFunction.FOR_EACH
+ MapFunction.FOR_EACH,
+ // --- XQuery 4.0 map functions ---
+ MapFunction.FNS_GET_DEFAULT,
+ MapFunction.FNS_EMPTY[0],
+ MapFunction.FNS_EMPTY[1],
+ MapFunction.BUILD_1,
+ MapFunction.BUILD_2,
+ MapFunction.ITEMS,
+ MapFunction.ENTRIES,
+ MapFunction.FILTER_SIG,
+ MapFunction.KEYS_WHERE
);
public MapModule(Map> parameters) {
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/math/MathModule.java b/exist-core/src/main/java/org/exist/xquery/functions/math/MathModule.java
index be6211ee905..75d665def8a 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/math/MathModule.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/math/MathModule.java
@@ -53,8 +53,12 @@ public class MathModule extends AbstractInternalModule {
new FunctionDef(OneParamFunctions.FNS_SIN, OneParamFunctions.class),
new FunctionDef(OneParamFunctions.FNS_SQRT, OneParamFunctions.class),
new FunctionDef(OneParamFunctions.FNS_TAN, OneParamFunctions.class),
-
+ new FunctionDef(OneParamFunctions.FNS_COSH, OneParamFunctions.class),
+ new FunctionDef(OneParamFunctions.FNS_SINH, OneParamFunctions.class),
+ new FunctionDef(OneParamFunctions.FNS_TANH, OneParamFunctions.class),
+
new FunctionDef(NoParamFunctions.FNS_PI, NoParamFunctions.class),
+ new FunctionDef(NoParamFunctions.FNS_E, NoParamFunctions.class),
new FunctionDef(TwoParamFunctions.FNS_ATAN2, TwoParamFunctions.class),
new FunctionDef(TwoParamFunctions.FNS_POW, TwoParamFunctions.class)
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/math/NoParamFunctions.java b/exist-core/src/main/java/org/exist/xquery/functions/math/NoParamFunctions.java
index 68417874a90..6ce93ed07b4 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/math/NoParamFunctions.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/math/NoParamFunctions.java
@@ -43,6 +43,7 @@ public class NoParamFunctions extends BasicFunction {
//private static final Logger logger = LogManager.getLogger(NoParamFunctions.class);
public static final String PI = "pi";
+ public static final String E = "e";
public final static FunctionSignature FNS_PI = new FunctionSignature(
new QName(PI, MathModule.NAMESPACE_URI, MathModule.PREFIX),
@@ -51,6 +52,13 @@ public class NoParamFunctions extends BasicFunction {
new FunctionReturnSequenceType(Type.DOUBLE, Cardinality.EXACTLY_ONE, "the value of pi")
);
+ public final static FunctionSignature FNS_E = new FunctionSignature(
+ new QName(E, MathModule.NAMESPACE_URI, MathModule.PREFIX),
+ "Returns the value of e (Euler's number, approximately 2.71828).",
+ null,
+ new FunctionReturnSequenceType(Type.DOUBLE, Cardinality.EXACTLY_ONE, "the value of e")
+ );
+
public NoParamFunctions(XQueryContext context, FunctionSignature signature) {
super(context, signature);
}
@@ -68,7 +76,8 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
final String functionName = getSignature().getName().getLocalPart();
if(PI.equals(functionName)) {
result=new DoubleValue(this, Math.PI);
-
+ } else if(E.equals(functionName)) {
+ result=new DoubleValue(this, Math.E);
} else {
throw new XPathException(this, "Function "+functionName+" not found.");
}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/math/OneParamFunctions.java b/exist-core/src/main/java/org/exist/xquery/functions/math/OneParamFunctions.java
index ca3f330249a..6968f7a58ae 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/math/OneParamFunctions.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/math/OneParamFunctions.java
@@ -47,6 +47,9 @@ public class OneParamFunctions extends BasicFunction {
public static final String SIN = "sin";
public static final String SQRT = "sqrt";
public static final String TAN = "tan";
+ public static final String COSH = "cosh";
+ public static final String SINH = "sinh";
+ public static final String TANH = "tanh";
public final static FunctionSignature FNS_ACOS = new FunctionSignature(
new QName(ACOS, MathModule.NAMESPACE_URI, MathModule.PREFIX),
@@ -125,6 +128,27 @@ public class OneParamFunctions extends BasicFunction {
new FunctionReturnSequenceType(Type.DOUBLE, Cardinality.ZERO_OR_ONE, "the tangent")
);
+ public final static FunctionSignature FNS_COSH = new FunctionSignature(
+ new QName(COSH, MathModule.NAMESPACE_URI, MathModule.PREFIX),
+ "Returns the hyperbolic cosine of the argument.",
+ new SequenceType[]{new FunctionParameterSequenceType("arg", Type.DOUBLE, Cardinality.ZERO_OR_ONE, "The input value")},
+ new FunctionReturnSequenceType(Type.DOUBLE, Cardinality.ZERO_OR_ONE, "the hyperbolic cosine")
+ );
+
+ public final static FunctionSignature FNS_SINH = new FunctionSignature(
+ new QName(SINH, MathModule.NAMESPACE_URI, MathModule.PREFIX),
+ "Returns the hyperbolic sine of the argument.",
+ new SequenceType[]{new FunctionParameterSequenceType("arg", Type.DOUBLE, Cardinality.ZERO_OR_ONE, "The input value")},
+ new FunctionReturnSequenceType(Type.DOUBLE, Cardinality.ZERO_OR_ONE, "the hyperbolic sine")
+ );
+
+ public final static FunctionSignature FNS_TANH = new FunctionSignature(
+ new QName(TANH, MathModule.NAMESPACE_URI, MathModule.PREFIX),
+ "Returns the hyperbolic tangent of the argument.",
+ new SequenceType[]{new FunctionParameterSequenceType("arg", Type.DOUBLE, Cardinality.ZERO_OR_ONE, "The input value")},
+ new FunctionReturnSequenceType(Type.DOUBLE, Cardinality.ZERO_OR_ONE, "the hyperbolic tangent")
+ );
+
public OneParamFunctions(XQueryContext context, FunctionSignature signature) {
super(context, signature);
}
@@ -156,6 +180,9 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
case SIN -> Math.sin(value.getDouble());
case SQRT -> Math.sqrt(value.getDouble());
case TAN -> Math.tan(value.getDouble());
+ case COSH -> Math.cosh(value.getDouble());
+ case SINH -> Math.sinh(value.getDouble());
+ case TANH -> Math.tanh(value.getDouble());
case null -> throw new XPathException(this, ERROR, "Function " + functionName + " not found.");
default -> 0;
};
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/math/TwoParamFunctions.java b/exist-core/src/main/java/org/exist/xquery/functions/math/TwoParamFunctions.java
index 8e45fd0d08c..95f06b1ee64 100644
--- a/exist-core/src/main/java/org/exist/xquery/functions/math/TwoParamFunctions.java
+++ b/exist-core/src/main/java/org/exist/xquery/functions/math/TwoParamFunctions.java
@@ -83,7 +83,21 @@ public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathExce
calcValue = Math.atan2(valueA.getDouble(), valueB.getDouble());
} else if (POW.equals(functionName)) {
- calcValue = Math.pow(valueA.getDouble(), valueB.getDouble());
+ final double a = valueA.getDouble();
+ final double b = valueB.getDouble();
+ // XPath spec §4.2.7 overrides IEEE 754 for these cases:
+ // pow(x, 0) = 1.0 for ANY x (including NaN, ±INF)
+ // pow(1, y) = 1.0 for ANY y (including NaN, ±INF)
+ // pow(-1, ±INF) = 1.0
+ if (b == 0.0) {
+ calcValue = 1.0;
+ } else if (a == 1.0) {
+ calcValue = 1.0;
+ } else if (a == -1.0 && Double.isInfinite(b)) {
+ calcValue = 1.0;
+ } else {
+ calcValue = Math.pow(a, b);
+ }
} else {
throw new XPathException(this, ERROR, "Function " + functionName + " not found.");
diff --git a/exist-core/src/main/java/org/exist/xquery/regex/RegexUtil.java b/exist-core/src/main/java/org/exist/xquery/regex/RegexUtil.java
index d54ca496c01..31c372db37f 100644
--- a/exist-core/src/main/java/org/exist/xquery/regex/RegexUtil.java
+++ b/exist-core/src/main/java/org/exist/xquery/regex/RegexUtil.java
@@ -155,7 +155,36 @@ public static String translateRegexp(final Expression context, final String patt
final List warnings = new ArrayList<>();
return JDK15RegexTranslator.translate(pattern, options, flagbits, warnings);
} catch (final RegexSyntaxException e) {
+ // Fallback: if the pattern uses \p{Is} Unicode block names that
+ // the bundled Saxon regex translator doesn't recognize, convert them to
+ // Java's \p{In} syntax and try compiling directly.
+ if (pattern.contains("\\p{Is") || pattern.contains("\\P{Is")) {
+ final String javaPattern = convertUnicodeBlockNames(pattern);
+ try {
+ int flags = Pattern.UNICODE_CHARACTER_CLASS;
+ if (ignoreWhitespace) {
+ flags |= Pattern.COMMENTS;
+ }
+ if (caseBlind) {
+ flags |= Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE;
+ }
+ Pattern.compile(javaPattern, flags);
+ return javaPattern;
+ } catch (final java.util.regex.PatternSyntaxException ignored) {
+ // fallback failed, throw original error
+ }
+ }
throw new XPathException(context, ErrorCodes.FORX0002, "Conversion from XPath F&O 3.0 regular expression syntax to Java regular expression syntax failed: " + e.getMessage(), new StringValue(pattern), e);
}
}
+
+ /**
+ * Convert XML Schema/XPath \p{Is} and \P{Is} Unicode block
+ * property escapes to Java's \p{In} and \P{In} syntax.
+ */
+ private static String convertUnicodeBlockNames(final String pattern) {
+ return pattern
+ .replaceAll("\\\\p\\{Is([^}]+)}", "\\\\p{In$1}")
+ .replaceAll("\\\\P\\{Is([^}]+)}", "\\\\P{In$1}");
+ }
}
diff --git a/exist-core/src/main/java/org/exist/xquery/util/DocUtils.java b/exist-core/src/main/java/org/exist/xquery/util/DocUtils.java
index 22e3e15721c..be77f2c4a94 100644
--- a/exist-core/src/main/java/org/exist/xquery/util/DocUtils.java
+++ b/exist-core/src/main/java/org/exist/xquery/util/DocUtils.java
@@ -99,10 +99,19 @@ private static Sequence getDocumentByPath(final XQueryContext context, final Str
Sequence doc = getFromDynamicallyAvailableDocuments(context, path, expression);
if (doc == null) {
if (PTN_PROTOCOL_PREFIX.matcher(path).matches() && !path.startsWith("xmldb:")) {
- /* URL */
- doc = getDocumentByPathFromURL(context, path, expression);
+ /* URL — use SourceFactory (has security checks) */
+ doc = getDocumentByPathFromURL(context, path, expression, false);
+ } else if (!PTN_PROTOCOL_PREFIX.matcher(path).matches()) {
+ // Relative URI: resolve against static base URI per XQuery spec §2.1.2
+ final String resolved = resolveAgainstBaseUri(context, path);
+ if (resolved != null && resolved.startsWith("file:")) {
+ doc = getDocumentByPathFromURL(context, resolved, expression, true);
+ } else {
+ /* Database documents */
+ doc = getDocumentByPathFromDB(context, path, expression);
+ }
} else {
- /* Database documents */
+ /* Database documents (xmldb: prefix) */
doc = getDocumentByPathFromDB(context, path, expression);
}
}
@@ -110,6 +119,29 @@ private static Sequence getDocumentByPath(final XQueryContext context, final Str
return doc;
}
+ /**
+ * Resolve a relative URI against the static base URI.
+ *
+ * @return the resolved URI string, or null if resolution is not possible
+ */
+ private static @Nullable String resolveAgainstBaseUri(final XQueryContext context, final String relativePath) {
+ try {
+ final AnyURIValue baseXdmUri = context.getBaseURI();
+ if (baseXdmUri != null && !baseXdmUri.equals(AnyURIValue.EMPTY_URI)) {
+ String baseStr = baseXdmUri.toURI().toString();
+ // Strip filename to get directory URI
+ final int lastSlash = baseStr.lastIndexOf('/');
+ if (lastSlash >= 0) {
+ baseStr = baseStr.substring(0, lastSlash + 1);
+ }
+ return new URI(baseStr).resolve(relativePath).toString();
+ }
+ } catch (final URISyntaxException | XPathException e) {
+ // fall through
+ }
+ return null;
+ }
+
private static @Nullable Sequence getFromDynamicallyAvailableDocuments(final XQueryContext context, final String path) throws XPathException {
return getFromDynamicallyAvailableDocuments(context, path, null);
}
@@ -134,11 +166,28 @@ private static Sequence getDocumentByPath(final XQueryContext context, final Str
}
private static Sequence getDocumentByPathFromURL(final XQueryContext context, final String path) throws XPathException, PermissionDeniedException {
- return getDocumentByPathFromURL(context, path, null);
+ return getDocumentByPathFromURL(context, path, null, false);
}
- private static Sequence getDocumentByPathFromURL(final XQueryContext context, final String path, final Expression expression) throws XPathException, PermissionDeniedException {
+ private static Sequence getDocumentByPathFromURL(final XQueryContext context, final String path, final Expression expression, final boolean resolvedFromBaseUri) throws XPathException, PermissionDeniedException {
try {
+ // Only use direct file: access for URIs resolved from a relative path
+ // against a file: base URI. Absolute file: URIs go through SourceFactory
+ // which enforces security checks (e.g., blocking file:///etc/passwd).
+ if (resolvedFromBaseUri && path.startsWith("file:")) {
+ final String filePath = path.replaceFirst("^file:(?://[^/]*)?", "");
+ final java.nio.file.Path nioPath = java.nio.file.Paths.get(filePath);
+ if (java.nio.file.Files.isReadable(nioPath)) {
+ try (final java.io.InputStream fis = java.nio.file.Files.newInputStream(nioPath)) {
+ final org.exist.dom.memtree.DocumentImpl memtreeDoc = parse(
+ context.getBroker().getBrokerPool(), context, fis, expression);
+ memtreeDoc.setDocumentURI(path);
+ return memtreeDoc;
+ }
+ }
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
final Source source = SourceFactory.getSource(context.getBroker(), "", path, false);
if (source == null) {
return Sequence.EMPTY_SEQUENCE;
diff --git a/exist-core/src/main/java/org/exist/xquery/util/NumberFormatter_en.java b/exist-core/src/main/java/org/exist/xquery/util/NumberFormatter_en.java
index bf51c0b26f1..47a8c337ef5 100644
--- a/exist-core/src/main/java/org/exist/xquery/util/NumberFormatter_en.java
+++ b/exist-core/src/main/java/org/exist/xquery/util/NumberFormatter_en.java
@@ -36,7 +36,8 @@ public NumberFormatter_en(final Locale locale) {
@Override
public String getOrdinalSuffix(long number) {
- if (number > 10 && number < 20)
+ final long lastTwo = number % 100;
+ if (lastTwo > 10 && lastTwo < 20)
{return "th";}
final long mod = number % 10;
if (mod == 1)
diff --git a/exist-core/src/main/java/org/exist/xquery/value/AbstractDateTimeValue.java b/exist-core/src/main/java/org/exist/xquery/value/AbstractDateTimeValue.java
index 4b4f36150e8..5573b3966cd 100644
--- a/exist-core/src/main/java/org/exist/xquery/value/AbstractDateTimeValue.java
+++ b/exist-core/src/main/java/org/exist/xquery/value/AbstractDateTimeValue.java
@@ -186,6 +186,31 @@ protected XMLGregorianCalendar getImplicitCalendar() {
implicitCalendar.setMonth(12);
implicitCalendar.setDay(31);
break;
+ case Type.G_DAY:
+ // Per XPath spec §10.4, use reference date 1972-12 for gDay comparison
+ implicitCalendar.setYear(1972);
+ implicitCalendar.setMonth(12);
+ implicitCalendar.setTime(0, 0, 0);
+ break;
+ case Type.G_MONTH:
+ // Per XPath spec §10.4, use reference date 1972-xx-01 for gMonth
+ implicitCalendar.setYear(1972);
+ implicitCalendar.setDay(1);
+ implicitCalendar.setTime(0, 0, 0);
+ break;
+ case Type.G_YEAR:
+ implicitCalendar.setMonth(1);
+ implicitCalendar.setDay(1);
+ implicitCalendar.setTime(0, 0, 0);
+ break;
+ case Type.G_MONTH_DAY:
+ implicitCalendar.setYear(1972);
+ implicitCalendar.setTime(0, 0, 0);
+ break;
+ case Type.G_YEAR_MONTH:
+ implicitCalendar.setDay(1);
+ implicitCalendar.setTime(0, 0, 0);
+ break;
default:
}
implicitCalendar = implicitCalendar.normalize(); // the comparison routines will normalize it anyway, just do it once here
@@ -401,11 +426,11 @@ public ComputableValue plus(ComputableValue other) throws XPathException {
}
public ComputableValue mult(ComputableValue other) throws XPathException {
- throw new XPathException(getExpression(), "multiplication is not supported for type " + Type.getTypeName(getType()));
+ throw new XPathException(getExpression(), ErrorCodes.XPTY0004, "multiplication is not supported for type " + Type.getTypeName(getType()));
}
public ComputableValue div(ComputableValue other) throws XPathException {
- throw new XPathException(getExpression(), "division is not supported for type " + Type.getTypeName(getType()));
+ throw new XPathException(getExpression(), ErrorCodes.XPTY0004, "division is not supported for type " + Type.getTypeName(getType()));
}
public int conversionPreference(Class> javaClass) {
diff --git a/exist-core/src/main/java/org/exist/xquery/value/AtomicValue.java b/exist-core/src/main/java/org/exist/xquery/value/AtomicValue.java
index cc6331f4017..568b57080b0 100644
--- a/exist-core/src/main/java/org/exist/xquery/value/AtomicValue.java
+++ b/exist-core/src/main/java/org/exist/xquery/value/AtomicValue.java
@@ -230,14 +230,14 @@ public NodeSet toNodeSet() throws XPathException {
if (!effectiveBooleanValue())
return NodeSet.EMPTY_SET;
*/
- throw new XPathException(getExpression(),
+ throw new XPathException(getExpression(), ErrorCodes.XPTY0019,
"cannot convert " + Type.getTypeName(getType()) + "('" + getStringValue() + "')"
+ " to a node set");
}
@Override
public MemoryNodeSet toMemNodeSet() throws XPathException {
- throw new XPathException(getExpression(),
+ throw new XPathException(getExpression(), ErrorCodes.XPTY0019,
"cannot convert " + Type.getTypeName(getType()) + "('" + getStringValue() + "')"
+ " to a node set");
}
diff --git a/exist-core/src/main/java/org/exist/xquery/value/AtomicValueComparator.java b/exist-core/src/main/java/org/exist/xquery/value/AtomicValueComparator.java
index 1eda756ca15..48059df743e 100644
--- a/exist-core/src/main/java/org/exist/xquery/value/AtomicValueComparator.java
+++ b/exist-core/src/main/java/org/exist/xquery/value/AtomicValueComparator.java
@@ -74,7 +74,9 @@ public int compare(final AtomicValue o1, final AtomicValue o2) {
return o1.compareTo(collator, o2);
} catch (final XPathException e) {
LOG.error(e.getMessage(), e);
- throw new ClassCastException(e.getMessage());
+ final ClassCastException cce = new ClassCastException(e.getMessage());
+ cce.initCause(e);
+ throw cce;
}
}
}
diff --git a/exist-core/src/main/java/org/exist/xquery/value/Base64BinaryValueType.java b/exist-core/src/main/java/org/exist/xquery/value/Base64BinaryValueType.java
index 3e96607b5a8..9f6c1027a77 100644
--- a/exist-core/src/main/java/org/exist/xquery/value/Base64BinaryValueType.java
+++ b/exist-core/src/main/java/org/exist/xquery/value/Base64BinaryValueType.java
@@ -22,6 +22,7 @@
package org.exist.xquery.value;
import org.exist.util.io.Base64OutputStream;
+import org.exist.xquery.ErrorCodes;
import org.exist.xquery.Expression;
import org.exist.xquery.XPathException;
@@ -50,7 +51,7 @@ private Matcher getMatcher(final String toMatch) {
@Override
public void verifyString(String str) throws XPathException {
if (!getMatcher(str).matches()) {
- throw new XPathException((Expression) null, "FORG0001: Invalid base64 data");
+ throw new XPathException((Expression) null, ErrorCodes.FORG0001, "Invalid base64 data");
}
}
diff --git a/exist-core/src/main/java/org/exist/xquery/value/DayTimeDurationValue.java b/exist-core/src/main/java/org/exist/xquery/value/DayTimeDurationValue.java
index cf960f108f2..59a7e09c953 100644
--- a/exist-core/src/main/java/org/exist/xquery/value/DayTimeDurationValue.java
+++ b/exist-core/src/main/java/org/exist/xquery/value/DayTimeDurationValue.java
@@ -216,7 +216,7 @@ public ComputableValue plus(ComputableValue other) throws XPathException {
try {
return super.plus(other);
} catch (IllegalArgumentException e) {
- throw new XPathException(getExpression(), "Operand to plus should be of type xdt:dayTimeDuration, xs:time, " +
+ throw new XPathException(getExpression(), ErrorCodes.XPTY0004, "Operand to plus should be of type xdt:dayTimeDuration, xs:time, " +
"xs:date or xs:dateTime; got: " +
Type.getTypeName(other.getType()));
}
diff --git a/exist-core/src/main/java/org/exist/xquery/value/DecimalValue.java b/exist-core/src/main/java/org/exist/xquery/value/DecimalValue.java
index d69144666b9..0beeaf53d6b 100644
--- a/exist-core/src/main/java/org/exist/xquery/value/DecimalValue.java
+++ b/exist-core/src/main/java/org/exist/xquery/value/DecimalValue.java
@@ -256,7 +256,7 @@ public AtomicValue convertTo(final int requiredType) throws XPathException {
case Type.UNSIGNED_SHORT:
case Type.UNSIGNED_BYTE:
case Type.POSITIVE_INTEGER:
- return new IntegerValue(getExpression(), value.longValue(), requiredType);
+ return new IntegerValue(getExpression(), value.toBigInteger(), requiredType);
case Type.BOOLEAN:
return value.signum() == 0 ? BooleanValue.FALSE : BooleanValue.TRUE;
default:
diff --git a/exist-core/src/main/java/org/exist/xquery/value/DurationValue.java b/exist-core/src/main/java/org/exist/xquery/value/DurationValue.java
index 192d8bf8537..536850d8486 100644
--- a/exist-core/src/main/java/org/exist/xquery/value/DurationValue.java
+++ b/exist-core/src/main/java/org/exist/xquery/value/DurationValue.java
@@ -214,7 +214,7 @@ protected BigDecimal secondsValue() {
).add(zeroIfNull((BigDecimal) duration.getField(DatatypeConstants.SECONDS)));
}
- protected BigDecimal secondsValueSigned() {
+ public BigDecimal secondsValueSigned() {
BigDecimal x = secondsValue();
if (duration.getSign() < 0) {
x = x.negate();
@@ -229,7 +229,7 @@ protected BigInteger monthsValue() {
.add(zeroIfNull((BigInteger) duration.getField(DatatypeConstants.MONTHS)));
}
- protected BigInteger monthsValueSigned() {
+ public BigInteger monthsValueSigned() {
BigInteger x = monthsValue();
if (duration.getSign() < 0) {
x = x.negate();
diff --git a/exist-core/src/main/java/org/exist/xquery/value/SequenceComparator.java b/exist-core/src/main/java/org/exist/xquery/value/SequenceComparator.java
index a64b1e65692..39eeb0e4ddb 100644
--- a/exist-core/src/main/java/org/exist/xquery/value/SequenceComparator.java
+++ b/exist-core/src/main/java/org/exist/xquery/value/SequenceComparator.java
@@ -71,7 +71,7 @@ public int compare(final Sequence o1, final Sequence o2) {
}
final int o1Count = o1.getItemCount();
- final int o2Count = o1.getItemCount();
+ final int o2Count = o2.getItemCount();
if (o1Count < o2Count) {
return -1;
diff --git a/exist-core/src/main/java/org/exist/xquery/value/StringValue.java b/exist-core/src/main/java/org/exist/xquery/value/StringValue.java
index 9b2fccf0c83..10e28830e79 100644
--- a/exist-core/src/main/java/org/exist/xquery/value/StringValue.java
+++ b/exist-core/src/main/java/org/exist/xquery/value/StringValue.java
@@ -134,40 +134,27 @@ public static String collapseWhitespace(final CharSequence in) {
if (in.isEmpty()) {
return in.toString();
}
- int i = 0;
- // this method is performance critical, so first test if we need to collapse at all
- for (; i < in.length(); i++) {
- final char c = in.charAt(i);
- if (XMLChar.isSpace(c)) {
- if (i + 1 < in.length() && XMLChar.isSpace(in.charAt(i + 1))) {
- break;
- }
- }
- }
- if (i == in.length()) {
- // no whitespace to collapse, just return
- return in.toString();
- }
-
- // start to collapse whitespace
+ // XML Schema "collapse" facet:
+ // 1. Replace #x9, #xA, #xD with #x20
+ // 2. Collapse consecutive #x20 to single #x20
+ // 3. Strip leading and trailing #x20
final StringBuilder sb = new StringBuilder(in.length());
- sb.append(in.subSequence(0, i + 1));
- boolean inWhitespace = true;
- for (; i < in.length(); i++) {
+ boolean lastWasSpace = true; // treat start as space to strip leading
+ for (int i = 0; i < in.length(); i++) {
final char c = in.charAt(i);
if (XMLChar.isSpace(c)) {
- if (inWhitespace) {
- // remove the whitespace
- } else {
+ if (!lastWasSpace) {
sb.append(' ');
- inWhitespace = true;
+ lastWasSpace = true;
}
+ // else: skip consecutive whitespace
} else {
sb.append(c);
- inWhitespace = false;
+ lastWasSpace = false;
}
}
- if (sb.charAt(sb.length() - 1) == ' ') {
+ // Strip trailing space
+ if (sb.length() > 0 && sb.charAt(sb.length() - 1) == ' ') {
sb.deleteCharAt(sb.length() - 1);
}
return sb.toString();
@@ -379,15 +366,14 @@ private void checkType() throws XPathException {
case Type.LANGUAGE:
final Matcher matcher = langPattern.matcher(value);
if (!matcher.matches()) {
- throw new XPathException(getExpression(),
- "Type error: string "
- + value
- + " is not valid for type xs:language");
+ throw new XPathException(getExpression(), ErrorCodes.FORG0001,
+ "String '" + value + "' is not valid for type xs:language");
}
return;
case Type.NAME:
if (QName.isQName(value) != VALID.val) {
- throw new XPathException(getExpression(), "Type error: string " + value + " is not a valid xs:Name");
+ throw new XPathException(getExpression(), ErrorCodes.FORG0001,
+ "String '" + value + "' is not a valid xs:Name");
}
return;
case Type.NCNAME:
@@ -395,12 +381,14 @@ private void checkType() throws XPathException {
case Type.IDREF:
case Type.ENTITY:
if (!XMLNames.isNCName(value)) {
- throw new XPathException(getExpression(), "Type error: string " + value + " is not a valid " + Type.getTypeName(type));
+ throw new XPathException(getExpression(), ErrorCodes.FORG0001,
+ "String '" + value + "' is not a valid " + Type.getTypeName(type));
}
return;
case Type.NMTOKEN:
if (!XMLNames.isNmToken(value)) {
- throw new XPathException(getExpression(), "Type error: string " + value + " is not a valid xs:NMTOKEN");
+ throw new XPathException(getExpression(), ErrorCodes.FORG0001,
+ "String '" + value + "' is not a valid xs:NMTOKEN");
}
}
}
diff --git a/exist-core/src/main/resources/org/exist/xquery/functions/fn/html5-entities.properties b/exist-core/src/main/resources/org/exist/xquery/functions/fn/html5-entities.properties
new file mode 100644
index 00000000000..6c8b0f49aed
--- /dev/null
+++ b/exist-core/src/main/resources/org/exist/xquery/functions/fn/html5-entities.properties
@@ -0,0 +1,2255 @@
+#
+# eXist-db Open Source Native XML Database
+# Copyright (C) 2001 The eXist-db Authors
+#
+# info@exist-db.org
+# http://www.exist-db.org
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+#
+
+# HTML5 Named Character References
+# Generated from https://html.spec.whatwg.org/entities.json
+AElig=U+00C6
+AElig=U+00C6
+AMP=U+0026
+AMP=U+0026
+Aacute=U+00C1
+Aacute=U+00C1
+Abreve=U+0102
+Acirc=U+00C2
+Acirc=U+00C2
+Acy=U+0410
+Afr=U+1D504
+Agrave=U+00C0
+Agrave=U+00C0
+Alpha=U+0391
+Amacr=U+0100
+And=U+2A53
+Aogon=U+0104
+Aopf=U+1D538
+ApplyFunction=U+2061
+Aring=U+00C5
+Aring=U+00C5
+Ascr=U+1D49C
+Assign=U+2254
+Atilde=U+00C3
+Atilde=U+00C3
+Auml=U+00C4
+Auml=U+00C4
+Backslash=U+2216
+Barv=U+2AE7
+Barwed=U+2306
+Bcy=U+0411
+Because=U+2235
+Bernoullis=U+212C
+Beta=U+0392
+Bfr=U+1D505
+Bopf=U+1D539
+Breve=U+02D8
+Bscr=U+212C
+Bumpeq=U+224E
+CHcy=U+0427
+COPY=U+00A9
+COPY=U+00A9
+Cacute=U+0106
+Cap=U+22D2
+CapitalDifferentialD=U+2145
+Cayleys=U+212D
+Ccaron=U+010C
+Ccedil=U+00C7
+Ccedil=U+00C7
+Ccirc=U+0108
+Cconint=U+2230
+Cdot=U+010A
+Cedilla=U+00B8
+CenterDot=U+00B7
+Cfr=U+212D
+Chi=U+03A7
+CircleDot=U+2299
+CircleMinus=U+2296
+CirclePlus=U+2295
+CircleTimes=U+2297
+ClockwiseContourIntegral=U+2232
+CloseCurlyDoubleQuote=U+201D
+CloseCurlyQuote=U+2019
+Colon=U+2237
+Colone=U+2A74
+Congruent=U+2261
+Conint=U+222F
+ContourIntegral=U+222E
+Copf=U+2102
+Coproduct=U+2210
+CounterClockwiseContourIntegral=U+2233
+Cross=U+2A2F
+Cscr=U+1D49E
+Cup=U+22D3
+CupCap=U+224D
+DD=U+2145
+DDotrahd=U+2911
+DJcy=U+0402
+DScy=U+0405
+DZcy=U+040F
+Dagger=U+2021
+Darr=U+21A1
+Dashv=U+2AE4
+Dcaron=U+010E
+Dcy=U+0414
+Del=U+2207
+Delta=U+0394
+Dfr=U+1D507
+DiacriticalAcute=U+00B4
+DiacriticalDot=U+02D9
+DiacriticalDoubleAcute=U+02DD
+DiacriticalGrave=U+0060
+DiacriticalTilde=U+02DC
+Diamond=U+22C4
+DifferentialD=U+2146
+Dopf=U+1D53B
+Dot=U+00A8
+DotDot=U+20DC
+DotEqual=U+2250
+DoubleContourIntegral=U+222F
+DoubleDot=U+00A8
+DoubleDownArrow=U+21D3
+DoubleLeftArrow=U+21D0
+DoubleLeftRightArrow=U+21D4
+DoubleLeftTee=U+2AE4
+DoubleLongLeftArrow=U+27F8
+DoubleLongLeftRightArrow=U+27FA
+DoubleLongRightArrow=U+27F9
+DoubleRightArrow=U+21D2
+DoubleRightTee=U+22A8
+DoubleUpArrow=U+21D1
+DoubleUpDownArrow=U+21D5
+DoubleVerticalBar=U+2225
+DownArrow=U+2193
+DownArrowBar=U+2913
+DownArrowUpArrow=U+21F5
+DownBreve=U+0311
+DownLeftRightVector=U+2950
+DownLeftTeeVector=U+295E
+DownLeftVector=U+21BD
+DownLeftVectorBar=U+2956
+DownRightTeeVector=U+295F
+DownRightVector=U+21C1
+DownRightVectorBar=U+2957
+DownTee=U+22A4
+DownTeeArrow=U+21A7
+Downarrow=U+21D3
+Dscr=U+1D49F
+Dstrok=U+0110
+ENG=U+014A
+ETH=U+00D0
+ETH=U+00D0
+Eacute=U+00C9
+Eacute=U+00C9
+Ecaron=U+011A
+Ecirc=U+00CA
+Ecirc=U+00CA
+Ecy=U+042D
+Edot=U+0116
+Efr=U+1D508
+Egrave=U+00C8
+Egrave=U+00C8
+Element=U+2208
+Emacr=U+0112
+EmptySmallSquare=U+25FB
+EmptyVerySmallSquare=U+25AB
+Eogon=U+0118
+Eopf=U+1D53C
+Epsilon=U+0395
+Equal=U+2A75
+EqualTilde=U+2242
+Equilibrium=U+21CC
+Escr=U+2130
+Esim=U+2A73
+Eta=U+0397
+Euml=U+00CB
+Euml=U+00CB
+Exists=U+2203
+ExponentialE=U+2147
+Fcy=U+0424
+Ffr=U+1D509
+FilledSmallSquare=U+25FC
+FilledVerySmallSquare=U+25AA
+Fopf=U+1D53D
+ForAll=U+2200
+Fouriertrf=U+2131
+Fscr=U+2131
+GJcy=U+0403
+GT=U+003E
+GT=U+003E
+Gamma=U+0393
+Gammad=U+03DC
+Gbreve=U+011E
+Gcedil=U+0122
+Gcirc=U+011C
+Gcy=U+0413
+Gdot=U+0120
+Gfr=U+1D50A
+Gg=U+22D9
+Gopf=U+1D53E
+GreaterEqual=U+2265
+GreaterEqualLess=U+22DB
+GreaterFullEqual=U+2267
+GreaterGreater=U+2AA2
+GreaterLess=U+2277
+GreaterSlantEqual=U+2A7E
+GreaterTilde=U+2273
+Gscr=U+1D4A2
+Gt=U+226B
+HARDcy=U+042A
+Hacek=U+02C7
+Hat=U+005E
+Hcirc=U+0124
+Hfr=U+210C
+HilbertSpace=U+210B
+Hopf=U+210D
+HorizontalLine=U+2500
+Hscr=U+210B
+Hstrok=U+0126
+HumpDownHump=U+224E
+HumpEqual=U+224F
+IEcy=U+0415
+IJlig=U+0132
+IOcy=U+0401
+Iacute=U+00CD
+Iacute=U+00CD
+Icirc=U+00CE
+Icirc=U+00CE
+Icy=U+0418
+Idot=U+0130
+Ifr=U+2111
+Igrave=U+00CC
+Igrave=U+00CC
+Im=U+2111
+Imacr=U+012A
+ImaginaryI=U+2148
+Implies=U+21D2
+Int=U+222C
+Integral=U+222B
+Intersection=U+22C2
+InvisibleComma=U+2063
+InvisibleTimes=U+2062
+Iogon=U+012E
+Iopf=U+1D540
+Iota=U+0399
+Iscr=U+2110
+Itilde=U+0128
+Iukcy=U+0406
+Iuml=U+00CF
+Iuml=U+00CF
+Jcirc=U+0134
+Jcy=U+0419
+Jfr=U+1D50D
+Jopf=U+1D541
+Jscr=U+1D4A5
+Jsercy=U+0408
+Jukcy=U+0404
+KHcy=U+0425
+KJcy=U+040C
+Kappa=U+039A
+Kcedil=U+0136
+Kcy=U+041A
+Kfr=U+1D50E
+Kopf=U+1D542
+Kscr=U+1D4A6
+LJcy=U+0409
+LT=U+003C
+LT=U+003C
+Lacute=U+0139
+Lambda=U+039B
+Lang=U+27EA
+Laplacetrf=U+2112
+Larr=U+219E
+Lcaron=U+013D
+Lcedil=U+013B
+Lcy=U+041B
+LeftAngleBracket=U+27E8
+LeftArrow=U+2190
+LeftArrowBar=U+21E4
+LeftArrowRightArrow=U+21C6
+LeftCeiling=U+2308
+LeftDoubleBracket=U+27E6
+LeftDownTeeVector=U+2961
+LeftDownVector=U+21C3
+LeftDownVectorBar=U+2959
+LeftFloor=U+230A
+LeftRightArrow=U+2194
+LeftRightVector=U+294E
+LeftTee=U+22A3
+LeftTeeArrow=U+21A4
+LeftTeeVector=U+295A
+LeftTriangle=U+22B2
+LeftTriangleBar=U+29CF
+LeftTriangleEqual=U+22B4
+LeftUpDownVector=U+2951
+LeftUpTeeVector=U+2960
+LeftUpVector=U+21BF
+LeftUpVectorBar=U+2958
+LeftVector=U+21BC
+LeftVectorBar=U+2952
+Leftarrow=U+21D0
+Leftrightarrow=U+21D4
+LessEqualGreater=U+22DA
+LessFullEqual=U+2266
+LessGreater=U+2276
+LessLess=U+2AA1
+LessSlantEqual=U+2A7D
+LessTilde=U+2272
+Lfr=U+1D50F
+Ll=U+22D8
+Lleftarrow=U+21DA
+Lmidot=U+013F
+LongLeftArrow=U+27F5
+LongLeftRightArrow=U+27F7
+LongRightArrow=U+27F6
+Longleftarrow=U+27F8
+Longleftrightarrow=U+27FA
+Longrightarrow=U+27F9
+Lopf=U+1D543
+LowerLeftArrow=U+2199
+LowerRightArrow=U+2198
+Lscr=U+2112
+Lsh=U+21B0
+Lstrok=U+0141
+Lt=U+226A
+Map=U+2905
+Mcy=U+041C
+MediumSpace=U+205F
+Mellintrf=U+2133
+Mfr=U+1D510
+MinusPlus=U+2213
+Mopf=U+1D544
+Mscr=U+2133
+Mu=U+039C
+NJcy=U+040A
+Nacute=U+0143
+Ncaron=U+0147
+Ncedil=U+0145
+Ncy=U+041D
+NegativeMediumSpace=U+200B
+NegativeThickSpace=U+200B
+NegativeThinSpace=U+200B
+NegativeVeryThinSpace=U+200B
+NestedGreaterGreater=U+226B
+NestedLessLess=U+226A
+NewLine=U+000A
+Nfr=U+1D511
+NoBreak=U+2060
+NonBreakingSpace=U+00A0
+Nopf=U+2115
+Not=U+2AEC
+NotCongruent=U+2262
+NotCupCap=U+226D
+NotDoubleVerticalBar=U+2226
+NotElement=U+2209
+NotEqual=U+2260
+NotEqualTilde=U+2242,U+0338
+NotExists=U+2204
+NotGreater=U+226F
+NotGreaterEqual=U+2271
+NotGreaterFullEqual=U+2267,U+0338
+NotGreaterGreater=U+226B,U+0338
+NotGreaterLess=U+2279
+NotGreaterSlantEqual=U+2A7E,U+0338
+NotGreaterTilde=U+2275
+NotHumpDownHump=U+224E,U+0338
+NotHumpEqual=U+224F,U+0338
+NotLeftTriangle=U+22EA
+NotLeftTriangleBar=U+29CF,U+0338
+NotLeftTriangleEqual=U+22EC
+NotLess=U+226E
+NotLessEqual=U+2270
+NotLessGreater=U+2278
+NotLessLess=U+226A,U+0338
+NotLessSlantEqual=U+2A7D,U+0338
+NotLessTilde=U+2274
+NotNestedGreaterGreater=U+2AA2,U+0338
+NotNestedLessLess=U+2AA1,U+0338
+NotPrecedes=U+2280
+NotPrecedesEqual=U+2AAF,U+0338
+NotPrecedesSlantEqual=U+22E0
+NotReverseElement=U+220C
+NotRightTriangle=U+22EB
+NotRightTriangleBar=U+29D0,U+0338
+NotRightTriangleEqual=U+22ED
+NotSquareSubset=U+228F,U+0338
+NotSquareSubsetEqual=U+22E2
+NotSquareSuperset=U+2290,U+0338
+NotSquareSupersetEqual=U+22E3
+NotSubset=U+2282,U+20D2
+NotSubsetEqual=U+2288
+NotSucceeds=U+2281
+NotSucceedsEqual=U+2AB0,U+0338
+NotSucceedsSlantEqual=U+22E1
+NotSucceedsTilde=U+227F,U+0338
+NotSuperset=U+2283,U+20D2
+NotSupersetEqual=U+2289
+NotTilde=U+2241
+NotTildeEqual=U+2244
+NotTildeFullEqual=U+2247
+NotTildeTilde=U+2249
+NotVerticalBar=U+2224
+Nscr=U+1D4A9
+Ntilde=U+00D1
+Ntilde=U+00D1
+Nu=U+039D
+OElig=U+0152
+Oacute=U+00D3
+Oacute=U+00D3
+Ocirc=U+00D4
+Ocirc=U+00D4
+Ocy=U+041E
+Odblac=U+0150
+Ofr=U+1D512
+Ograve=U+00D2
+Ograve=U+00D2
+Omacr=U+014C
+Omega=U+03A9
+Omicron=U+039F
+Oopf=U+1D546
+OpenCurlyDoubleQuote=U+201C
+OpenCurlyQuote=U+2018
+Or=U+2A54
+Oscr=U+1D4AA
+Oslash=U+00D8
+Oslash=U+00D8
+Otilde=U+00D5
+Otilde=U+00D5
+Otimes=U+2A37
+Ouml=U+00D6
+Ouml=U+00D6
+OverBar=U+203E
+OverBrace=U+23DE
+OverBracket=U+23B4
+OverParenthesis=U+23DC
+PartialD=U+2202
+Pcy=U+041F
+Pfr=U+1D513
+Phi=U+03A6
+Pi=U+03A0
+PlusMinus=U+00B1
+Poincareplane=U+210C
+Popf=U+2119
+Pr=U+2ABB
+Precedes=U+227A
+PrecedesEqual=U+2AAF
+PrecedesSlantEqual=U+227C
+PrecedesTilde=U+227E
+Prime=U+2033
+Product=U+220F
+Proportion=U+2237
+Proportional=U+221D
+Pscr=U+1D4AB
+Psi=U+03A8
+QUOT=U+0022
+QUOT=U+0022
+Qfr=U+1D514
+Qopf=U+211A
+Qscr=U+1D4AC
+RBarr=U+2910
+REG=U+00AE
+REG=U+00AE
+Racute=U+0154
+Rang=U+27EB
+Rarr=U+21A0
+Rarrtl=U+2916
+Rcaron=U+0158
+Rcedil=U+0156
+Rcy=U+0420
+Re=U+211C
+ReverseElement=U+220B
+ReverseEquilibrium=U+21CB
+ReverseUpEquilibrium=U+296F
+Rfr=U+211C
+Rho=U+03A1
+RightAngleBracket=U+27E9
+RightArrow=U+2192
+RightArrowBar=U+21E5
+RightArrowLeftArrow=U+21C4
+RightCeiling=U+2309
+RightDoubleBracket=U+27E7
+RightDownTeeVector=U+295D
+RightDownVector=U+21C2
+RightDownVectorBar=U+2955
+RightFloor=U+230B
+RightTee=U+22A2
+RightTeeArrow=U+21A6
+RightTeeVector=U+295B
+RightTriangle=U+22B3
+RightTriangleBar=U+29D0
+RightTriangleEqual=U+22B5
+RightUpDownVector=U+294F
+RightUpTeeVector=U+295C
+RightUpVector=U+21BE
+RightUpVectorBar=U+2954
+RightVector=U+21C0
+RightVectorBar=U+2953
+Rightarrow=U+21D2
+Ropf=U+211D
+RoundImplies=U+2970
+Rrightarrow=U+21DB
+Rscr=U+211B
+Rsh=U+21B1
+RuleDelayed=U+29F4
+SHCHcy=U+0429
+SHcy=U+0428
+SOFTcy=U+042C
+Sacute=U+015A
+Sc=U+2ABC
+Scaron=U+0160
+Scedil=U+015E
+Scirc=U+015C
+Scy=U+0421
+Sfr=U+1D516
+ShortDownArrow=U+2193
+ShortLeftArrow=U+2190
+ShortRightArrow=U+2192
+ShortUpArrow=U+2191
+Sigma=U+03A3
+SmallCircle=U+2218
+Sopf=U+1D54A
+Sqrt=U+221A
+Square=U+25A1
+SquareIntersection=U+2293
+SquareSubset=U+228F
+SquareSubsetEqual=U+2291
+SquareSuperset=U+2290
+SquareSupersetEqual=U+2292
+SquareUnion=U+2294
+Sscr=U+1D4AE
+Star=U+22C6
+Sub=U+22D0
+Subset=U+22D0
+SubsetEqual=U+2286
+Succeeds=U+227B
+SucceedsEqual=U+2AB0
+SucceedsSlantEqual=U+227D
+SucceedsTilde=U+227F
+SuchThat=U+220B
+Sum=U+2211
+Sup=U+22D1
+Superset=U+2283
+SupersetEqual=U+2287
+Supset=U+22D1
+THORN=U+00DE
+THORN=U+00DE
+TRADE=U+2122
+TSHcy=U+040B
+TScy=U+0426
+Tab=U+0009
+Tau=U+03A4
+Tcaron=U+0164
+Tcedil=U+0162
+Tcy=U+0422
+Tfr=U+1D517
+Therefore=U+2234
+Theta=U+0398
+ThickSpace=U+205F,U+200A
+ThinSpace=U+2009
+Tilde=U+223C
+TildeEqual=U+2243
+TildeFullEqual=U+2245
+TildeTilde=U+2248
+Topf=U+1D54B
+TripleDot=U+20DB
+Tscr=U+1D4AF
+Tstrok=U+0166
+Uacute=U+00DA
+Uacute=U+00DA
+Uarr=U+219F
+Uarrocir=U+2949
+Ubrcy=U+040E
+Ubreve=U+016C
+Ucirc=U+00DB
+Ucirc=U+00DB
+Ucy=U+0423
+Udblac=U+0170
+Ufr=U+1D518
+Ugrave=U+00D9
+Ugrave=U+00D9
+Umacr=U+016A
+UnderBar=U+005F
+UnderBrace=U+23DF
+UnderBracket=U+23B5
+UnderParenthesis=U+23DD
+Union=U+22C3
+UnionPlus=U+228E
+Uogon=U+0172
+Uopf=U+1D54C
+UpArrow=U+2191
+UpArrowBar=U+2912
+UpArrowDownArrow=U+21C5
+UpDownArrow=U+2195
+UpEquilibrium=U+296E
+UpTee=U+22A5
+UpTeeArrow=U+21A5
+Uparrow=U+21D1
+Updownarrow=U+21D5
+UpperLeftArrow=U+2196
+UpperRightArrow=U+2197
+Upsi=U+03D2
+Upsilon=U+03A5
+Uring=U+016E
+Uscr=U+1D4B0
+Utilde=U+0168
+Uuml=U+00DC
+Uuml=U+00DC
+VDash=U+22AB
+Vbar=U+2AEB
+Vcy=U+0412
+Vdash=U+22A9
+Vdashl=U+2AE6
+Vee=U+22C1
+Verbar=U+2016
+Vert=U+2016
+VerticalBar=U+2223
+VerticalLine=U+007C
+VerticalSeparator=U+2758
+VerticalTilde=U+2240
+VeryThinSpace=U+200A
+Vfr=U+1D519
+Vopf=U+1D54D
+Vscr=U+1D4B1
+Vvdash=U+22AA
+Wcirc=U+0174
+Wedge=U+22C0
+Wfr=U+1D51A
+Wopf=U+1D54E
+Wscr=U+1D4B2
+Xfr=U+1D51B
+Xi=U+039E
+Xopf=U+1D54F
+Xscr=U+1D4B3
+YAcy=U+042F
+YIcy=U+0407
+YUcy=U+042E
+Yacute=U+00DD
+Yacute=U+00DD
+Ycirc=U+0176
+Ycy=U+042B
+Yfr=U+1D51C
+Yopf=U+1D550
+Yscr=U+1D4B4
+Yuml=U+0178
+ZHcy=U+0416
+Zacute=U+0179
+Zcaron=U+017D
+Zcy=U+0417
+Zdot=U+017B
+ZeroWidthSpace=U+200B
+Zeta=U+0396
+Zfr=U+2128
+Zopf=U+2124
+Zscr=U+1D4B5
+aacute=U+00E1
+aacute=U+00E1
+abreve=U+0103
+ac=U+223E
+acE=U+223E,U+0333
+acd=U+223F
+acirc=U+00E2
+acirc=U+00E2
+acute=U+00B4
+acute=U+00B4
+acy=U+0430
+aelig=U+00E6
+aelig=U+00E6
+af=U+2061
+afr=U+1D51E
+agrave=U+00E0
+agrave=U+00E0
+alefsym=U+2135
+aleph=U+2135
+alpha=U+03B1
+amacr=U+0101
+amalg=U+2A3F
+amp=U+0026
+amp=U+0026
+and=U+2227
+andand=U+2A55
+andd=U+2A5C
+andslope=U+2A58
+andv=U+2A5A
+ang=U+2220
+ange=U+29A4
+angle=U+2220
+angmsd=U+2221
+angmsdaa=U+29A8
+angmsdab=U+29A9
+angmsdac=U+29AA
+angmsdad=U+29AB
+angmsdae=U+29AC
+angmsdaf=U+29AD
+angmsdag=U+29AE
+angmsdah=U+29AF
+angrt=U+221F
+angrtvb=U+22BE
+angrtvbd=U+299D
+angsph=U+2222
+angst=U+00C5
+angzarr=U+237C
+aogon=U+0105
+aopf=U+1D552
+ap=U+2248
+apE=U+2A70
+apacir=U+2A6F
+ape=U+224A
+apid=U+224B
+apos=U+0027
+approx=U+2248
+approxeq=U+224A
+aring=U+00E5
+aring=U+00E5
+ascr=U+1D4B6
+ast=U+002A
+asymp=U+2248
+asympeq=U+224D
+atilde=U+00E3
+atilde=U+00E3
+auml=U+00E4
+auml=U+00E4
+awconint=U+2233
+awint=U+2A11
+bNot=U+2AED
+backcong=U+224C
+backepsilon=U+03F6
+backprime=U+2035
+backsim=U+223D
+backsimeq=U+22CD
+barvee=U+22BD
+barwed=U+2305
+barwedge=U+2305
+bbrk=U+23B5
+bbrktbrk=U+23B6
+bcong=U+224C
+bcy=U+0431
+bdquo=U+201E
+becaus=U+2235
+because=U+2235
+bemptyv=U+29B0
+bepsi=U+03F6
+bernou=U+212C
+beta=U+03B2
+beth=U+2136
+between=U+226C
+bfr=U+1D51F
+bigcap=U+22C2
+bigcirc=U+25EF
+bigcup=U+22C3
+bigodot=U+2A00
+bigoplus=U+2A01
+bigotimes=U+2A02
+bigsqcup=U+2A06
+bigstar=U+2605
+bigtriangledown=U+25BD
+bigtriangleup=U+25B3
+biguplus=U+2A04
+bigvee=U+22C1
+bigwedge=U+22C0
+bkarow=U+290D
+blacklozenge=U+29EB
+blacksquare=U+25AA
+blacktriangle=U+25B4
+blacktriangledown=U+25BE
+blacktriangleleft=U+25C2
+blacktriangleright=U+25B8
+blank=U+2423
+blk12=U+2592
+blk14=U+2591
+blk34=U+2593
+block=U+2588
+bne=U+003D,U+20E5
+bnequiv=U+2261,U+20E5
+bnot=U+2310
+bopf=U+1D553
+bot=U+22A5
+bottom=U+22A5
+bowtie=U+22C8
+boxDL=U+2557
+boxDR=U+2554
+boxDl=U+2556
+boxDr=U+2553
+boxH=U+2550
+boxHD=U+2566
+boxHU=U+2569
+boxHd=U+2564
+boxHu=U+2567
+boxUL=U+255D
+boxUR=U+255A
+boxUl=U+255C
+boxUr=U+2559
+boxV=U+2551
+boxVH=U+256C
+boxVL=U+2563
+boxVR=U+2560
+boxVh=U+256B
+boxVl=U+2562
+boxVr=U+255F
+boxbox=U+29C9
+boxdL=U+2555
+boxdR=U+2552
+boxdl=U+2510
+boxdr=U+250C
+boxh=U+2500
+boxhD=U+2565
+boxhU=U+2568
+boxhd=U+252C
+boxhu=U+2534
+boxminus=U+229F
+boxplus=U+229E
+boxtimes=U+22A0
+boxuL=U+255B
+boxuR=U+2558
+boxul=U+2518
+boxur=U+2514
+boxv=U+2502
+boxvH=U+256A
+boxvL=U+2561
+boxvR=U+255E
+boxvh=U+253C
+boxvl=U+2524
+boxvr=U+251C
+bprime=U+2035
+breve=U+02D8
+brvbar=U+00A6
+brvbar=U+00A6
+bscr=U+1D4B7
+bsemi=U+204F
+bsim=U+223D
+bsime=U+22CD
+bsol=U+005C
+bsolb=U+29C5
+bsolhsub=U+27C8
+bull=U+2022
+bullet=U+2022
+bump=U+224E
+bumpE=U+2AAE
+bumpe=U+224F
+bumpeq=U+224F
+cacute=U+0107
+cap=U+2229
+capand=U+2A44
+capbrcup=U+2A49
+capcap=U+2A4B
+capcup=U+2A47
+capdot=U+2A40
+caps=U+2229,U+FE00
+caret=U+2041
+caron=U+02C7
+ccaps=U+2A4D
+ccaron=U+010D
+ccedil=U+00E7
+ccedil=U+00E7
+ccirc=U+0109
+ccups=U+2A4C
+ccupssm=U+2A50
+cdot=U+010B
+cedil=U+00B8
+cedil=U+00B8
+cemptyv=U+29B2
+cent=U+00A2
+cent=U+00A2
+centerdot=U+00B7
+cfr=U+1D520
+chcy=U+0447
+check=U+2713
+checkmark=U+2713
+chi=U+03C7
+cir=U+25CB
+cirE=U+29C3
+circ=U+02C6
+circeq=U+2257
+circlearrowleft=U+21BA
+circlearrowright=U+21BB
+circledR=U+00AE
+circledS=U+24C8
+circledast=U+229B
+circledcirc=U+229A
+circleddash=U+229D
+cire=U+2257
+cirfnint=U+2A10
+cirmid=U+2AEF
+cirscir=U+29C2
+clubs=U+2663
+clubsuit=U+2663
+colon=U+003A
+colone=U+2254
+coloneq=U+2254
+comma=U+002C
+commat=U+0040
+comp=U+2201
+compfn=U+2218
+complement=U+2201
+complexes=U+2102
+cong=U+2245
+congdot=U+2A6D
+conint=U+222E
+copf=U+1D554
+coprod=U+2210
+copy=U+00A9
+copy=U+00A9
+copysr=U+2117
+crarr=U+21B5
+cross=U+2717
+cscr=U+1D4B8
+csub=U+2ACF
+csube=U+2AD1
+csup=U+2AD0
+csupe=U+2AD2
+ctdot=U+22EF
+cudarrl=U+2938
+cudarrr=U+2935
+cuepr=U+22DE
+cuesc=U+22DF
+cularr=U+21B6
+cularrp=U+293D
+cup=U+222A
+cupbrcap=U+2A48
+cupcap=U+2A46
+cupcup=U+2A4A
+cupdot=U+228D
+cupor=U+2A45
+cups=U+222A,U+FE00
+curarr=U+21B7
+curarrm=U+293C
+curlyeqprec=U+22DE
+curlyeqsucc=U+22DF
+curlyvee=U+22CE
+curlywedge=U+22CF
+curren=U+00A4
+curren=U+00A4
+curvearrowleft=U+21B6
+curvearrowright=U+21B7
+cuvee=U+22CE
+cuwed=U+22CF
+cwconint=U+2232
+cwint=U+2231
+cylcty=U+232D
+dArr=U+21D3
+dHar=U+2965
+dagger=U+2020
+daleth=U+2138
+darr=U+2193
+dash=U+2010
+dashv=U+22A3
+dbkarow=U+290F
+dblac=U+02DD
+dcaron=U+010F
+dcy=U+0434
+dd=U+2146
+ddagger=U+2021
+ddarr=U+21CA
+ddotseq=U+2A77
+deg=U+00B0
+deg=U+00B0
+delta=U+03B4
+demptyv=U+29B1
+dfisht=U+297F
+dfr=U+1D521
+dharl=U+21C3
+dharr=U+21C2
+diam=U+22C4
+diamond=U+22C4
+diamondsuit=U+2666
+diams=U+2666
+die=U+00A8
+digamma=U+03DD
+disin=U+22F2
+div=U+00F7
+divide=U+00F7
+divide=U+00F7
+divideontimes=U+22C7
+divonx=U+22C7
+djcy=U+0452
+dlcorn=U+231E
+dlcrop=U+230D
+dollar=U+0024
+dopf=U+1D555
+dot=U+02D9
+doteq=U+2250
+doteqdot=U+2251
+dotminus=U+2238
+dotplus=U+2214
+dotsquare=U+22A1
+doublebarwedge=U+2306
+downarrow=U+2193
+downdownarrows=U+21CA
+downharpoonleft=U+21C3
+downharpoonright=U+21C2
+drbkarow=U+2910
+drcorn=U+231F
+drcrop=U+230C
+dscr=U+1D4B9
+dscy=U+0455
+dsol=U+29F6
+dstrok=U+0111
+dtdot=U+22F1
+dtri=U+25BF
+dtrif=U+25BE
+duarr=U+21F5
+duhar=U+296F
+dwangle=U+29A6
+dzcy=U+045F
+dzigrarr=U+27FF
+eDDot=U+2A77
+eDot=U+2251
+eacute=U+00E9
+eacute=U+00E9
+easter=U+2A6E
+ecaron=U+011B
+ecir=U+2256
+ecirc=U+00EA
+ecirc=U+00EA
+ecolon=U+2255
+ecy=U+044D
+edot=U+0117
+ee=U+2147
+efDot=U+2252
+efr=U+1D522
+eg=U+2A9A
+egrave=U+00E8
+egrave=U+00E8
+egs=U+2A96
+egsdot=U+2A98
+el=U+2A99
+elinters=U+23E7
+ell=U+2113
+els=U+2A95
+elsdot=U+2A97
+emacr=U+0113
+empty=U+2205
+emptyset=U+2205
+emptyv=U+2205
+emsp13=U+2004
+emsp14=U+2005
+emsp=U+2003
+eng=U+014B
+ensp=U+2002
+eogon=U+0119
+eopf=U+1D556
+epar=U+22D5
+eparsl=U+29E3
+eplus=U+2A71
+epsi=U+03B5
+epsilon=U+03B5
+epsiv=U+03F5
+eqcirc=U+2256
+eqcolon=U+2255
+eqsim=U+2242
+eqslantgtr=U+2A96
+eqslantless=U+2A95
+equals=U+003D
+equest=U+225F
+equiv=U+2261
+equivDD=U+2A78
+eqvparsl=U+29E5
+erDot=U+2253
+erarr=U+2971
+escr=U+212F
+esdot=U+2250
+esim=U+2242
+eta=U+03B7
+eth=U+00F0
+eth=U+00F0
+euml=U+00EB
+euml=U+00EB
+euro=U+20AC
+excl=U+0021
+exist=U+2203
+expectation=U+2130
+exponentiale=U+2147
+fallingdotseq=U+2252
+fcy=U+0444
+female=U+2640
+ffilig=U+FB03
+fflig=U+FB00
+ffllig=U+FB04
+ffr=U+1D523
+filig=U+FB01
+fjlig=U+0066,U+006A
+flat=U+266D
+fllig=U+FB02
+fltns=U+25B1
+fnof=U+0192
+fopf=U+1D557
+forall=U+2200
+fork=U+22D4
+forkv=U+2AD9
+fpartint=U+2A0D
+frac12=U+00BD
+frac12=U+00BD
+frac13=U+2153
+frac14=U+00BC
+frac14=U+00BC
+frac15=U+2155
+frac16=U+2159
+frac18=U+215B
+frac23=U+2154
+frac25=U+2156
+frac34=U+00BE
+frac34=U+00BE
+frac35=U+2157
+frac38=U+215C
+frac45=U+2158
+frac56=U+215A
+frac58=U+215D
+frac78=U+215E
+frasl=U+2044
+frown=U+2322
+fscr=U+1D4BB
+gE=U+2267
+gEl=U+2A8C
+gacute=U+01F5
+gamma=U+03B3
+gammad=U+03DD
+gap=U+2A86
+gbreve=U+011F
+gcirc=U+011D
+gcy=U+0433
+gdot=U+0121
+ge=U+2265
+gel=U+22DB
+geq=U+2265
+geqq=U+2267
+geqslant=U+2A7E
+ges=U+2A7E
+gescc=U+2AA9
+gesdot=U+2A80
+gesdoto=U+2A82
+gesdotol=U+2A84
+gesl=U+22DB,U+FE00
+gesles=U+2A94
+gfr=U+1D524
+gg=U+226B
+ggg=U+22D9
+gimel=U+2137
+gjcy=U+0453
+gl=U+2277
+glE=U+2A92
+gla=U+2AA5
+glj=U+2AA4
+gnE=U+2269
+gnap=U+2A8A
+gnapprox=U+2A8A
+gne=U+2A88
+gneq=U+2A88
+gneqq=U+2269
+gnsim=U+22E7
+gopf=U+1D558
+grave=U+0060
+gscr=U+210A
+gsim=U+2273
+gsime=U+2A8E
+gsiml=U+2A90
+gt=U+003E
+gt=U+003E
+gtcc=U+2AA7
+gtcir=U+2A7A
+gtdot=U+22D7
+gtlPar=U+2995
+gtquest=U+2A7C
+gtrapprox=U+2A86
+gtrarr=U+2978
+gtrdot=U+22D7
+gtreqless=U+22DB
+gtreqqless=U+2A8C
+gtrless=U+2277
+gtrsim=U+2273
+gvertneqq=U+2269,U+FE00
+gvnE=U+2269,U+FE00
+hArr=U+21D4
+hairsp=U+200A
+half=U+00BD
+hamilt=U+210B
+hardcy=U+044A
+harr=U+2194
+harrcir=U+2948
+harrw=U+21AD
+hbar=U+210F
+hcirc=U+0125
+hearts=U+2665
+heartsuit=U+2665
+hellip=U+2026
+hercon=U+22B9
+hfr=U+1D525
+hksearow=U+2925
+hkswarow=U+2926
+hoarr=U+21FF
+homtht=U+223B
+hookleftarrow=U+21A9
+hookrightarrow=U+21AA
+hopf=U+1D559
+horbar=U+2015
+hscr=U+1D4BD
+hslash=U+210F
+hstrok=U+0127
+hybull=U+2043
+hyphen=U+2010
+iacute=U+00ED
+iacute=U+00ED
+ic=U+2063
+icirc=U+00EE
+icirc=U+00EE
+icy=U+0438
+iecy=U+0435
+iexcl=U+00A1
+iexcl=U+00A1
+iff=U+21D4
+ifr=U+1D526
+igrave=U+00EC
+igrave=U+00EC
+ii=U+2148
+iiiint=U+2A0C
+iiint=U+222D
+iinfin=U+29DC
+iiota=U+2129
+ijlig=U+0133
+imacr=U+012B
+image=U+2111
+imagline=U+2110
+imagpart=U+2111
+imath=U+0131
+imof=U+22B7
+imped=U+01B5
+in=U+2208
+incare=U+2105
+infin=U+221E
+infintie=U+29DD
+inodot=U+0131
+int=U+222B
+intcal=U+22BA
+integers=U+2124
+intercal=U+22BA
+intlarhk=U+2A17
+intprod=U+2A3C
+iocy=U+0451
+iogon=U+012F
+iopf=U+1D55A
+iota=U+03B9
+iprod=U+2A3C
+iquest=U+00BF
+iquest=U+00BF
+iscr=U+1D4BE
+isin=U+2208
+isinE=U+22F9
+isindot=U+22F5
+isins=U+22F4
+isinsv=U+22F3
+isinv=U+2208
+it=U+2062
+itilde=U+0129
+iukcy=U+0456
+iuml=U+00EF
+iuml=U+00EF
+jcirc=U+0135
+jcy=U+0439
+jfr=U+1D527
+jmath=U+0237
+jopf=U+1D55B
+jscr=U+1D4BF
+jsercy=U+0458
+jukcy=U+0454
+kappa=U+03BA
+kappav=U+03F0
+kcedil=U+0137
+kcy=U+043A
+kfr=U+1D528
+kgreen=U+0138
+khcy=U+0445
+kjcy=U+045C
+kopf=U+1D55C
+kscr=U+1D4C0
+lAarr=U+21DA
+lArr=U+21D0
+lAtail=U+291B
+lBarr=U+290E
+lE=U+2266
+lEg=U+2A8B
+lHar=U+2962
+lacute=U+013A
+laemptyv=U+29B4
+lagran=U+2112
+lambda=U+03BB
+lang=U+27E8
+langd=U+2991
+langle=U+27E8
+lap=U+2A85
+laquo=U+00AB
+laquo=U+00AB
+larr=U+2190
+larrb=U+21E4
+larrbfs=U+291F
+larrfs=U+291D
+larrhk=U+21A9
+larrlp=U+21AB
+larrpl=U+2939
+larrsim=U+2973
+larrtl=U+21A2
+lat=U+2AAB
+latail=U+2919
+late=U+2AAD
+lates=U+2AAD,U+FE00
+lbarr=U+290C
+lbbrk=U+2772
+lbrace=U+007B
+lbrack=U+005B
+lbrke=U+298B
+lbrksld=U+298F
+lbrkslu=U+298D
+lcaron=U+013E
+lcedil=U+013C
+lceil=U+2308
+lcub=U+007B
+lcy=U+043B
+ldca=U+2936
+ldquo=U+201C
+ldquor=U+201E
+ldrdhar=U+2967
+ldrushar=U+294B
+ldsh=U+21B2
+le=U+2264
+leftarrow=U+2190
+leftarrowtail=U+21A2
+leftharpoondown=U+21BD
+leftharpoonup=U+21BC
+leftleftarrows=U+21C7
+leftrightarrow=U+2194
+leftrightarrows=U+21C6
+leftrightharpoons=U+21CB
+leftrightsquigarrow=U+21AD
+leftthreetimes=U+22CB
+leg=U+22DA
+leq=U+2264
+leqq=U+2266
+leqslant=U+2A7D
+les=U+2A7D
+lescc=U+2AA8
+lesdot=U+2A7F
+lesdoto=U+2A81
+lesdotor=U+2A83
+lesg=U+22DA,U+FE00
+lesges=U+2A93
+lessapprox=U+2A85
+lessdot=U+22D6
+lesseqgtr=U+22DA
+lesseqqgtr=U+2A8B
+lessgtr=U+2276
+lesssim=U+2272
+lfisht=U+297C
+lfloor=U+230A
+lfr=U+1D529
+lg=U+2276
+lgE=U+2A91
+lhard=U+21BD
+lharu=U+21BC
+lharul=U+296A
+lhblk=U+2584
+ljcy=U+0459
+ll=U+226A
+llarr=U+21C7
+llcorner=U+231E
+llhard=U+296B
+lltri=U+25FA
+lmidot=U+0140
+lmoust=U+23B0
+lmoustache=U+23B0
+lnE=U+2268
+lnap=U+2A89
+lnapprox=U+2A89
+lne=U+2A87
+lneq=U+2A87
+lneqq=U+2268
+lnsim=U+22E6
+loang=U+27EC
+loarr=U+21FD
+lobrk=U+27E6
+longleftarrow=U+27F5
+longleftrightarrow=U+27F7
+longmapsto=U+27FC
+longrightarrow=U+27F6
+looparrowleft=U+21AB
+looparrowright=U+21AC
+lopar=U+2985
+lopf=U+1D55D
+loplus=U+2A2D
+lotimes=U+2A34
+lowast=U+2217
+lowbar=U+005F
+loz=U+25CA
+lozenge=U+25CA
+lozf=U+29EB
+lpar=U+0028
+lparlt=U+2993
+lrarr=U+21C6
+lrcorner=U+231F
+lrhar=U+21CB
+lrhard=U+296D
+lrm=U+200E
+lrtri=U+22BF
+lsaquo=U+2039
+lscr=U+1D4C1
+lsh=U+21B0
+lsim=U+2272
+lsime=U+2A8D
+lsimg=U+2A8F
+lsqb=U+005B
+lsquo=U+2018
+lsquor=U+201A
+lstrok=U+0142
+lt=U+003C
+lt=U+003C
+ltcc=U+2AA6
+ltcir=U+2A79
+ltdot=U+22D6
+lthree=U+22CB
+ltimes=U+22C9
+ltlarr=U+2976
+ltquest=U+2A7B
+ltrPar=U+2996
+ltri=U+25C3
+ltrie=U+22B4
+ltrif=U+25C2
+lurdshar=U+294A
+luruhar=U+2966
+lvertneqq=U+2268,U+FE00
+lvnE=U+2268,U+FE00
+mDDot=U+223A
+macr=U+00AF
+macr=U+00AF
+male=U+2642
+malt=U+2720
+maltese=U+2720
+map=U+21A6
+mapsto=U+21A6
+mapstodown=U+21A7
+mapstoleft=U+21A4
+mapstoup=U+21A5
+marker=U+25AE
+mcomma=U+2A29
+mcy=U+043C
+mdash=U+2014
+measuredangle=U+2221
+mfr=U+1D52A
+mho=U+2127
+micro=U+00B5
+micro=U+00B5
+mid=U+2223
+midast=U+002A
+midcir=U+2AF0
+middot=U+00B7
+middot=U+00B7
+minus=U+2212
+minusb=U+229F
+minusd=U+2238
+minusdu=U+2A2A
+mlcp=U+2ADB
+mldr=U+2026
+mnplus=U+2213
+models=U+22A7
+mopf=U+1D55E
+mp=U+2213
+mscr=U+1D4C2
+mstpos=U+223E
+mu=U+03BC
+multimap=U+22B8
+mumap=U+22B8
+nGg=U+22D9,U+0338
+nGt=U+226B,U+20D2
+nGtv=U+226B,U+0338
+nLeftarrow=U+21CD
+nLeftrightarrow=U+21CE
+nLl=U+22D8,U+0338
+nLt=U+226A,U+20D2
+nLtv=U+226A,U+0338
+nRightarrow=U+21CF
+nVDash=U+22AF
+nVdash=U+22AE
+nabla=U+2207
+nacute=U+0144
+nang=U+2220,U+20D2
+nap=U+2249
+napE=U+2A70,U+0338
+napid=U+224B,U+0338
+napos=U+0149
+napprox=U+2249
+natur=U+266E
+natural=U+266E
+naturals=U+2115
+nbsp=U+00A0
+nbsp=U+00A0
+nbump=U+224E,U+0338
+nbumpe=U+224F,U+0338
+ncap=U+2A43
+ncaron=U+0148
+ncedil=U+0146
+ncong=U+2247
+ncongdot=U+2A6D,U+0338
+ncup=U+2A42
+ncy=U+043D
+ndash=U+2013
+ne=U+2260
+neArr=U+21D7
+nearhk=U+2924
+nearr=U+2197
+nearrow=U+2197
+nedot=U+2250,U+0338
+nequiv=U+2262
+nesear=U+2928
+nesim=U+2242,U+0338
+nexist=U+2204
+nexists=U+2204
+nfr=U+1D52B
+ngE=U+2267,U+0338
+nge=U+2271
+ngeq=U+2271
+ngeqq=U+2267,U+0338
+ngeqslant=U+2A7E,U+0338
+nges=U+2A7E,U+0338
+ngsim=U+2275
+ngt=U+226F
+ngtr=U+226F
+nhArr=U+21CE
+nharr=U+21AE
+nhpar=U+2AF2
+ni=U+220B
+nis=U+22FC
+nisd=U+22FA
+niv=U+220B
+njcy=U+045A
+nlArr=U+21CD
+nlE=U+2266,U+0338
+nlarr=U+219A
+nldr=U+2025
+nle=U+2270
+nleftarrow=U+219A
+nleftrightarrow=U+21AE
+nleq=U+2270
+nleqq=U+2266,U+0338
+nleqslant=U+2A7D,U+0338
+nles=U+2A7D,U+0338
+nless=U+226E
+nlsim=U+2274
+nlt=U+226E
+nltri=U+22EA
+nltrie=U+22EC
+nmid=U+2224
+nopf=U+1D55F
+not=U+00AC
+not=U+00AC
+notin=U+2209
+notinE=U+22F9,U+0338
+notindot=U+22F5,U+0338
+notinva=U+2209
+notinvb=U+22F7
+notinvc=U+22F6
+notni=U+220C
+notniva=U+220C
+notnivb=U+22FE
+notnivc=U+22FD
+npar=U+2226
+nparallel=U+2226
+nparsl=U+2AFD,U+20E5
+npart=U+2202,U+0338
+npolint=U+2A14
+npr=U+2280
+nprcue=U+22E0
+npre=U+2AAF,U+0338
+nprec=U+2280
+npreceq=U+2AAF,U+0338
+nrArr=U+21CF
+nrarr=U+219B
+nrarrc=U+2933,U+0338
+nrarrw=U+219D,U+0338
+nrightarrow=U+219B
+nrtri=U+22EB
+nrtrie=U+22ED
+nsc=U+2281
+nsccue=U+22E1
+nsce=U+2AB0,U+0338
+nscr=U+1D4C3
+nshortmid=U+2224
+nshortparallel=U+2226
+nsim=U+2241
+nsime=U+2244
+nsimeq=U+2244
+nsmid=U+2224
+nspar=U+2226
+nsqsube=U+22E2
+nsqsupe=U+22E3
+nsub=U+2284
+nsubE=U+2AC5,U+0338
+nsube=U+2288
+nsubset=U+2282,U+20D2
+nsubseteq=U+2288
+nsubseteqq=U+2AC5,U+0338
+nsucc=U+2281
+nsucceq=U+2AB0,U+0338
+nsup=U+2285
+nsupE=U+2AC6,U+0338
+nsupe=U+2289
+nsupset=U+2283,U+20D2
+nsupseteq=U+2289
+nsupseteqq=U+2AC6,U+0338
+ntgl=U+2279
+ntilde=U+00F1
+ntilde=U+00F1
+ntlg=U+2278
+ntriangleleft=U+22EA
+ntrianglelefteq=U+22EC
+ntriangleright=U+22EB
+ntrianglerighteq=U+22ED
+nu=U+03BD
+num=U+0023
+numero=U+2116
+numsp=U+2007
+nvDash=U+22AD
+nvHarr=U+2904
+nvap=U+224D,U+20D2
+nvdash=U+22AC
+nvge=U+2265,U+20D2
+nvgt=U+003E,U+20D2
+nvinfin=U+29DE
+nvlArr=U+2902
+nvle=U+2264,U+20D2
+nvlt=U+003C,U+20D2
+nvltrie=U+22B4,U+20D2
+nvrArr=U+2903
+nvrtrie=U+22B5,U+20D2
+nvsim=U+223C,U+20D2
+nwArr=U+21D6
+nwarhk=U+2923
+nwarr=U+2196
+nwarrow=U+2196
+nwnear=U+2927
+oS=U+24C8
+oacute=U+00F3
+oacute=U+00F3
+oast=U+229B
+ocir=U+229A
+ocirc=U+00F4
+ocirc=U+00F4
+ocy=U+043E
+odash=U+229D
+odblac=U+0151
+odiv=U+2A38
+odot=U+2299
+odsold=U+29BC
+oelig=U+0153
+ofcir=U+29BF
+ofr=U+1D52C
+ogon=U+02DB
+ograve=U+00F2
+ograve=U+00F2
+ogt=U+29C1
+ohbar=U+29B5
+ohm=U+03A9
+oint=U+222E
+olarr=U+21BA
+olcir=U+29BE
+olcross=U+29BB
+oline=U+203E
+olt=U+29C0
+omacr=U+014D
+omega=U+03C9
+omicron=U+03BF
+omid=U+29B6
+ominus=U+2296
+oopf=U+1D560
+opar=U+29B7
+operp=U+29B9
+oplus=U+2295
+or=U+2228
+orarr=U+21BB
+ord=U+2A5D
+order=U+2134
+orderof=U+2134
+ordf=U+00AA
+ordf=U+00AA
+ordm=U+00BA
+ordm=U+00BA
+origof=U+22B6
+oror=U+2A56
+orslope=U+2A57
+orv=U+2A5B
+oscr=U+2134
+oslash=U+00F8
+oslash=U+00F8
+osol=U+2298
+otilde=U+00F5
+otilde=U+00F5
+otimes=U+2297
+otimesas=U+2A36
+ouml=U+00F6
+ouml=U+00F6
+ovbar=U+233D
+par=U+2225
+para=U+00B6
+para=U+00B6
+parallel=U+2225
+parsim=U+2AF3
+parsl=U+2AFD
+part=U+2202
+pcy=U+043F
+percnt=U+0025
+period=U+002E
+permil=U+2030
+perp=U+22A5
+pertenk=U+2031
+pfr=U+1D52D
+phi=U+03C6
+phiv=U+03D5
+phmmat=U+2133
+phone=U+260E
+pi=U+03C0
+pitchfork=U+22D4
+piv=U+03D6
+planck=U+210F
+planckh=U+210E
+plankv=U+210F
+plus=U+002B
+plusacir=U+2A23
+plusb=U+229E
+pluscir=U+2A22
+plusdo=U+2214
+plusdu=U+2A25
+pluse=U+2A72
+plusmn=U+00B1
+plusmn=U+00B1
+plussim=U+2A26
+plustwo=U+2A27
+pm=U+00B1
+pointint=U+2A15
+popf=U+1D561
+pound=U+00A3
+pound=U+00A3
+pr=U+227A
+prE=U+2AB3
+prap=U+2AB7
+prcue=U+227C
+pre=U+2AAF
+prec=U+227A
+precapprox=U+2AB7
+preccurlyeq=U+227C
+preceq=U+2AAF
+precnapprox=U+2AB9
+precneqq=U+2AB5
+precnsim=U+22E8
+precsim=U+227E
+prime=U+2032
+primes=U+2119
+prnE=U+2AB5
+prnap=U+2AB9
+prnsim=U+22E8
+prod=U+220F
+profalar=U+232E
+profline=U+2312
+profsurf=U+2313
+prop=U+221D
+propto=U+221D
+prsim=U+227E
+prurel=U+22B0
+pscr=U+1D4C5
+psi=U+03C8
+puncsp=U+2008
+qfr=U+1D52E
+qint=U+2A0C
+qopf=U+1D562
+qprime=U+2057
+qscr=U+1D4C6
+quaternions=U+210D
+quatint=U+2A16
+quest=U+003F
+questeq=U+225F
+quot=U+0022
+quot=U+0022
+rAarr=U+21DB
+rArr=U+21D2
+rAtail=U+291C
+rBarr=U+290F
+rHar=U+2964
+race=U+223D,U+0331
+racute=U+0155
+radic=U+221A
+raemptyv=U+29B3
+rang=U+27E9
+rangd=U+2992
+range=U+29A5
+rangle=U+27E9
+raquo=U+00BB
+raquo=U+00BB
+rarr=U+2192
+rarrap=U+2975
+rarrb=U+21E5
+rarrbfs=U+2920
+rarrc=U+2933
+rarrfs=U+291E
+rarrhk=U+21AA
+rarrlp=U+21AC
+rarrpl=U+2945
+rarrsim=U+2974
+rarrtl=U+21A3
+rarrw=U+219D
+ratail=U+291A
+ratio=U+2236
+rationals=U+211A
+rbarr=U+290D
+rbbrk=U+2773
+rbrace=U+007D
+rbrack=U+005D
+rbrke=U+298C
+rbrksld=U+298E
+rbrkslu=U+2990
+rcaron=U+0159
+rcedil=U+0157
+rceil=U+2309
+rcub=U+007D
+rcy=U+0440
+rdca=U+2937
+rdldhar=U+2969
+rdquo=U+201D
+rdquor=U+201D
+rdsh=U+21B3
+real=U+211C
+realine=U+211B
+realpart=U+211C
+reals=U+211D
+rect=U+25AD
+reg=U+00AE
+reg=U+00AE
+rfisht=U+297D
+rfloor=U+230B
+rfr=U+1D52F
+rhard=U+21C1
+rharu=U+21C0
+rharul=U+296C
+rho=U+03C1
+rhov=U+03F1
+rightarrow=U+2192
+rightarrowtail=U+21A3
+rightharpoondown=U+21C1
+rightharpoonup=U+21C0
+rightleftarrows=U+21C4
+rightleftharpoons=U+21CC
+rightrightarrows=U+21C9
+rightsquigarrow=U+219D
+rightthreetimes=U+22CC
+ring=U+02DA
+risingdotseq=U+2253
+rlarr=U+21C4
+rlhar=U+21CC
+rlm=U+200F
+rmoust=U+23B1
+rmoustache=U+23B1
+rnmid=U+2AEE
+roang=U+27ED
+roarr=U+21FE
+robrk=U+27E7
+ropar=U+2986
+ropf=U+1D563
+roplus=U+2A2E
+rotimes=U+2A35
+rpar=U+0029
+rpargt=U+2994
+rppolint=U+2A12
+rrarr=U+21C9
+rsaquo=U+203A
+rscr=U+1D4C7
+rsh=U+21B1
+rsqb=U+005D
+rsquo=U+2019
+rsquor=U+2019
+rthree=U+22CC
+rtimes=U+22CA
+rtri=U+25B9
+rtrie=U+22B5
+rtrif=U+25B8
+rtriltri=U+29CE
+ruluhar=U+2968
+rx=U+211E
+sacute=U+015B
+sbquo=U+201A
+sc=U+227B
+scE=U+2AB4
+scap=U+2AB8
+scaron=U+0161
+sccue=U+227D
+sce=U+2AB0
+scedil=U+015F
+scirc=U+015D
+scnE=U+2AB6
+scnap=U+2ABA
+scnsim=U+22E9
+scpolint=U+2A13
+scsim=U+227F
+scy=U+0441
+sdot=U+22C5
+sdotb=U+22A1
+sdote=U+2A66
+seArr=U+21D8
+searhk=U+2925
+searr=U+2198
+searrow=U+2198
+sect=U+00A7
+sect=U+00A7
+semi=U+003B
+seswar=U+2929
+setminus=U+2216
+setmn=U+2216
+sext=U+2736
+sfr=U+1D530
+sfrown=U+2322
+sharp=U+266F
+shchcy=U+0449
+shcy=U+0448
+shortmid=U+2223
+shortparallel=U+2225
+shy=U+00AD
+shy=U+00AD
+sigma=U+03C3
+sigmaf=U+03C2
+sigmav=U+03C2
+sim=U+223C
+simdot=U+2A6A
+sime=U+2243
+simeq=U+2243
+simg=U+2A9E
+simgE=U+2AA0
+siml=U+2A9D
+simlE=U+2A9F
+simne=U+2246
+simplus=U+2A24
+simrarr=U+2972
+slarr=U+2190
+smallsetminus=U+2216
+smashp=U+2A33
+smeparsl=U+29E4
+smid=U+2223
+smile=U+2323
+smt=U+2AAA
+smte=U+2AAC
+smtes=U+2AAC,U+FE00
+softcy=U+044C
+sol=U+002F
+solb=U+29C4
+solbar=U+233F
+sopf=U+1D564
+spades=U+2660
+spadesuit=U+2660
+spar=U+2225
+sqcap=U+2293
+sqcaps=U+2293,U+FE00
+sqcup=U+2294
+sqcups=U+2294,U+FE00
+sqsub=U+228F
+sqsube=U+2291
+sqsubset=U+228F
+sqsubseteq=U+2291
+sqsup=U+2290
+sqsupe=U+2292
+sqsupset=U+2290
+sqsupseteq=U+2292
+squ=U+25A1
+square=U+25A1
+squarf=U+25AA
+squf=U+25AA
+srarr=U+2192
+sscr=U+1D4C8
+ssetmn=U+2216
+ssmile=U+2323
+sstarf=U+22C6
+star=U+2606
+starf=U+2605
+straightepsilon=U+03F5
+straightphi=U+03D5
+strns=U+00AF
+sub=U+2282
+subE=U+2AC5
+subdot=U+2ABD
+sube=U+2286
+subedot=U+2AC3
+submult=U+2AC1
+subnE=U+2ACB
+subne=U+228A
+subplus=U+2ABF
+subrarr=U+2979
+subset=U+2282
+subseteq=U+2286
+subseteqq=U+2AC5
+subsetneq=U+228A
+subsetneqq=U+2ACB
+subsim=U+2AC7
+subsub=U+2AD5
+subsup=U+2AD3
+succ=U+227B
+succapprox=U+2AB8
+succcurlyeq=U+227D
+succeq=U+2AB0
+succnapprox=U+2ABA
+succneqq=U+2AB6
+succnsim=U+22E9
+succsim=U+227F
+sum=U+2211
+sung=U+266A
+sup1=U+00B9
+sup1=U+00B9
+sup2=U+00B2
+sup2=U+00B2
+sup3=U+00B3
+sup3=U+00B3
+sup=U+2283
+supE=U+2AC6
+supdot=U+2ABE
+supdsub=U+2AD8
+supe=U+2287
+supedot=U+2AC4
+suphsol=U+27C9
+suphsub=U+2AD7
+suplarr=U+297B
+supmult=U+2AC2
+supnE=U+2ACC
+supne=U+228B
+supplus=U+2AC0
+supset=U+2283
+supseteq=U+2287
+supseteqq=U+2AC6
+supsetneq=U+228B
+supsetneqq=U+2ACC
+supsim=U+2AC8
+supsub=U+2AD4
+supsup=U+2AD6
+swArr=U+21D9
+swarhk=U+2926
+swarr=U+2199
+swarrow=U+2199
+swnwar=U+292A
+szlig=U+00DF
+szlig=U+00DF
+target=U+2316
+tau=U+03C4
+tbrk=U+23B4
+tcaron=U+0165
+tcedil=U+0163
+tcy=U+0442
+tdot=U+20DB
+telrec=U+2315
+tfr=U+1D531
+there4=U+2234
+therefore=U+2234
+theta=U+03B8
+thetasym=U+03D1
+thetav=U+03D1
+thickapprox=U+2248
+thicksim=U+223C
+thinsp=U+2009
+thkap=U+2248
+thksim=U+223C
+thorn=U+00FE
+thorn=U+00FE
+tilde=U+02DC
+times=U+00D7
+times=U+00D7
+timesb=U+22A0
+timesbar=U+2A31
+timesd=U+2A30
+tint=U+222D
+toea=U+2928
+top=U+22A4
+topbot=U+2336
+topcir=U+2AF1
+topf=U+1D565
+topfork=U+2ADA
+tosa=U+2929
+tprime=U+2034
+trade=U+2122
+triangle=U+25B5
+triangledown=U+25BF
+triangleleft=U+25C3
+trianglelefteq=U+22B4
+triangleq=U+225C
+triangleright=U+25B9
+trianglerighteq=U+22B5
+tridot=U+25EC
+trie=U+225C
+triminus=U+2A3A
+triplus=U+2A39
+trisb=U+29CD
+tritime=U+2A3B
+trpezium=U+23E2
+tscr=U+1D4C9
+tscy=U+0446
+tshcy=U+045B
+tstrok=U+0167
+twixt=U+226C
+twoheadleftarrow=U+219E
+twoheadrightarrow=U+21A0
+uArr=U+21D1
+uHar=U+2963
+uacute=U+00FA
+uacute=U+00FA
+uarr=U+2191
+ubrcy=U+045E
+ubreve=U+016D
+ucirc=U+00FB
+ucirc=U+00FB
+ucy=U+0443
+udarr=U+21C5
+udblac=U+0171
+udhar=U+296E
+ufisht=U+297E
+ufr=U+1D532
+ugrave=U+00F9
+ugrave=U+00F9
+uharl=U+21BF
+uharr=U+21BE
+uhblk=U+2580
+ulcorn=U+231C
+ulcorner=U+231C
+ulcrop=U+230F
+ultri=U+25F8
+umacr=U+016B
+uml=U+00A8
+uml=U+00A8
+uogon=U+0173
+uopf=U+1D566
+uparrow=U+2191
+updownarrow=U+2195
+upharpoonleft=U+21BF
+upharpoonright=U+21BE
+uplus=U+228E
+upsi=U+03C5
+upsih=U+03D2
+upsilon=U+03C5
+upuparrows=U+21C8
+urcorn=U+231D
+urcorner=U+231D
+urcrop=U+230E
+uring=U+016F
+urtri=U+25F9
+uscr=U+1D4CA
+utdot=U+22F0
+utilde=U+0169
+utri=U+25B5
+utrif=U+25B4
+uuarr=U+21C8
+uuml=U+00FC
+uuml=U+00FC
+uwangle=U+29A7
+vArr=U+21D5
+vBar=U+2AE8
+vBarv=U+2AE9
+vDash=U+22A8
+vangrt=U+299C
+varepsilon=U+03F5
+varkappa=U+03F0
+varnothing=U+2205
+varphi=U+03D5
+varpi=U+03D6
+varpropto=U+221D
+varr=U+2195
+varrho=U+03F1
+varsigma=U+03C2
+varsubsetneq=U+228A,U+FE00
+varsubsetneqq=U+2ACB,U+FE00
+varsupsetneq=U+228B,U+FE00
+varsupsetneqq=U+2ACC,U+FE00
+vartheta=U+03D1
+vartriangleleft=U+22B2
+vartriangleright=U+22B3
+vcy=U+0432
+vdash=U+22A2
+vee=U+2228
+veebar=U+22BB
+veeeq=U+225A
+vellip=U+22EE
+verbar=U+007C
+vert=U+007C
+vfr=U+1D533
+vltri=U+22B2
+vnsub=U+2282,U+20D2
+vnsup=U+2283,U+20D2
+vopf=U+1D567
+vprop=U+221D
+vrtri=U+22B3
+vscr=U+1D4CB
+vsubnE=U+2ACB,U+FE00
+vsubne=U+228A,U+FE00
+vsupnE=U+2ACC,U+FE00
+vsupne=U+228B,U+FE00
+vzigzag=U+299A
+wcirc=U+0175
+wedbar=U+2A5F
+wedge=U+2227
+wedgeq=U+2259
+weierp=U+2118
+wfr=U+1D534
+wopf=U+1D568
+wp=U+2118
+wr=U+2240
+wreath=U+2240
+wscr=U+1D4CC
+xcap=U+22C2
+xcirc=U+25EF
+xcup=U+22C3
+xdtri=U+25BD
+xfr=U+1D535
+xhArr=U+27FA
+xharr=U+27F7
+xi=U+03BE
+xlArr=U+27F8
+xlarr=U+27F5
+xmap=U+27FC
+xnis=U+22FB
+xodot=U+2A00
+xopf=U+1D569
+xoplus=U+2A01
+xotime=U+2A02
+xrArr=U+27F9
+xrarr=U+27F6
+xscr=U+1D4CD
+xsqcup=U+2A06
+xuplus=U+2A04
+xutri=U+25B3
+xvee=U+22C1
+xwedge=U+22C0
+yacute=U+00FD
+yacute=U+00FD
+yacy=U+044F
+ycirc=U+0177
+ycy=U+044B
+yen=U+00A5
+yen=U+00A5
+yfr=U+1D536
+yicy=U+0457
+yopf=U+1D56A
+yscr=U+1D4CE
+yucy=U+044E
+yuml=U+00FF
+yuml=U+00FF
+zacute=U+017A
+zcaron=U+017E
+zcy=U+0437
+zdot=U+017C
+zeetrf=U+2128
+zeta=U+03B6
+zfr=U+1D537
+zhcy=U+0436
+zigrarr=U+21DD
+zopf=U+1D56B
+zscr=U+1D4CF
+zwj=U+200D
+zwnj=U+200C
diff --git a/exist-core/src/test/xquery/numbers/format-number-map.xql b/exist-core/src/test/xquery/numbers/format-number-map.xql
new file mode 100644
index 00000000000..75541c6554e
--- /dev/null
+++ b/exist-core/src/test/xquery/numbers/format-number-map.xql
@@ -0,0 +1,142 @@
+(:
+ : eXist-db Open Source Native XML Database
+ : Copyright (C) 2001 The eXist-db Authors
+ :
+ : info@exist-db.org
+ : http://www.exist-db.org
+ :
+ : This library is free software; you can redistribute it and/or
+ : modify it under the terms of the GNU Lesser General Public
+ : License as published by the Free Software Foundation; either
+ : version 2.1 of the License, or (at your option) any later version.
+ :
+ : This library is distributed in the hope that it will be useful,
+ : but WITHOUT ANY WARRANTY; without even the implied warranty of
+ : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ : Lesser General Public License for more details.
+ :
+ : You should have received a copy of the GNU Lesser General Public
+ : License along with this library; if not, write to the Free Software
+ : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ :)
+xquery version "3.1";
+
+(:~ Tests for fn:format-number with XQuery 4.0 map overload :)
+module namespace fnm="http://exist-db.org/xquery/test/format-number-map";
+
+declare namespace test="http://exist-db.org/xquery/xqsuite";
+
+(: === Basic map overload — custom separators === :)
+
+declare
+ %test:assertEquals("12.345,60")
+function fnm:european-format() {
+ format-number(12345.6, '#.###,00', map {
+ 'decimal-separator': ',',
+ 'grouping-separator': '.'
+ })
+};
+
+declare
+ %test:assertEquals("12 345,60")
+function fnm:french-format() {
+ format-number(12345.6, '# ###,00', map {
+ 'decimal-separator': ',',
+ 'grouping-separator': ' '
+ })
+};
+
+(: === Custom infinity and NaN === :)
+
+declare
+ %test:assertEquals("∞")
+function fnm:custom-infinity() {
+ format-number(1 div 0e0, '#', map {
+ 'infinity': '∞'
+ })
+};
+
+declare
+ %test:assertEquals("N/A")
+function fnm:custom-nan() {
+ format-number(number('NaN'), '#', map {
+ 'NaN': 'N/A'
+ })
+};
+
+(: === Custom minus sign === :)
+
+declare
+ %test:assertEquals("(42)")
+function fnm:custom-minus() {
+ format-number(-42, '#;(#)', map {
+ 'minus-sign': '−'
+ })
+};
+
+(: === Custom percent and per-mille === :)
+
+declare
+ %test:assertEquals("75%")
+function fnm:default-percent() {
+ format-number(0.75, '#%')
+};
+
+(: === Empty map = unnamed default === :)
+
+declare
+ %test:assertEquals("1,234.50")
+function fnm:empty-map-uses-default() {
+ format-number(1234.5, '#,###.00', map {})
+};
+
+(: === Map with format-name selects base format === :)
+(: Note: this test uses the unnamed default since we can't declare
+ custom decimal formats in a module without declare decimal-format :)
+
+declare
+ %test:assertEquals("1,234.50")
+function fnm:map-with-no-format-name() {
+ format-number(1234.5, '#,###.00', map {})
+};
+
+(: === Zero digit override === :)
+
+declare
+ %test:assertEquals("١٢٣")
+function fnm:arabic-digits() {
+ (: Arabic-Indic digit zero is U+0660, picture must use same digit family :)
+ format-number(123, '٠٠٠', map {
+ 'zero-digit': '٠'
+ })
+};
+
+(: === Exponent separator === :)
+
+declare
+ %test:assertEquals("1.23E3")
+function fnm:custom-exponent-separator() {
+ format-number(1230, '0.00E0', map {
+ 'exponent-separator': 'E'
+ })
+};
+
+(: === Multiple overrides at once === :)
+
+declare
+ %test:assertEquals("1.234,56")
+function fnm:multiple-overrides() {
+ format-number(1234.56, '#.###,00', map {
+ 'decimal-separator': ',',
+ 'grouping-separator': '.'
+ })
+};
+
+(: === Backward compatibility: string arg still works === :)
+
+declare
+ %test:assertEquals("1,234.50")
+function fnm:string-arg-still-works() {
+ (: No custom decimal format declared, so unnamed default :)
+ format-number(1234.5, '#,###.00')
+};
diff --git a/exist-core/src/test/xquery/xquery3/deep-equal-options-test.xq b/exist-core/src/test/xquery/xquery3/deep-equal-options-test.xq
new file mode 100644
index 00000000000..cc691a199d6
--- /dev/null
+++ b/exist-core/src/test/xquery/xquery3/deep-equal-options-test.xq
@@ -0,0 +1,171 @@
+(:
+ : eXist-db Open Source Native XML Database
+ : Copyright (C) 2001 The eXist-db Authors
+ :
+ : info@exist-db.org
+ : http://www.exist-db.org
+ :
+ : This library is free software; you can redistribute it and/or
+ : modify it under the terms of the GNU Lesser General Public
+ : License as published by the Free Software Foundation; either
+ : version 2.1 of the License, or (at your option) any later version.
+ :
+ : This library is distributed in the hope that it will be useful,
+ : but WITHOUT ANY WARRANTY; without even the implied warranty of
+ : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ : Lesser General Public License for more details.
+ :
+ : You should have received a copy of the GNU Lesser General Public
+ : License along with this library; if not, write to the Free Software
+ : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ :)
+xquery version "3.1";
+
+module namespace det = "http://exist-db.org/test/deep-equal-options";
+
+declare namespace test = "http://exist-db.org/xquery/xqsuite";
+
+(: === Whitespace options === :)
+
+declare
+ %test:assertTrue
+function det:whitespace-strip-basic() {
+ deep-equal(parse-xml(' '), parse-xml(' '), map { 'whitespace': 'strip' })
+};
+
+declare
+ %test:assertFalse
+function det:whitespace-strip-attr() {
+ (: attribute values should NOT be affected by whitespace:strip :)
+ deep-equal(parse-xml(' '), parse-xml(' '), map { 'whitespace': 'strip' })
+};
+
+declare
+ %test:assertTrue
+function det:whitespace-normalize-basic() {
+ deep-equal('bedtime', ' bedtime ', map { 'whitespace': 'normalize' })
+};
+
+declare
+ %test:assertTrue
+function det:whitespace-normalize-attr() {
+ (: Attribute values are normalized, whitespace-only text nodes normalize to empty :)
+ deep-equal(
+ parse-xml(' '),
+ parse-xml(' '),
+ map { 'whitespace': 'normalize' })
+};
+
+declare
+ %test:assertFalse
+function det:whitespace-normalize-attr-different() {
+ (: Attribute values differ after normalization :)
+ deep-equal(
+ parse-xml(' '),
+ parse-xml(' '),
+ map { 'whitespace': 'normalize' })
+};
+
+(: === Options validation === :)
+
+declare
+ %test:assertError("XPTY0004")
+function det:options-invalid-key() {
+ deep-equal(1, 2, map { 'bifurcation': true() })
+};
+
+declare
+ %test:assertTrue
+function det:options-valid-booleans() {
+ deep-equal((), (), map {
+ 'base-uri': true(), 'comments': true(), 'debug': true(),
+ 'id-property': true(), 'idrefs-property': true(),
+ 'in-scope-namespaces': true(), 'namespace-prefixes': true(), 'nilled-property': true(),
+ 'processing-instructions': true(), 'timezones': true(), 'type-annotations': true(),
+ 'type-variety': true(), 'typed-values': true()
+ })
+};
+
+(: === Ordered/unordered === :)
+
+declare
+ %test:assertTrue
+function det:unordered-basic() {
+ deep-equal((1, 2), (2, 1), map { 'ordered': false() })
+};
+
+declare
+ %test:assertFalse
+function det:ordered-basic() {
+ deep-equal((1, 2), (2, 1), map { 'ordered': true() })
+};
+
+(: === Comments option === :)
+
+declare
+ %test:assertTrue
+function det:comments-default-ignored() {
+ (: By default comments are ignored :)
+ deep-equal(
+ parse-xml(' '),
+ parse-xml(' '))
+};
+
+declare
+ %test:assertFalse
+function det:comments-true-compared() {
+ (: With comments:true, different comments matter :)
+ deep-equal(
+ parse-xml(' '),
+ parse-xml(' '),
+ map { 'comments': true() })
+};
+
+(: === Processing instructions === :)
+
+declare
+ %test:assertTrue
+function det:pi-default-ignored() {
+ deep-equal(
+ parse-xml(' '),
+ parse-xml(' '))
+};
+
+declare
+ %test:assertFalse
+function det:pi-true-compared() {
+ deep-equal(
+ parse-xml(' '),
+ parse-xml(' '),
+ map { 'processing-instructions': true() })
+};
+
+(: === Function item comparison === :)
+
+declare
+ %test:assertFalse
+function det:function-items-different() {
+ deep-equal(true#0, false#0)
+};
+
+declare
+ %test:assertTrue
+function det:function-items-same() {
+ let $f := true#0
+ return deep-equal($f, $f)
+};
+
+declare
+ %test:assertTrue
+function det:function-items-same-name() {
+ (: Two separate references to same named function should be equal :)
+ deep-equal(true#0, true#0)
+};
+
+declare function local:helper() { 42 };
+
+declare
+ %test:assertTrue
+function det:function-items-user-defined-same() {
+ deep-equal(local:helper#0, local:helper#0)
+};
diff --git a/exist-core/src/test/xquery/xquery3/fnInvisibleXml.xqm b/exist-core/src/test/xquery/xquery3/fnInvisibleXml.xqm
new file mode 100644
index 00000000000..f4776038779
--- /dev/null
+++ b/exist-core/src/test/xquery/xquery3/fnInvisibleXml.xqm
@@ -0,0 +1,100 @@
+(:
+ : eXist-db Open Source Native XML Database
+ : Copyright (C) 2001 The eXist-db Authors
+ :
+ : info@exist-db.org
+ : http://www.exist-db.org
+ :
+ : This library is free software; you can redistribute it and/or
+ : modify it under the terms of the GNU Lesser General Public
+ : License as published by the Free Software Foundation; either
+ : version 2.1 of the License, or (at your option) any later version.
+ :
+ : This library is distributed in the hope that it will be useful,
+ : but WITHOUT ANY WARRANTY; without even the implied warranty of
+ : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ : Lesser General Public License for more details.
+ :
+ : You should have received a copy of the GNU Lesser General Public
+ : License along with this library; if not, write to the Free Software
+ : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ :)
+xquery version "3.1";
+
+(:~
+ : Tests for fn:invisible-xml().
+ :)
+module namespace ixml = "http://exist-db.org/xquery/test/invisible-xml";
+
+declare namespace test = "http://exist-db.org/xquery/xqsuite";
+
+declare variable $ixml:date-grammar := "date = year, -'-', month, -'-', day.
+year = digit, digit, digit, digit.
+month = digit, digit.
+day = digit, digit.
+-digit = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'.";
+
+declare
+ %test:assertEquals('2023 10 31 ')
+function ixml:date-parse() {
+ let $parser := fn:invisible-xml($ixml:date-grammar, map {})
+ return $parser("2023-10-31")
+};
+
+declare
+ %test:assertEquals('2024 01 15 ')
+function ixml:date-parse-different-input() {
+ let $parser := fn:invisible-xml($ixml:date-grammar, map {})
+ return $parser("2024-01-15")
+};
+
+declare
+ %test:assertError("FOIX0001")
+function ixml:invalid-grammar() {
+ fn:invisible-xml("this is not a valid grammar !!!", map {})
+};
+
+declare
+ %test:assertError("FOIX0002")
+function ixml:parse-error() {
+ let $parser := fn:invisible-xml($ixml:date-grammar, map {"fail-on-error": true()})
+ return $parser("not-a-date")
+};
+
+declare
+ %test:assertEquals(' ')
+function ixml:xml-grammar-element() {
+ let $grammar := parse-xml(" ")/*
+ return invisible-xml($grammar)("")
+};
+
+declare
+ %test:assertEquals('2023 10 31 ')
+function ixml:xml-grammar-complex() {
+ let $xml := concat(
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ "",
+ " ")
+ let $grammar := parse-xml($xml)/*
+ return invisible-xml($grammar)("2023-10-31")
+};
+
+declare
+ %test:assertTrue
+function ixml:reuse-parser() {
+ let $parser := fn:invisible-xml($ixml:date-grammar, map {})
+ let $d1 := $parser("2023-10-31")
+ let $d2 := $parser("2024-01-15")
+ return $d1/date/year = "2023" and $d2/date/year = "2024"
+};
diff --git a/exist-core/src/test/xquery/xquery3/fnLanguage.xqm b/exist-core/src/test/xquery/xquery3/fnLanguage.xqm
index 8952c81ae3a..dda6529cc97 100644
--- a/exist-core/src/test/xquery/xquery3/fnLanguage.xqm
+++ b/exist-core/src/test/xquery/xquery3/fnLanguage.xqm
@@ -33,6 +33,6 @@ declare
%test:assertExists
function test-sort:default-language() {
let $language := fn:default-language()
- let $all := ("aa","ab","ae","af","ak","am","an","ar","as","av","ay","az","ba","be","bg","bh","bi","bm","bn","bo","br","bs","ca","ce","ch","co","cr","cs","cu","cv","cy","da","de","dv","dz","ee","el","en","eo","es","et","eu","fa","ff","fi","fj","fo","fr","fy","ga","gd","gl","gn","gu","gv","ha","he","hi","ho","hr","ht","hu","hy","hz","ia","id","ie","ig","ii","ik","in","io","is","it","iu","iw","ja","ji","jv","ka","kg","ki","kj","kk","kl","km","kn","ko","kr","ks","ku","kv","kw","ky","la","lb","lg","li","ln","lo","lt","lu","lv","mg","mh","mi","mk","ml","mn","mo","mr","ms","mt","my","na","nb","nd","ne","ng","nl","nn","no","nr","nv","ny","oc","oj","om","or","os","pa","pi","pl","ps","pt","qu","rm","rn","ro","ru","rw","sa","sc","sd","se","sg","si","sk","sl","sm","sn","so","sq","sr","ss","st","su","sv","sw","ta","te","tg","th","ti","tk","tl","tn","to","tr","ts","tt","tw","ty","ug","uk","ur","uz","ve","vi","vo","wa","wo","xh","yi","yo","za","zh","zu")
- return index-of($all, $language)
+ let $languages := ("aa","ab","ae","af","ak","am","an","ar","as","av","ay","az","ba","be","bg","bh","bi","bm","bn","bo","br","bs","ca","ce","ch","co","cr","cs","cu","cv","cy","da","de","dv","dz","ee","el","en","eo","es","et","eu","fa","ff","fi","fj","fo","fr","fy","ga","gd","gl","gn","gu","gv","ha","he","hi","ho","hr","ht","hu","hy","hz","ia","id","ie","ig","ii","ik","in","io","is","it","iu","iw","ja","ji","jv","ka","kg","ki","kj","kk","kl","km","kn","ko","kr","ks","ku","kv","kw","ky","la","lb","lg","li","ln","lo","lt","lu","lv","mg","mh","mi","mk","ml","mn","mo","mr","ms","mt","my","na","nb","nd","ne","ng","nl","nn","no","nr","nv","ny","oc","oj","om","or","os","pa","pi","pl","ps","pt","qu","rm","rn","ro","ru","rw","sa","sc","sd","se","sg","si","sk","sl","sm","sn","so","sq","sr","ss","st","su","sv","sw","ta","te","tg","th","ti","tk","tl","tn","to","tr","ts","tt","tw","ty","ug","uk","ur","uz","ve","vi","vo","wa","wo","xh","yi","yo","za","zh","zu")
+ return index-of($languages, $language)
};
diff --git a/exist-core/src/test/xquery/xquery3/fnXQuery40.xql b/exist-core/src/test/xquery/xquery3/fnXQuery40.xql
new file mode 100644
index 00000000000..18564bf4cb5
--- /dev/null
+++ b/exist-core/src/test/xquery/xquery3/fnXQuery40.xql
@@ -0,0 +1,1204 @@
+(:
+ : eXist-db Open Source Native XML Database
+ : Copyright (C) 2001 The eXist-db Authors
+ :
+ : info@exist-db.org
+ : http://www.exist-db.org
+ :
+ : This library is free software; you can redistribute it and/or
+ : modify it under the terms of the GNU Lesser General Public
+ : License as published by the Free Software Foundation; either
+ : version 2.1 of the License, or (at your option) any later version.
+ :
+ : This library is distributed in the hope that it will be useful,
+ : but WITHOUT ANY WARRANTY; without even the implied warranty of
+ : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ : Lesser General Public License for more details.
+ :
+ : You should have received a copy of the GNU Lesser General Public
+ : License along with this library; if not, write to the Free Software
+ : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ :)
+xquery version "3.1";
+
+(:~
+ : Tests for XQuery 4.0 functions implemented in eXist-db.
+ :)
+module namespace t = "http://exist-db.org/xquery/test/fn-xquery40";
+
+declare namespace test = "http://exist-db.org/xquery/xqsuite";
+
+(: fn:foot :)
+
+declare
+ %test:assertEquals(5)
+function t:foot-sequence() {
+ foot(1 to 5)
+};
+
+declare
+ %test:assertEmpty
+function t:foot-empty() {
+ foot(())
+};
+
+declare
+ %test:assertEquals("c")
+function t:foot-string-sequence() {
+ foot(("a", "b", "c"))
+};
+
+(: fn:trunk :)
+
+declare
+ %test:assertEquals(1, 2, 3, 4)
+function t:trunk-sequence() {
+ trunk(1 to 5)
+};
+
+declare
+ %test:assertEmpty
+function t:trunk-empty() {
+ trunk(())
+};
+
+declare
+ %test:assertEmpty
+function t:trunk-single() {
+ trunk("a")
+};
+
+declare
+ %test:assertEquals("a", "b")
+function t:trunk-string-sequence() {
+ trunk(("a", "b", "c"))
+};
+
+(: fn:identity :)
+
+declare
+ %test:assertEquals(0)
+function t:identity-zero() {
+ identity(0)
+};
+
+declare
+ %test:assertEmpty
+function t:identity-empty() {
+ identity(())
+};
+
+declare
+ %test:assertEquals(1, 2, 3)
+function t:identity-sequence() {
+ identity((1, 2, 3))
+};
+
+(: fn:void :)
+
+declare
+ %test:assertEmpty
+function t:void-value() {
+ void(1 to 1000000)
+};
+
+declare
+ %test:assertEmpty
+function t:void-no-args() {
+ void()
+};
+
+(: fn:is-NaN :)
+
+declare
+ %test:assertFalse
+function t:isNaN-integer() {
+ is-NaN(23)
+};
+
+declare
+ %test:assertFalse
+function t:isNaN-string() {
+ is-NaN("NaN")
+};
+
+declare
+ %test:assertTrue
+function t:isNaN-number-invalid() {
+ is-NaN(number("twenty-three"))
+};
+
+(: fn:characters :)
+
+declare
+ %test:assertEquals("T", "h", "e")
+function t:characters-basic() {
+ characters("The")
+};
+
+declare
+ %test:assertEmpty
+function t:characters-empty-string() {
+ characters("")
+};
+
+declare
+ %test:assertEmpty
+function t:characters-empty-sequence() {
+ characters(())
+};
+
+(: fn:replicate :)
+
+declare
+ %test:assertEquals(0, 0, 0)
+function t:replicate-basic() {
+ replicate(0, 3)
+};
+
+declare
+ %test:assertEmpty
+function t:replicate-zero-count() {
+ replicate("A", 0)
+};
+
+declare
+ %test:assertEmpty
+function t:replicate-empty-input() {
+ replicate((), 5)
+};
+
+(: fn:insert-separator :)
+
+declare
+ %test:assertEquals(1, "|", 2, "|", 3)
+function t:insertSeparator-basic() {
+ insert-separator(1 to 3, "|")
+};
+
+declare
+ %test:assertEmpty
+function t:insertSeparator-empty() {
+ insert-separator((), "|")
+};
+
+declare
+ %test:assertEquals("A")
+function t:insertSeparator-single() {
+ insert-separator("A", "|")
+};
+
+(: fn:all-equal :)
+
+declare
+ %test:assertFalse
+function t:allEqual-different() {
+ all-equal((1, 2, 3))
+};
+
+declare
+ %test:assertTrue
+function t:allEqual-same() {
+ all-equal((1, 1, 1))
+};
+
+declare
+ %test:assertFalse
+function t:allEqual-mixed-numeric-types() {
+ (: XQ4: decimal 1.2 and double 1.2 differ in exact mathematical value :)
+ all-equal((xs:decimal('1.2'), xs:double('1.2')))
+};
+
+declare
+ %test:assertTrue
+function t:allEqual-empty() {
+ all-equal(())
+};
+
+declare
+ %test:assertTrue
+function t:allEqual-single() {
+ all-equal("one")
+};
+
+(: fn:all-different :)
+
+declare
+ %test:assertTrue
+function t:allDifferent-different() {
+ all-different((1, 2, 3))
+};
+
+declare
+ %test:assertFalse
+function t:allDifferent-duplicates() {
+ all-different((1, 2, 1))
+};
+
+declare
+ %test:assertTrue
+function t:allDifferent-empty() {
+ all-different(())
+};
+
+(: fn:items-at :)
+
+declare
+ %test:assertEquals(14)
+function t:itemsAt-single() {
+ items-at(11 to 20, 4)
+};
+
+declare
+ %test:assertEquals(17, 13)
+function t:itemsAt-reorder() {
+ items-at(11 to 20, (7, 3))
+};
+
+declare
+ %test:assertEmpty
+function t:itemsAt-empty-input() {
+ items-at((), 832)
+};
+
+(: fn:index-where :)
+
+declare
+ %test:assertEquals(2, 3)
+function t:indexWhere-basic() {
+ index-where((0, 4, 9), boolean#1)
+};
+
+declare
+ %test:assertEmpty
+function t:indexWhere-empty() {
+ index-where((), boolean#1)
+};
+
+(: fn:take-while :)
+
+declare
+ %test:assertEquals(10, 11, 12)
+function t:takeWhile-basic() {
+ take-while(10 to 20, function($x) { $x le 12 })
+};
+
+declare
+ %test:assertEmpty
+function t:takeWhile-empty() {
+ take-while((), boolean#1)
+};
+
+(: fn:slice :)
+
+declare
+ %test:assertEquals("b", "c", "d")
+function t:slice-startEnd() {
+ let $in := ("a", "b", "c", "d", "e")
+ return slice($in, 2, 4)
+};
+
+declare
+ %test:assertEquals("e")
+function t:slice-negative-start() {
+ let $in := ("a", "b", "c", "d", "e")
+ return slice($in, -1)
+};
+
+(: fn:duplicate-values :)
+
+declare
+ %test:assertEquals(1)
+function t:duplicateValues-basic() {
+ duplicate-values((1, 2, 3, 1))
+};
+
+declare
+ %test:assertEmpty
+function t:duplicateValues-noDups() {
+ duplicate-values((1, 2, 3))
+};
+
+(: fn:hash :)
+
+declare
+ %test:assertEquals("900150983CD24FB0D6963F7D28E17F72")
+function t:hash-md5() {
+ string(hash("abc"))
+};
+
+declare
+ %test:assertEmpty
+function t:hash-empty() {
+ hash(())
+};
+
+(: fn:while-do :)
+
+declare
+ %test:assertEquals(16)
+function t:whileDo-doubling() {
+ while-do(1, function($x) { $x lt 10 }, function($x) { $x * 2 })
+};
+
+(: fn:do-until :)
+
+declare
+ %test:assertEquals(16)
+function t:doUntil-doubling() {
+ do-until(1, function($x) { $x * 2 }, function($x) { $x ge 10 })
+};
+
+(: fn:sort-with :)
+
+declare
+ %test:assertEquals(1, 1, 3, 4, 5)
+function t:sortWith-ascending() {
+ sort-with((3, 1, 4, 1, 5), function($a, $b) { compare(string($a), string($b)) })
+};
+
+(: fn:op :)
+
+declare
+ %test:assertEquals(7)
+function t:op-add() {
+ op("+")(3, 4)
+};
+
+declare
+ %test:assertTrue
+function t:op-lt() {
+ op("lt")(3, 4)
+};
+
+declare
+ %test:assertEquals(7)
+function t:op-subtract() {
+ op("-")(10, 3)
+};
+
+(: fn:char :)
+
+declare
+ %test:assertEquals("A")
+function t:char-codepoint() {
+ char(65)
+};
+
+declare
+ %test:assertEquals("&")
+function t:char-name() {
+ char("amp")
+};
+
+(: fn:atomic-equal :)
+
+declare
+ %test:assertTrue
+function t:atomicEqual-same() {
+ atomic-equal(1, 1)
+};
+
+declare
+ %test:assertFalse
+function t:atomicEqual-different-type() {
+ atomic-equal("1", 1)
+};
+
+declare
+ %test:assertTrue
+function t:atomicEqual-nan() {
+ atomic-equal(number("NaN"), number("NaN"))
+};
+
+(: fn:expanded-QName :)
+
+declare
+ %test:assertEquals("Q{}local")
+function t:expandedQName-noNS() {
+ expanded-QName(QName("", "local"))
+};
+
+declare
+ %test:assertEquals("Q{http://example.com}test")
+function t:expandedQName-withNS() {
+ expanded-QName(QName("http://example.com", "test"))
+};
+
+(: fn:highest / fn:lowest :)
+
+declare
+ %test:assertEquals(5)
+function t:highest-basic() {
+ highest((3, 1, 5, 2, 4))
+};
+
+declare
+ %test:assertEquals(1)
+function t:lowest-basic() {
+ lowest((3, 1, 5, 2, 4))
+};
+
+(: fn:partition :)
+
+declare
+ %test:assertEquals(3)
+function t:partition-basic() {
+ count(partition(1 to 6, function($current, $next, $pos) { $pos mod 2 eq 1 }))
+};
+
+(: fn:parse-uri :)
+
+declare
+ %test:assertEquals("http")
+function t:parseUri-scheme() {
+ parse-uri("http://example.com/path")?scheme
+};
+
+declare
+ %test:assertTrue
+function t:parseUri-hierarchical() {
+ parse-uri("http://example.com/path")?hierarchical
+};
+
+declare
+ %test:assertEquals("example.com")
+function t:parseUri-host() {
+ parse-uri("http://example.com/path")?host
+};
+
+declare
+ %test:assertEquals("/path")
+function t:parseUri-path() {
+ parse-uri("http://example.com/path")?path
+};
+
+declare
+ %test:assertFalse
+function t:parseUri-opaque() {
+ parse-uri("mailto:user@example.com")?hierarchical
+};
+
+(: fn:scan-left :)
+
+declare
+ %test:assertEquals(3)
+function t:scanLeft-count() {
+ count(scan-left(1 to 2, 0, function($acc, $item) { $acc + $item }))
+};
+
+declare
+ %test:assertEquals(0, 1, 3)
+function t:scanLeft-sums() {
+ for $arr in scan-left(1 to 2, 0, function($acc, $item) { $acc + $item })
+ return $arr?1
+};
+
+(: fn:scan-right :)
+
+declare
+ %test:assertEquals(3)
+function t:scanRight-count() {
+ count(scan-right(1 to 2, 0, function($item, $acc) { $acc + $item }))
+};
+
+declare
+ %test:assertEquals(3, 2, 0)
+function t:scanRight-sums() {
+ for $arr in scan-right(1 to 2, 0, function($item, $acc) { $acc + $item })
+ return $arr?1
+};
+
+(: fn:build-uri :)
+
+declare
+ %test:assertEquals("https://qt4cg.org/specifications/index.html")
+function t:buildUri-basic() {
+ build-uri(map {
+ "scheme": "https",
+ "host": "qt4cg.org",
+ "path": "/specifications/index.html"
+ })
+};
+
+(: fn:every :)
+
+declare
+ %test:assertTrue
+function t:every-all-true() {
+ every((1, 2, 3), function($x) { $x gt 0 })
+};
+
+declare
+ %test:assertFalse
+function t:every-one-false() {
+ every((1, -1, 3), function($x) { $x gt 0 })
+};
+
+declare
+ %test:assertTrue
+function t:every-empty() {
+ every((), function($x) { $x gt 0 })
+};
+
+declare
+ %test:assertTrue
+function t:every-1arg-truthy() {
+ every((1, true(), "yes"))
+};
+
+declare
+ %test:assertFalse
+function t:every-1arg-falsy() {
+ every((1, 0, "yes"))
+};
+
+(: fn:some :)
+
+declare
+ %test:assertTrue
+function t:some-one-true() {
+ some((-1, 0, 3), function($x) { $x gt 0 })
+};
+
+declare
+ %test:assertFalse
+function t:some-none-true() {
+ some((-1, -2, -3), function($x) { $x gt 0 })
+};
+
+declare
+ %test:assertFalse
+function t:some-empty() {
+ some((), function($x) { $x gt 0 })
+};
+
+declare
+ %test:assertTrue
+function t:some-1arg-truthy() {
+ some((0, false(), 1))
+};
+
+(: fn:sort-by :)
+
+declare
+ %test:assertEquals("a", "bb", "ccc")
+function t:sortBy-stringLength() {
+ sort-by(("ccc", "a", "bb"), map { "key": string-length#1 })
+};
+
+declare
+ %test:assertEquals("ccc", "bb", "a")
+function t:sortBy-descending() {
+ sort-by(("a", "bb", "ccc"), map { "key": string-length#1, "order": "descending" })
+};
+
+declare
+ %test:assertEmpty
+function t:sortBy-empty() {
+ sort-by((), map { "key": string-length#1 })
+};
+
+(: fn:contains-subsequence :)
+
+declare
+ %test:assertTrue
+function t:containsSubseq-present() {
+ contains-subsequence((1, 2, 3, 4, 5), (2, 3, 4))
+};
+
+declare
+ %test:assertFalse
+function t:containsSubseq-absent() {
+ contains-subsequence((1, 2, 3, 4, 5), (2, 4))
+};
+
+declare
+ %test:assertTrue
+function t:containsSubseq-emptySubseq() {
+ contains-subsequence((1, 2, 3), ())
+};
+
+(: fn:starts-with-subsequence :)
+
+declare
+ %test:assertTrue
+function t:startsWithSubseq-true() {
+ starts-with-subsequence((1, 2, 3, 4), (1, 2))
+};
+
+declare
+ %test:assertFalse
+function t:startsWithSubseq-false() {
+ starts-with-subsequence((1, 2, 3, 4), (2, 3))
+};
+
+declare
+ %test:assertTrue
+function t:startsWithSubseq-empty() {
+ starts-with-subsequence((1, 2, 3), ())
+};
+
+(: fn:ends-with-subsequence :)
+
+declare
+ %test:assertTrue
+function t:endsWithSubseq-true() {
+ ends-with-subsequence((1, 2, 3, 4), (3, 4))
+};
+
+declare
+ %test:assertFalse
+function t:endsWithSubseq-false() {
+ ends-with-subsequence((1, 2, 3, 4), (2, 3))
+};
+
+(: fn:decode-from-uri :)
+
+declare
+ %test:assertEquals("hello world")
+function t:decodeFromUri-plus() {
+ decode-from-uri("hello+world")
+};
+
+declare
+ %test:assertEquals("a/b")
+function t:decodeFromUri-percent() {
+ decode-from-uri("a%2Fb")
+};
+
+declare
+ %test:assertEquals("")
+function t:decodeFromUri-empty() {
+ decode-from-uri(())
+};
+
+(: fn:parse-integer :)
+
+declare
+ %test:assertEquals(42)
+function t:parseInteger-decimal() {
+ parse-integer("42")
+};
+
+declare
+ %test:assertEquals(255)
+function t:parseInteger-hex() {
+ parse-integer("FF", 16)
+};
+
+declare
+ %test:assertEquals(7)
+function t:parseInteger-binary() {
+ parse-integer("111", 2)
+};
+
+declare
+ %test:assertEquals(1000)
+function t:parseInteger-underscores() {
+ parse-integer("1_000")
+};
+
+declare
+ %test:assertEmpty
+function t:parseInteger-empty() {
+ parse-integer(())
+};
+
+(: fn:divide-decimals :)
+
+declare
+ %test:assertEquals(3)
+function t:divideDecimals-quotient() {
+ divide-decimals(10, 3)?quotient
+};
+
+declare
+ %test:assertEquals(1)
+function t:divideDecimals-remainder() {
+ divide-decimals(10, 3)?remainder
+};
+
+declare
+ %test:assertEquals(3.3)
+function t:divideDecimals-precision() {
+ divide-decimals(10, 3, 1)?quotient
+};
+
+(: fn:distinct-ordered-nodes :)
+
+declare
+ %test:assertEquals(3)
+function t:distinctOrderedNodes-basic() {
+ let $doc :=
+ return count(distinct-ordered-nodes(($doc/a, $doc/c, $doc/b, $doc/a)))
+};
+
+(: fn:siblings :)
+
+declare
+ %test:assertEquals(3)
+function t:siblings-count() {
+ let $doc :=
+ return count(siblings($doc/b))
+};
+
+declare
+ %test:assertEmpty
+function t:siblings-empty() {
+ siblings(())
+};
+
+(: fn:type-of :)
+
+declare
+ %test:assertEquals("xs:integer")
+function t:typeOf-integer() {
+ type-of(42)
+};
+
+declare
+ %test:assertEquals("xs:string")
+function t:typeOf-string() {
+ type-of("hello")
+};
+
+declare
+ %test:assertEquals("empty-sequence()")
+function t:typeOf-empty() {
+ type-of(())
+};
+
+declare
+ %test:assertEquals("element()")
+function t:typeOf-element() {
+ type-of( )
+};
+
+declare
+ %test:assertEquals("map(*)")
+function t:typeOf-map() {
+ type-of(map { "a": 1 })
+};
+
+(: fn:unix-dateTime :)
+
+declare
+ %test:assertEquals("1970-01-01T00:00:00Z")
+function t:unixDateTime-epoch() {
+ string(unix-dateTime(0))
+};
+
+declare
+ %test:assertEquals("1970-01-01T00:00:01Z")
+function t:unixDateTime-oneSecond() {
+ string(unix-dateTime(1000))
+};
+
+(: fn:message :)
+
+declare
+ %test:assertEmpty
+function t:message-basic() {
+ message("test output")
+};
+
+declare
+ %test:assertEmpty
+function t:message-withLabel() {
+ message("test output", "DEBUG")
+};
+
+(: fn:parse-QName :)
+
+declare
+ %test:assertEmpty
+function t:parseQName-empty() {
+ parse-QName(())
+};
+
+declare
+ %test:assertEquals("foo")
+function t:parseQName-ncname() {
+ local-name-from-QName(parse-QName("foo"))
+};
+
+declare
+ %test:assertEquals("")
+function t:parseQName-ncname-ns() {
+ namespace-uri-from-QName(parse-QName("foo"))
+};
+
+declare
+ %test:assertEquals("local")
+function t:parseQName-uriQualified() {
+ local-name-from-QName(parse-QName("Q{http://example.com}local"))
+};
+
+declare
+ %test:assertEquals("http://example.com")
+function t:parseQName-uriQualified-ns() {
+ namespace-uri-from-QName(parse-QName("Q{http://example.com}local"))
+};
+
+declare
+ %test:assertEquals("integer")
+function t:parseQName-prefixed() {
+ local-name-from-QName(parse-QName("xs:integer"))
+};
+
+declare
+ %test:assertEquals("http://www.w3.org/2001/XMLSchema")
+function t:parseQName-prefixed-ns() {
+ namespace-uri-from-QName(parse-QName("xs:integer"))
+};
+
+(: fn:atomic-type-annotation :)
+
+declare
+ %test:assertTrue
+function t:atomicTypeAnnotation-integer-name() {
+ let $r := atomic-type-annotation(42)
+ return $r?name eq xs:QName("xs:integer")
+};
+
+declare
+ %test:assertTrue
+function t:atomicTypeAnnotation-integer-isSimple() {
+ atomic-type-annotation(42)?is-simple
+};
+
+declare
+ %test:assertEquals("atomic")
+function t:atomicTypeAnnotation-integer-variety() {
+ atomic-type-annotation(42)?variety
+};
+
+declare
+ %test:assertTrue
+function t:atomicTypeAnnotation-string-name() {
+ let $r := atomic-type-annotation("hello")
+ return $r?name eq xs:QName("xs:string")
+};
+
+declare
+ %test:assertTrue
+function t:atomicTypeAnnotation-boolean-name() {
+ let $r := atomic-type-annotation(true())
+ return $r?name eq xs:QName("xs:boolean")
+};
+
+(: fn:node-type-annotation :)
+
+declare
+ %test:assertTrue
+function t:nodeTypeAnnotation-element() {
+ let $r := node-type-annotation( )
+ return $r?name eq xs:QName("xs:untyped")
+};
+
+declare
+ %test:assertFalse
+function t:nodeTypeAnnotation-element-isSimple() {
+ node-type-annotation( )?is-simple
+};
+
+declare
+ %test:assertEquals("mixed")
+function t:nodeTypeAnnotation-element-variety() {
+ node-type-annotation( )?variety
+};
+
+declare
+ %test:assertTrue
+function t:nodeTypeAnnotation-attribute() {
+ let $r := node-type-annotation(( )/@a)
+ return $r?name eq xs:QName("xs:untypedAtomic")
+};
+
+declare
+ %test:assertTrue
+function t:nodeTypeAnnotation-attribute-isSimple() {
+ node-type-annotation(( )/@a)?is-simple
+};
+
+declare
+ %test:assertEquals("atomic")
+function t:nodeTypeAnnotation-attribute-variety() {
+ node-type-annotation(( )/@a)?variety
+};
+
+declare
+ %test:assertTrue
+function t:atomicTypeAnnotation-hasBaseType() {
+ let $r := atomic-type-annotation(true())
+ return map:contains($r, "base-type")
+};
+
+declare
+ %test:assertTrue
+function t:atomicTypeAnnotation-hasMatches() {
+ let $r := atomic-type-annotation(true())
+ return map:contains($r, "matches")
+};
+
+declare
+ %test:assertTrue
+function t:atomicTypeAnnotation-hasConstructor() {
+ let $r := atomic-type-annotation(true())
+ return map:contains($r, "constructor")
+};
+
+declare
+ %test:assertTrue
+function t:nodeTypeAnnotation-element-hasBaseType() {
+ let $r := node-type-annotation( )
+ return map:contains($r, "base-type")
+};
+
+(: fn:atomic-type-annotation — base-type function :)
+
+declare
+ %test:assertTrue
+function t:atomicTypeAnnotation-baseType-returns-parent() {
+ let $r := atomic-type-annotation(42)
+ let $base := $r?base-type()
+ return $base?name eq xs:QName("xs:decimal")
+};
+
+declare
+ %test:assertTrue
+function t:atomicTypeAnnotation-baseType-chain-to-anyType() {
+ (: Walk the chain: integer → decimal → anyAtomicType → anySimpleType → anyType :)
+ let $r := atomic-type-annotation(42)
+ let $decimal := $r?base-type()
+ let $atomic := $decimal?base-type()
+ let $simple := $atomic?base-type()
+ let $anyType := $simple?base-type()
+ return $anyType?name eq xs:QName("xs:anyType")
+};
+
+(: fn:atomic-type-annotation — primitive-type function :)
+
+declare
+ %test:assertTrue
+function t:atomicTypeAnnotation-primitiveType-integer() {
+ (: primitive type of xs:integer is xs:decimal :)
+ let $r := atomic-type-annotation(42)
+ let $prim := $r?primitive-type()
+ return $prim?name eq xs:QName("xs:decimal")
+};
+
+declare
+ %test:assertTrue
+function t:atomicTypeAnnotation-primitiveType-string-self() {
+ (: primitive type of xs:string is xs:string itself :)
+ let $r := atomic-type-annotation("hello")
+ let $prim := $r?primitive-type()
+ return $prim?name eq xs:QName("xs:string")
+};
+
+(: fn:atomic-type-annotation — matches function :)
+
+declare
+ %test:assertTrue
+function t:atomicTypeAnnotation-matches-true() {
+ let $r := atomic-type-annotation(42)
+ return $r?matches(xs:integer(99))
+};
+
+declare
+ %test:assertFalse
+function t:atomicTypeAnnotation-matches-false() {
+ let $r := atomic-type-annotation(42)
+ return $r?matches("not an integer")
+};
+
+(: fn:atomic-type-annotation — constructor function :)
+
+declare
+ %test:assertEquals(42)
+function t:atomicTypeAnnotation-constructor-cast() {
+ let $r := atomic-type-annotation(42)
+ return $r?constructor("42")
+};
+
+(: fn:atomic-type-annotation — variety for special types :)
+
+declare
+ %test:assertTrue
+function t:atomicTypeAnnotation-anySimpleType-noVariety() {
+ (: xs:anySimpleType has no variety :)
+ let $r := atomic-type-annotation(42)
+ (: Walk to anySimpleType: integer → decimal → anyAtomicType → anySimpleType :)
+ let $simple := $r?base-type()?base-type()?base-type()
+ return not(map:contains($simple, "variety"))
+};
+
+declare
+ %test:assertEquals("mixed")
+function t:nodeTypeAnnotation-anyType-variety() {
+ (: Walk: untyped → anyType :)
+ let $r := node-type-annotation( )
+ let $anyType := $r?base-type()
+ return $anyType?variety
+};
+
+(: fn:civil-timezone :)
+
+declare
+ %test:assertEquals("PT1H")
+function t:civilTimezone-paris-winter() {
+ string(civil-timezone(xs:dateTime("2024-11-05T12:00:00"), "Europe/Paris"))
+};
+
+declare
+ %test:assertEquals("PT2H")
+function t:civilTimezone-paris-summer() {
+ string(civil-timezone(xs:dateTime("2024-05-05T12:00:00"), "Europe/Paris"))
+};
+
+declare
+ %test:assertEquals("PT5H30M")
+function t:civilTimezone-india() {
+ string(civil-timezone(xs:dateTime("2024-06-15T12:00:00"), "Asia/Kolkata"))
+};
+
+declare
+ %test:assertEquals("-PT5H")
+function t:civilTimezone-peru() {
+ string(civil-timezone(xs:dateTime("2024-06-15T12:00:00"), "America/Lima"))
+};
+
+declare
+ %test:assertError("FODT0004")
+function t:civilTimezone-unknown-place() {
+ civil-timezone(xs:dateTime("2024-06-15T12:00:00"), "North/Pole")
+};
+
+(: fn:format-number with XQ4 map options and char:rendition :)
+
+declare
+ %test:assertEquals("12,56")
+function t:formatNumber-map-decimalRendition() {
+ (: decimal-separator marker is . for picture, rendered as , in output :)
+ format-number(12.56, '#0.##', map {
+ 'decimal-separator': '.:,'
+ })
+};
+
+declare
+ %test:assertEquals("1 234.56")
+function t:formatNumber-map-groupingRendition() {
+ (: grouping-separator marker is , for picture, but space is rendered :)
+ format-number(1234.56, '#,##0.##', map {
+ 'grouping-separator': ',: '
+ })
+};
+
+declare
+ %test:assertEquals("14pc")
+function t:formatNumber-map-percentRendition() {
+ (: percent marker is % in picture, but "pc" is rendered :)
+ format-number(0.14, '01%', map {
+ 'percent': '%:pc'
+ })
+};
+
+declare
+ %test:assertEquals("1,234.56")
+function t:formatNumber-map-noRendition() {
+ (: No rendition — marker used directly in output :)
+ format-number(1234.56, '#,##0.##', map {
+ 'decimal-separator': '.',
+ 'grouping-separator': ','
+ })
+};
+
+declare
+ %test:assertEquals("1.5EXP2")
+function t:formatNumber-map-exponentRendition() {
+ (: exponent-separator marker is e for picture, "EXP" is rendered :)
+ format-number(150, '0.0e0', map {
+ 'exponent-separator': 'e:EXP'
+ })
+};
+
+(: fn:function-annotations :)
+
+declare %private function local:annotated-fn() { 42 };
+
+declare
+ %test:assertTrue
+function t:functionAnnotations-private() {
+ (: %private annotation should be returned :)
+ let $anns := function-annotations(local:annotated-fn#0)
+ return some $m in $anns satisfies
+ map:keys($m) = xs:QName("fn:private")
+};
+
+declare
+ %test:assertTrue
+function t:functionAnnotations-builtin-empty() {
+ (: Built-in functions have no annotations :)
+ empty(function-annotations(true#0))
+};
+
+declare
+ %test:assertTrue
+function t:functionAnnotations-returns-maps() {
+ (: Each annotation is a single-entry map :)
+ let $anns := function-annotations(local:annotated-fn#0)
+ return every $m in $anns satisfies ($m instance of map(*) and map:size($m) = 1)
+};
+
+(: fn:function-identity :)
+
+declare
+ %test:assertTrue
+function t:functionIdentity-same() {
+ (: Same named function returns same identity :)
+ function-identity(true#0) eq function-identity(true#0)
+};
+
+declare
+ %test:assertFalse
+function t:functionIdentity-different() {
+ (: Different functions return different identities :)
+ function-identity(true#0) eq function-identity(false#0)
+};
+
+declare
+ %test:assertTrue
+function t:functionIdentity-isString() {
+ (: Returns a string :)
+ function-identity(true#0) instance of xs:string
+};
+
+declare
+ %test:assertTrue
+function t:functionIdentity-map-self-equal() {
+ (: Same map variable has same identity :)
+ let $m := map { "a": 1 }
+ return function-identity($m) eq function-identity($m)
+};
+
+declare
+ %test:assertTrue
+function t:functionIdentity-array-self-equal() {
+ (: Same array variable has same identity :)
+ let $a := [ 1, 2, 3 ]
+ return function-identity($a) eq function-identity($a)
+};
+
+(: ==================== fn:load-xquery-module content option ==================== :)
+
+declare
+ %test:assertEquals("world")
+function t:load-xquery-module-content() {
+ let $src := "module namespace m = 'http://example.com/test';
+ declare function m:hello() as xs:string { 'world' };"
+ let $mod := fn:load-xquery-module('http://example.com/test', map { 'content': $src })
+ let $hello := $mod?functions(QName('http://example.com/test', 'hello'))
+ return $hello?0()
+};
diff --git a/exist-core/src/test/xquery/xquery3/json-to-xml.xql b/exist-core/src/test/xquery/xquery3/json-to-xml.xql
index a0d39c153b4..e9756ba396d 100644
--- a/exist-core/src/test/xquery/xquery3/json-to-xml.xql
+++ b/exist-core/src/test/xquery/xquery3/json-to-xml.xql
@@ -43,7 +43,7 @@ function jsonxml:json-to-xml-2() {
};
declare
- %test:pending("not implemented yet")
+ %test:pending("escape option not yet implemented — Jackson does not expose raw string values")
%test:assertEquals("\\ % ")
function jsonxml:json-to-xml-3() {
json-to-xml('{"x": "\\", "y": "\u0025"}', map{'escape': true()})
@@ -58,7 +58,7 @@ function jsonxml:json-to-xml-error-1() {
declare
- %test:pending("not implemented yet")
+ %test:pending("FOJS0005 validation for invalid option values not yet implemented")
%test:assertError("err:FOJS0005")
function jsonxml:json-to-xml-error-2() {
json-to-xml('{"x": "\\", "y": "\u0025"}', map{'escape': 'invalid-value'})
diff --git a/exist-core/src/test/xquery/xquery3/replace.xqm b/exist-core/src/test/xquery/xquery3/replace.xqm
index 05943a5437d..fbf323790c1 100644
--- a/exist-core/src/test/xquery/xquery3/replace.xqm
+++ b/exist-core/src/test/xquery/xquery3/replace.xqm
@@ -19,7 +19,7 @@
: License along with this library; if not, write to the Free Software
: Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
:)
-xquery version "3.1";
+xquery version "4.0";
module namespace rt="http://exist-db.org/xquery/test/replace";
@@ -27,14 +27,15 @@ declare namespace test="http://exist-db.org/xquery/xqsuite";
declare
%test:args("")
- %test:assertError("err:FORX0003")
+ %test:assertEquals("")
%test:args(".?")
- %test:assertError("err:FORX0003")
+ %test:assertEquals("")
%test:args(".*")
- %test:assertError("err:FORX0003")
+ %test:assertEquals("")
%test:args("(.*)")
- %test:assertError("err:FORX0003")
-function rt:empty-match-fails($p as xs:string) {
+ %test:assertEquals("")
+function rt:empty-match-allowed($p as xs:string) {
+ (: XQ4: empty-matching regex no longer raises FORX0003 :)
replace("",$p,"")
};
@@ -78,3 +79,29 @@ declare
function rt:invalid-flag($flag as xs:string) {
replace("",".+","", $flag)
};
+
+(: XQ4: function replacement :)
+declare
+ %test:assertEquals("C")
+function rt:function-replacement-basic() {
+ replace("c", "c", function($k, $g) { upper-case($k) })
+};
+
+declare
+ %test:assertEquals("")
+function rt:function-replacement-empty() {
+ replace("b", "b", function($k, $g) { })
+};
+
+declare
+ %test:assertEquals("ddee")
+function rt:function-replacement-duplicate() {
+ replace("de", ".", function($k, $g) { $k || $k })
+};
+
+(: XQ4: empty replacement arg :)
+declare
+ %test:assertEquals("")
+function rt:empty-replacement-arg() {
+ replace("abc", "abc", ())
+};
diff --git a/exist-parent/pom.xml b/exist-parent/pom.xml
index b1e11b72d1b..2d92b2a0262 100644
--- a/exist-parent/pom.xml
+++ b/exist-parent/pom.xml
@@ -121,6 +121,7 @@
1.8.1.3
1.8.1.3-jakarta5
2.1.3
+ 1.4.16
9.9.1-8
6.0.21
2.11.0
@@ -597,6 +598,20 @@
${objenesis.version}
test
+
+
+
+ de.bottlecaps
+ markup-blitz
+ 1.10
+
+
+
+
+ nu.validator
+ htmlparser
+ ${htmlparser.version}
+