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..e52308a0037 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 @@ -2360,7 +2360,13 @@ throws PermissionDeniedException, EXistException, XPathException (s.getTest().getType() == Type.ATTRIBUTE && s.getAxis() == Constants.CHILD_AXIS)) // combines descendant-or-self::node()/attribute:* s.setAxis(Constants.DESCENDANT_ATTRIBUTE_AXIS); - else { + else if (s.getAxis() <= Constants.PRECEDING_SIBLING_AXIS) { + // Reverse axis: insert explicit descendant-or-self::node() step + LocationStep descStep = new LocationStep(context, Constants.DESCENDANT_SELF_AXIS, new TypeTest(Type.NODE)); + descStep.setAbbreviated(true); + path.replaceLastExpression(descStep); + path.add(step); + } else { s.setAxis(Constants.DESCENDANT_SELF_AXIS); s.setAbbreviated(true); } @@ -2984,6 +2990,13 @@ throws PermissionDeniedException, EXistException, XPathException rs.setAxis(Constants.DESCENDANT_AXIS); } else if (rs.getAxis() == Constants.SELF_AXIS) { rs.setAxis(Constants.DESCENDANT_SELF_AXIS); + } else if (rs.getAxis() <= Constants.PRECEDING_SIBLING_AXIS) { + // Reverse axis: cannot merge with descendant-or-self, + // insert explicit descendant-or-self::node() step before the reverse axis step + LocationStep descStep = new LocationStep(context, Constants.DESCENDANT_SELF_AXIS, new TypeTest(Type.NODE)); + descStep.setAbbreviated(true); + path.replaceLastExpression(descStep); + path.add(rightStep); } else { rs.setAxis(Constants.DESCENDANT_SELF_AXIS); rs.setAbbreviated(true); 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..c92f0908fed 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); } 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..994a81fedc0 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)); } @@ -117,6 +118,13 @@ public Sequence eval(final Sequence contextSequence, final Item contextItem) thr throw new XPathException(this, ErrorCodes.XPTY0004, "Cannot cast " + Type.getTypeName(item.getType()) + " to xs:QName"); } } else { + // Per XPath F&O 3.1, Section 19: if the source and target types + // have no valid casting relationship, raise XPTY0004 (not FORG0001). + if (!Type.isCastable(item.getType(), requiredType)) { + throw new XPathException(this, ErrorCodes.XPTY0004, + "Cannot cast " + Type.getTypeName(item.getType()) + + " to " + Type.getTypeName(requiredType)); + } result = item.convertTo(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..7a7d567497c 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; @@ -118,6 +119,11 @@ public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathExc } else { try { + // Per XPath F&O 3.1: if the cast is impossible per the casting table, + // castable as returns false without attempting the conversion. + if (!Type.isCastable(seq.itemAt(0).getType(), requiredType)) { + result = BooleanValue.FALSE; + } else { seq.itemAt(0).convertTo(requiredType); //If ? is specified after the target type, the result of the cast expression is an empty sequence. if (requiredCardinality.isSuperCardinalityOrEqualOf(seq.getCardinality())) @@ -125,6 +131,7 @@ public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathExc //If ? is not specified after the target type, a type error is raised [err:XPTY0004]. else {result = BooleanValue.FALSE;} + } //TODO : improve by *not* using a costly exception ? } catch(final XPathException e) { result = BooleanValue.FALSE; 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/DynamicCardinalityCheck.java b/exist-core/src/main/java/org/exist/xquery/DynamicCardinalityCheck.java index 5accad4503e..ed3c8fcd8b8 100644 --- a/exist-core/src/main/java/org/exist/xquery/DynamicCardinalityCheck.java +++ b/exist-core/src/main/java/org/exist/xquery/DynamicCardinalityCheck.java @@ -47,6 +47,10 @@ public DynamicCardinalityCheck(final XQueryContext context, final Cardinality re setLocation(expression.getLine(), expression.getColumn()); } + public Expression getExpression() { + return expression; + } + /* (non-Javadoc) * @see org.exist.xquery.Expression#analyze(org.exist.xquery.Expression) */ 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/OpSimpleMap.java b/exist-core/src/main/java/org/exist/xquery/OpSimpleMap.java index 7a4e31e6ee0..55fba78f1bb 100644 --- a/exist-core/src/main/java/org/exist/xquery/OpSimpleMap.java +++ b/exist-core/src/main/java/org/exist/xquery/OpSimpleMap.java @@ -44,7 +44,22 @@ public OpSimpleMap(XQueryContext context, PathExpr left, PathExpr right) { @Override public void analyze(AnalyzeContextInfo contextInfo) throws XPathException { left.analyze(new AnalyzeContextInfo(contextInfo)); - right.analyze(new AnalyzeContextInfo(contextInfo)); + // The right side of "!" evaluates with each left-side item as the new + // context item. Strip IN_PREDICATE so that sub-expressions (e.g. + // GeneralComparison) do not apply the node-set-intersection optimisation + // that is only valid for the outer predicate's direct children. + final AnalyzeContextInfo rightContextInfo = new AnalyzeContextInfo(contextInfo); + rightContextInfo.removeFlag(IN_PREDICATE); + right.analyze(rightContextInfo); + } + + @Override + public int getDependencies() { + // The simple map's external dependencies come from the left operand, + // which evaluates in the caller's context. The right operand evaluates + // in a per-item context derived from the left, so its dependencies are + // internal to the operator. + return left.getDependencies(); } @Override 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..aebac01eb97 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 @@ -715,13 +715,19 @@ 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); } 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..f4e19e79348 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 @@ -394,7 +394,7 @@ private static int compareContents(Node a, Node b, @Nullable final Collator coll } break; default: - throw new RuntimeException("unexpected node type " + nodeTypeA); + break; } a = findNextTextOrElementNode(a.getNextSibling()); b = findNextTextOrElementNode(b.getNextSibling()); 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/FunMax.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunMax.java index 31fe3ee95b3..059682225c1 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 @@ -170,7 +170,9 @@ public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathExc {max = value;} else { - if (Type.getCommonSuperType(max.getType(), value.getType()) == Type.ANY_ATOMIC_TYPE) { + if (Type.getCommonSuperType(max.getType(), value.getType()) == Type.ANY_ATOMIC_TYPE + && !(Type.subTypeOfUnion(max.getType(), Type.NUMERIC) + && Type.subTypeOfUnion(value.getType(), Type.NUMERIC))) { throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(max.getType()) + " and " + Type.getTypeName(value.getType()), 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..28deb22dbdb 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 @@ -170,7 +170,9 @@ public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathExc if (min == null) {min = value;} else { - if (Type.getCommonSuperType(min.getType(), value.getType()) == Type.ANY_ATOMIC_TYPE) { + if (Type.getCommonSuperType(min.getType(), value.getType()) == Type.ANY_ATOMIC_TYPE + && !(Type.subTypeOfUnion(min.getType(), Type.NUMERIC) + && Type.subTypeOfUnion(value.getType(), Type.NUMERIC))) { throw new XPathException(this, ErrorCodes.FORG0006, "Cannot compare " + Type.getTypeName(min.getType()) + " and " + Type.getTypeName(value.getType()), value); } diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunNot.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunNot.java index 64c1389563f..ada2d2285ed 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunNot.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunNot.java @@ -26,8 +26,11 @@ import org.exist.dom.QName; import org.exist.xquery.AnalyzeContextInfo; import org.exist.xquery.Cardinality; +import org.exist.xquery.Constants; import org.exist.xquery.Dependency; +import org.exist.xquery.DynamicCardinalityCheck; import org.exist.xquery.Expression; +import org.exist.xquery.LocationStep; import org.exist.xquery.Function; import org.exist.xquery.FunctionSignature; import org.exist.xquery.Profiler; @@ -81,7 +84,28 @@ public int returnsType() { * @see org.exist.xquery.functions.Function#getDependencies() */ public int getDependencies() { - return Dependency.CONTEXT_SET | getArgument(0).getDependencies(); + final Expression arg = getArgument(0); + int deps = Dependency.CONTEXT_SET | arg.getDependencies(); + // When the argument is the context item expression "." used inside a + // predicate on an atomic sequence (e.g., (0, 1, 2)[not(.)]), Predicate + // must iterate per-item. LocationStep.getDependencies() does not report + // CONTEXT_ITEM inside predicates (for the set-difference optimization), + // so we add it here to ensure correct per-item evaluation (GitHub #2308). + if (inPredicate) { + // Unwrap DynamicCardinalityCheck and similar wrappers to find the + // underlying LocationStep, since fn:not()'s argument signature + // accepts zero-or-more items which triggers cardinality wrapping. + Expression unwrapped = arg; + while (unwrapped instanceof DynamicCardinalityCheck) { + unwrapped = ((DynamicCardinalityCheck) unwrapped).getExpression(); + } + if (unwrapped instanceof LocationStep step + && step.getAxis() == Constants.SELF_AXIS + && step.getTest().getType() == Type.NODE) { + deps = deps | Dependency.CONTEXT_ITEM; + } + } + return deps; } public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathException { @@ -106,13 +130,16 @@ public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathExc (contextSequence == null || contextSequence.isPersistentSet()) && !Dependency.dependsOn(arg, Dependency.CONTEXT_ITEM)) { if (contextSequence == null || contextSequence.isEmpty()) { - // TODO: special treatment if the context sequence is empty: - // within a predicate, we just return the empty sequence - // otherwise evaluate the argument and return a boolean result -// if (inPredicate && !inWhereClause) -// result = Sequence.EMPTY_SEQUENCE; -// else - result = evalBoolean(contextSequence, contextItem, arg); + if (inPredicate) { + // When used inside a predicate in NODE mode, the result is consumed + // as a node set by Predicate.selectByNodeSet(). An empty context + // means there are no nodes to filter — the set-difference result + // is always empty. Returning a boolean here would crash with + // "cannot convert xs:boolean to a node set" (GitHub #2159). + result = Sequence.EMPTY_SEQUENCE; + } else { + result = evalBoolean(contextSequence, contextItem, arg); + } } else { result = contextSequence.toNodeSet().copy(); diff --git a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunParseIetfDate.java b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunParseIetfDate.java index 2f72af85b32..fab4936b033 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/fn/FunParseIetfDate.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/fn/FunParseIetfDate.java @@ -85,14 +85,14 @@ private class Parser { private final char[] WS = {0x000A, 0x0009, 0x000D, 0x0020}; private final String WS_STR = new String(WS); - private final String[] dayNames = { - "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", - "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" + private final String[] lowerDayNames = { + "mon", "tue", "wed", "thu", "fri", "sat", "sun", + "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday" }; - private final String[] monthNames = { - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" + private final String[] lowerMonthNames = { + "jan", "feb", "mar", "apr", "may", "jun", + "jul", "aug", "sep", "oct", "nov", "dec" }; private final String[] tzNames = { @@ -163,6 +163,17 @@ public XMLGregorianCalendar parse() throws IllegalArgumentException { if (vidx != vlen) { throw new IllegalArgumentException(value); } + // Handle 24:00:00 as midnight at the end of the day (= 00:00:00 next day) + if (hour == 24 && minute == 0 && second == 0 + && (fractionalSecond == null || fractionalSecond.signum() == 0)) { + hour = 0; + final XMLGregorianCalendar cal = TimeUtils + .getInstance() + .getFactory() + .newXMLGregorianCalendar(year, month, day, hour, minute, second, fractionalSecond, timezone); + cal.add(TimeUtils.getInstance().getFactory().newDuration(true, 0, 0, 1, 0, 0, 0)); + return cal; + } return TimeUtils .getInstance() .getFactory() @@ -170,9 +181,11 @@ public XMLGregorianCalendar parse() throws IllegalArgumentException { } private void dayName() { - if (StringUtils.startsWithAny(value, dayNames)) { - skipTo(WS_STR); - vidx++; + if (StringUtils.startsWithAny(value.substring(vidx).toLowerCase(), lowerDayNames)) { + skipTo(WS_STR + ","); + if (vidx < vlen && (isWS(peek()) || peek() == ',')) { + vidx++; + } } } @@ -180,13 +193,23 @@ private void dateSpec() throws IllegalArgumentException { if (isWS(peek())) { skipWS(); } - if (StringUtils.startsWithAny(value.substring(vidx), monthNames)) { + if (startsWithMonthName(value.substring(vidx))) { asctime(); } else { rfcDate(); } } + private boolean startsWithMonthName(final String s) { + final String lower = s.toLowerCase(); + for (final String mn : lowerMonthNames) { + if (lower.startsWith(mn)) { + return true; + } + } + return false; + } + private void rfcDate() throws IllegalArgumentException { day(); dsep(); @@ -232,8 +255,8 @@ private void month() throws IllegalArgumentException { if (vidx >= vlen) { throw new IllegalArgumentException(value); } - final String monthName = value.substring(vstart, vidx); - final int idx = Arrays.asList(monthNames).indexOf(monthName); + final String monthName = value.substring(vstart, vidx).toLowerCase(); + final int idx = Arrays.asList(lowerMonthNames).indexOf(monthName); if (idx < 0) { throw new IllegalArgumentException(value); } @@ -248,12 +271,18 @@ private void time() throws IllegalArgumentException { hours(); minutes(); seconds(); - skipWS(); - timezone(); + // Whitespace before timezone is optional per the IETF date grammar + if (isWS(peek())) { + skipWS(); + } + // Timezone is optional in the grammar: (S? timezone)? + if (vidx < vlen) { + timezone(); + } } private void hours() throws IllegalArgumentException { - hour = parseInt(2, 2); + hour = parseInt(1, 2); } private void minutes() throws IllegalArgumentException { @@ -263,12 +292,13 @@ private void minutes() throws IllegalArgumentException { } private void seconds() throws IllegalArgumentException { - if (isWS(peek())) { + if (peek() != ':') { + // No colon means no seconds component second = 0; return; } skip(':'); - second = parseInt(2, 2); + second = parseInt(1, 2); fractionalSecond = parseBigDecimal(); } 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..595f4d4fc8f 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") ) ) ); @@ -212,24 +212,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. * 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/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/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/BooleanValue.java b/exist-core/src/main/java/org/exist/xquery/value/BooleanValue.java index 07db3c5b77b..731794044a5 100644 --- a/exist-core/src/main/java/org/exist/xquery/value/BooleanValue.java +++ b/exist-core/src/main/java/org/exist/xquery/value/BooleanValue.java @@ -89,7 +89,11 @@ public AtomicValue convertTo(final int requiredType) throws XPathException { case Type.UNTYPED_ATOMIC: return new UntypedAtomicValue(getExpression(), getStringValue()); default: - throw new XPathException(getExpression(), ErrorCodes.XPTY0004, + // Handle integer subtypes (nonPositiveInteger, negativeInteger, etc.) + if (Type.subTypeOf(requiredType, Type.INTEGER)) { + return new IntegerValue(getExpression(), value ? 1 : 0).convertTo(requiredType); + } + throw new XPathException(getExpression(), ErrorCodes.FORG0001, "cannot convert 'xs:boolean(" + value + ")' to " + Type.getTypeName(requiredType)); } } 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/DoubleValue.java b/exist-core/src/main/java/org/exist/xquery/value/DoubleValue.java index 3cd6cd24094..0b64ad532b3 100644 --- a/exist-core/src/main/java/org/exist/xquery/value/DoubleValue.java +++ b/exist-core/src/main/java/org/exist/xquery/value/DoubleValue.java @@ -195,21 +195,21 @@ public AtomicValue convertTo(final int requiredType) throws XPathException { public DecimalValue toDecimalValue() throws XPathException { if (isNaN() || isInfinite()) { - throw conversionError(Type.DECIMAL); + throw conversionError(ErrorCodes.FOCA0002, Type.DECIMAL); } return new DecimalValue(getExpression(), BigDecimal.valueOf(value)); } public IntegerValue toIntegerValue() throws XPathException { if (isNaN() || isInfinite()) { - throw conversionError(Type.INTEGER); + throw conversionError(ErrorCodes.FOCA0002, Type.INTEGER); } return new IntegerValue(getExpression(), (long) value); } public IntegerValue toIntegerSubType(final int subType) throws XPathException { if (isNaN() || isInfinite()) { - throw conversionError(subType); + throw conversionError(ErrorCodes.FOCA0002, subType); } if (subType != Type.INTEGER && value > Integer.MAX_VALUE) { throw new XPathException(getExpression(), ErrorCodes.FOCA0003, "Value is out of range for type " @@ -219,7 +219,11 @@ public IntegerValue toIntegerSubType(final int subType) throws XPathException { } private XPathException conversionError(final int type) { - return new XPathException(getExpression(), ErrorCodes.FORG0001, "Cannot convert " + return conversionError(ErrorCodes.FORG0001, type); + } + + private XPathException conversionError(final ErrorCodes.ErrorCode errorCode, final int type) { + return new XPathException(getExpression(), errorCode, "Cannot convert " + Type.getTypeName(getType()) + "('" + getStringValue() + "') to " + Type.getTypeName(type)); } 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/java/org/exist/xquery/value/Type.java b/exist-core/src/main/java/org/exist/xquery/value/Type.java index f60c60d7255..e3afdb7149e 100644 --- a/exist-core/src/main/java/org/exist/xquery/value/Type.java +++ b/exist-core/src/main/java/org/exist/xquery/value/Type.java @@ -716,6 +716,109 @@ public static int primitiveTypeOf(final int type) throws IllegalArgumentExceptio return primitiveType; } + /** + * Check if a cast from sourceType to targetType is allowed per XPath F&O 3.1 + * Section 19 (Casting), Table 6. This implements the casting table that determines + * whether a cast between two primitive types is possible (may succeed for some values) + * or impossible (will never succeed for any value). + * + *

If the cast is impossible, the caller should raise XPTY0004 rather than + * attempting the cast (which would incorrectly raise FORG0001).

+ * + * @param sourceType the type constant of the source type + * @param targetType the type constant of the target type + * + * @return true if the cast may succeed (for some values), false if the cast can never succeed + */ + public static boolean isCastable(final int sourceType, final int targetType) { + // Casting to/from ITEM, ANY_ATOMIC_TYPE, same type, or string-family types is always allowed + if (sourceType == targetType || targetType == ITEM || targetType == ANY_ATOMIC_TYPE + || isStringOrUntypedAtomic(sourceType) || isStringOrUntypedAtomic(targetType)) { + return true; + } + + // Get primitive types for the casting table lookup + final int srcPrimitive; + final int tgtPrimitive; + try { + srcPrimitive = primitiveTypeOf(sourceType); + tgtPrimitive = primitiveTypeOf(targetType); + } catch (final IllegalArgumentException e) { + // Unknown type — allow the cast to proceed and let convertTo() handle errors + return true; + } + + // Same primitive type is always castable; otherwise consult the casting table + return srcPrimitive == tgtPrimitive || isPrimitiveCastable(srcPrimitive, tgtPrimitive); + } + + private static boolean isStringOrUntypedAtomic(final int type) { + return type == UNTYPED_ATOMIC || type == STRING || subTypeOf(type, STRING); + } + + /** + * Check the XPath F&O 3.1 Section 19, Table 6 casting rules for two + * primitive types. Returns true for 'M' (may) or 'Y' (yes) entries, + * false for 'N' (no) entries. + * + *

This method assumes the caller has already handled: same-type casts, + * string/untypedAtomic sources and targets, and same-primitive-type casts.

+ * + * @param srcPrimitive the primitive type of the source + * @param tgtPrimitive the primitive type of the target + * + * @return true if the cast is allowed per the casting table + */ + private static boolean isPrimitiveCastable(final int srcPrimitive, final int tgtPrimitive) { + return switch (srcPrimitive) { + case FLOAT, DOUBLE, DECIMAL -> + isNumericTarget(tgtPrimitive) || tgtPrimitive == BOOLEAN; + + case BOOLEAN -> + isNumericTarget(tgtPrimitive); + + case DURATION -> + false; + + case DATE_TIME -> + isDateTimeTarget(tgtPrimitive); + + case DATE -> + tgtPrimitive == DATE_TIME || isGregorianTarget(tgtPrimitive); + + case TIME, G_YEAR_MONTH, G_YEAR, G_MONTH_DAY, G_DAY, G_MONTH, ANY_URI -> + false; + + case HEX_BINARY -> + tgtPrimitive == BASE64_BINARY; + + case BASE64_BINARY -> + tgtPrimitive == HEX_BINARY; + + case QNAME -> + tgtPrimitive == NOTATION; + + case NOTATION -> + tgtPrimitive == QNAME; + + default -> true; + }; + } + + private static boolean isNumericTarget(final int tgtPrimitive) { + return tgtPrimitive == FLOAT || tgtPrimitive == DOUBLE || tgtPrimitive == DECIMAL; + } + + private static boolean isDateTimeTarget(final int tgtPrimitive) { + return tgtPrimitive == DATE || tgtPrimitive == TIME || isGregorianTarget(tgtPrimitive); + } + + private static boolean isGregorianTarget(final int tgtPrimitive) { + return tgtPrimitive == G_YEAR_MONTH || tgtPrimitive == G_YEAR + || tgtPrimitive == G_MONTH_DAY || tgtPrimitive == G_DAY + || tgtPrimitive == G_MONTH; + } + /** * Get the XDM equivalent type of a DOM Node type (i.e. {@link Node#getNodeType()}). * diff --git a/exist-core/src/main/resources/org/exist/xquery/lib/xqsuite/xqsuite.xql b/exist-core/src/main/resources/org/exist/xquery/lib/xqsuite/xqsuite.xql index f9171cec289..4ab36842984 100755 --- a/exist-core/src/main/resources/org/exist/xquery/lib/xqsuite/xqsuite.xql +++ b/exist-core/src/main/resources/org/exist/xquery/lib/xqsuite/xqsuite.xql @@ -935,7 +935,12 @@ declare %private function test:equals($annotation-value as element(value), $resu default return $result let $value := test:xdm-value-from-annotation-value($annotation-value) - let $normValue := test:cast-to-type($value, $result) + let $normValue := + typeswitch ($result) + case node() return + test:cast-to-type($value, $result) => test:normalize() + default return + test:cast-to-type($value, $result) return typeswitch ($normResult) case node() return diff --git a/exist-core/src/test/java/org/exist/xquery/XPathQueryTest.java b/exist-core/src/test/java/org/exist/xquery/XPathQueryTest.java index 024aad3379b..3becf68e10a 100644 --- a/exist-core/src/test/java/org/exist/xquery/XPathQueryTest.java +++ b/exist-core/src/test/java/org/exist/xquery/XPathQueryTest.java @@ -808,6 +808,65 @@ public void precedingAxis() throws XMLDBException { queryResource(service, "siblings.xml", "//a/n[. = '3']/preceding::s", 3); } + /** + * Tests that // followed by a reverse axis correctly expands to + * /descendant-or-self::node()/ + reverse axis, rather than + * collapsing the reverse axis into descendant-or-self. + * + * @see #691 + */ + @Test + public void dslashWithReverseAxis() throws XMLDBException { + final String xml = + "" + + " " + + " 1" + + " 2" + + " " + + " " + + " 3" + + " 4" + + " " + + ""; + + final XQueryService service = + storeXMLStringAndGetQueryService("dslash_reverse.xml", xml); + + // //preceding::b should produce the same count as the expanded form + queryAndAssert(service, + "let $d := doc('/db/test/dslash_reverse.xml') " + + "return count($d//preceding::b) eq count($d/descendant-or-self::node()/preceding::b)", + 1, "//preceding::b count should match expanded form"); + + // //ancestor::a should produce the same count as the expanded form + queryAndAssert(service, + "let $d := doc('/db/test/dslash_reverse.xml') " + + "return count($d//ancestor::a) eq count($d/descendant-or-self::node()/ancestor::a)", + 1, "//ancestor::a count should match expanded form"); + + // Note: //preceding-sibling::b skipped due to pre-existing NPE in + // NewArrayNodeSet.selectPrecedingSiblings when evaluating preceding-sibling + // on descendant-or-self::node() context (affects both abbreviated and expanded forms) + + // //ancestor-or-self::a should produce the same count as the expanded form + queryAndAssert(service, + "let $d := doc('/db/test/dslash_reverse.xml') " + + "return count($d//ancestor-or-self::a) eq count($d/descendant-or-self::node()/ancestor-or-self::a)", + 1, "//ancestor-or-self::a count should match expanded form"); + + // //parent::a should produce the same count as the expanded form + queryAndAssert(service, + "let $d := doc('/db/test/dslash_reverse.xml') " + + "return count($d//parent::a) eq count($d/descendant-or-self::node()/parent::a)", + 1, "//parent::a count should match expanded form"); + + // Relative path: $node//preceding::b should match expanded form + queryAndAssert(service, + "let $node := doc('/db/test/dslash_reverse.xml')/root/a[2] " + + "return count($node//preceding::b) eq count($node/descendant-or-self::node()/preceding::b)", + 1, "$node//preceding::b count should match expanded form"); + } + @Test public void position() throws XMLDBException, IOException, SAXException { diff --git a/exist-core/src/test/java/org/exist/xquery/functions/fn/FunNotBenchmark.java b/exist-core/src/test/java/org/exist/xquery/functions/fn/FunNotBenchmark.java new file mode 100644 index 00000000000..694206e9e27 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/fn/FunNotBenchmark.java @@ -0,0 +1,175 @@ +/* + * 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.test.ExistXmldbEmbeddedServer; +import org.junit.*; +import org.xmldb.api.base.XMLDBException; +import org.xmldb.api.modules.CollectionManagementService; +import org.xmldb.api.modules.XMLResource; +import org.xmldb.api.modules.XQueryService; + +import static org.junit.Assume.assumeTrue; + +/** + * Performance benchmark for fn:not() predicate evaluation. + * + *

Verifies that the set-difference optimization for fn:not() on persistent + * node sets is preserved after the fixes for issues #2159 and #2308, and that + * the new boolean-per-item fallback path for atomic sequences performs + * acceptably.

+ * + *

Skipped by default. Run with:

+ *
+ * mvn test -pl exist-core -Dtest=FunNotBenchmark \
+ *     -Dexist.run.benchmarks=true -Ddependency-check.skip=true
+ * 
+ */ +public class FunNotBenchmark { + + private static final String COLLECTION_NAME = "bench-fn-not"; + private static final int WARMUP_ITERATIONS = 100; + private static final int MEASURED_ITERATIONS = 500; + + @ClassRule + public static final ExistXmldbEmbeddedServer server = + new ExistXmldbEmbeddedServer(false, true, true); + + @BeforeClass + public static void setUp() throws XMLDBException { + assumeTrue("Benchmark skipped (pass -Dexist.run.benchmarks=true to enable)", + Boolean.getBoolean("exist.run.benchmarks")); + + final CollectionManagementService cms = + server.getRoot().getService(CollectionManagementService.class); + final var col = cms.createCollection(COLLECTION_NAME); + + // Generate test document: 200 items with varying children and attributes + final StringBuilder sb = new StringBuilder(); + sb.append("\n"); + for (int i = 1; i <= 200; i++) { + sb.append(" \n"); + if (i % 2 == 0) { + sb.append(" child-").append(i).append("\n"); + } + if (i % 5 == 0) { + sb.append(" descendant-").append(i).append("\n"); + } + sb.append(" \n"); + } + sb.append(""); + + final XMLResource res = col.createResource("data.xml", XMLResource.class); + res.setContent(sb.toString()); + col.storeResource(res); + col.close(); + } + + @AfterClass + public static void tearDown() throws XMLDBException { + try { + final CollectionManagementService cms = + server.getRoot().getService(CollectionManagementService.class); + cms.removeCollection(COLLECTION_NAME); + } catch (final XMLDBException e) { + // ignore cleanup failures + } + } + + // --- Benchmark queries --- + + private static final String DOC = "doc('/db/" + COLLECTION_NAME + "/data.xml')"; + + /** Set-difference optimization: child axis */ + @Test + public void notChild() throws XMLDBException { + runBenchmark("not(child)", + DOC + "//item[not(child)]"); + } + + /** Set-difference optimization: attribute axis */ + @Test + public void notAttribute() throws XMLDBException { + runBenchmark("not(@attr)", + DOC + "//item[not(@attr)]"); + } + + /** Set-difference optimization: descendant axis */ + @Test + public void notDescendant() throws XMLDBException { + runBenchmark("not(descendant::x)", + DOC + "//item[not(descendant::x)]"); + } + + /** Boolean fallback: general comparison inside not() */ + @Test + public void notComparison() throws XMLDBException { + runBenchmark("not(@id > 100)", + DOC + "//item[not(@id > 100)]"); + } + + /** Boolean fallback: not(.) on in-memory nodes */ + @Test + public void notDotOnNodes() throws XMLDBException { + runBenchmark("not(.) on nodes", + DOC + "//item[not(.)]"); + } + + // --- Benchmark harness --- + + private void runBenchmark(final String label, final String xquery) throws XMLDBException { + final XQueryService queryService = server.getRoot().getService(XQueryService.class); + + // Warmup + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + queryService.query(xquery); + } + + // Measured + final long[] timings = new long[MEASURED_ITERATIONS]; + for (int i = 0; i < MEASURED_ITERATIONS; i++) { + final long start = System.nanoTime(); + queryService.query(xquery); + timings[i] = System.nanoTime() - start; + } + + // Stats + long total = 0; + long min = Long.MAX_VALUE; + for (final long t : timings) { + total += t; + if (t < min) { + min = t; + } + } + final double avgMs = (total / (double) MEASURED_ITERATIONS) / 1_000_000.0; + final double minMs = min / 1_000_000.0; + final double opsPerSec = MEASURED_ITERATIONS / (total / 1_000_000_000.0); + + System.out.printf("| %-25s | %8.3f ms | %8.3f ms | %10.1f ops/s |%n", + label, avgMs, minMs, opsPerSec); + } +} diff --git a/exist-core/src/test/xquery/boolean-sequences.xq b/exist-core/src/test/xquery/boolean-sequences.xq index f5749bb1ffb..85e60b59aba 100644 --- a/exist-core/src/test/xquery/boolean-sequences.xq +++ b/exist-core/src/test/xquery/boolean-sequences.xq @@ -49,10 +49,9 @@ function boolseq:countPositivesContextItem() { }; (:~ - this is the failing issue + this was the failing issue — fixed in GitHub #2308 ~:) declare - %test:pending %test:assertEquals(1) function boolseq:countNegativesContextItem() { count($boolseq:sequence[not(.)]) diff --git a/exist-core/src/test/xquery/fn-not.xq b/exist-core/src/test/xquery/fn-not.xq new file mode 100644 index 00000000000..801fe533c5e --- /dev/null +++ b/exist-core/src/test/xquery/fn-not.xq @@ -0,0 +1,129 @@ +(: + : 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:not() predicate handling. + https://github.com/eXist-db/exist/issues/2159 + https://github.com/eXist-db/exist/issues/2308 +~:) +module namespace fn-not="http://exist-db.org/xquery/test/fn-not"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +declare variable $fn-not:collection := 'test-fn-not'; +declare variable $fn-not:doc := 'test.xml'; +declare variable $fn-not:nodes := document { + + + + + +}; + +declare + %test:setUp +function fn-not:setup() { + xmldb:create-collection('/db', $fn-not:collection), + xmldb:store('/db/' || $fn-not:collection, $fn-not:doc, $fn-not:nodes) +}; + +declare + %test:tearDown +function fn-not:teardown() { + xmldb:remove('/db/' || $fn-not:collection) +}; + +(: #2159 — not(self::x) on empty derived path must not crash :) +declare + %test:assertEquals(0) +function fn-not:empty-path-not-self() { + let $doc := + return count($doc/nonexistent/*[not(self::abc)]) +}; + +(: #2159 — not(self::x) on non-empty path filters correctly :) +declare + %test:assertEquals(1) +function fn-not:non-empty-path-not-self() { + let $doc := + return count($doc/*[not(self::abc)]) +}; + +(: #2159 — not(*) on empty path returns empty :) +declare + %test:assertEquals(0) +function fn-not:empty-path-not-wildcard() { + let $doc := + return count($doc/nonexistent/*[not(*)]) +}; + +(: Standalone not(()) returns true — boolean path unaffected :) +declare + %test:assertTrue +function fn-not:standalone-not-empty() { + not(()) +}; + +(: Set-difference optimization on persistent nodes — not(child) :) +declare + %test:assertEquals(2) +function fn-not:persistent-not-child() { + let $dom := doc('/db/' || $fn-not:collection || '/' || $fn-not:doc) + return count($dom//item[not(child)]) +}; + +(: Set-difference optimization on persistent nodes — not(self::x) :) +declare + %test:assertEquals(1) +function fn-not:persistent-not-self() { + let $dom := doc('/db/' || $fn-not:collection || '/' || $fn-not:doc) + return count($dom//item[not(@type = 'a')]) +}; + +(: #2308 — not(.) on integer sequence :) +declare + %test:assertEquals(1) +function fn-not:not-dot-integers() { + count((0, 1, 2)[not(.)]) +}; + +(: #2308 — not(.) on string sequence :) +declare + %test:assertEquals(1) +function fn-not:not-dot-strings() { + count(("", "a")[not(.)]) +}; + +(: #2308 — not(.) on mixed booleans :) +declare + %test:assertEquals(1) +function fn-not:not-dot-booleans() { + count((true(), false())[not(.)]) +}; + +(: not(.) on in-memory node sequence filters correctly :) +declare + %test:assertEquals(0) +function fn-not:not-dot-nodes() { + count((, )[not(.)]) +}; diff --git a/exist-core/src/test/xquery/simple-map-predicate.xq b/exist-core/src/test/xquery/simple-map-predicate.xq new file mode 100644 index 00000000000..fe596dd73d3 --- /dev/null +++ b/exist-core/src/test/xquery/simple-map-predicate.xq @@ -0,0 +1,136 @@ +(: + : 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 simple map operator inside predicates. + https://github.com/eXist-db/exist/issues/3289 + + The expression @*[name() ! contains(., 'DateTime')] crashes with + "Type error: the sequence cannot be converted into a node set. + Item type is xs:boolean" when used on persistent (stored) documents. +~:) +module namespace smp="http://exist-db.org/xquery/test/simple-map-predicate"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +declare variable $smp:collection := 'test-simple-map-predicate'; +declare variable $smp:doc := 'sts135.xml'; +declare variable $smp:data := + + + + ; + +declare + %test:setUp +function smp:setup() { + xmldb:create-collection('/db', $smp:collection), + xmldb:store('/db/' || $smp:collection, $smp:doc, $smp:data) +}; + +declare + %test:tearDown +function smp:teardown() { + xmldb:remove('/db/' || $smp:collection) +}; + +(:~ + #3289 — @*[name() ! contains(., 'DateTime')] on stored doc crashes with + "Type error: the sequence cannot be converted into a node set." +~:) +declare + %test:assertEquals(1) +function smp:persistent-simple-map-contains-in-attr-predicate() { + let $doc := doc('/db/' || $smp:collection || '/' || $smp:doc) + return count($doc//*[@*[name() ! contains(., 'DateTime')]]) +}; + +(:~ + Workaround from #3289: contains(name(.), ...) — should always work. +~:) +declare + %test:assertEquals(1) +function smp:persistent-contains-name-dot() { + let $doc := doc('/db/' || $smp:collection || '/' || $smp:doc) + return count($doc//*[@*[contains(name(.), 'DateTime')]]) +}; + +(:~ + Another workaround from #3289: @* ! name()[contains(., ...)] +~:) +declare + %test:assertEquals(2) +function smp:persistent-attr-map-name-predicate() { + let $doc := doc('/db/' || $smp:collection || '/' || $smp:doc) + let $event := $doc//event[1] + return count($event/@* ! name()[contains(., 'DateTime')]) +}; + +(:~ + Workaround from #3289 comment: @*[name()[contains(., ...)]] +~:) +declare + %test:assertEquals(1) +function smp:persistent-nested-name-predicate() { + let $doc := doc('/db/' || $smp:collection || '/' || $smp:doc) + return count($doc//*[@*[name()[contains(., 'DateTime')]]]) +}; + +(:~ + Workaround from #3289 comment: //@*[name() ! contains(., ...)]/.. +~:) +declare + %test:assertEquals(1) +function smp:persistent-deref-attr-parent() { + let $doc := doc('/db/' || $smp:collection || '/' || $smp:doc) + return count($doc//@*[name() ! contains(., 'DateTime')]/..) +}; + +(:~ + #3289 comment pattern 1: @* ! name() ! contains(., ...) is expected to fail + per spec when multiple attributes produce multiple booleans, since EBV is + undefined for a sequence of two or more booleans. +~:) +declare + %test:assertError("FORG0006") +function smp:persistent-double-map-expected-error() { + let $doc := doc('/db/' || $smp:collection || '/' || $smp:doc) + return $doc//*[@* ! name() ! contains(., 'DateTime')] +}; + +(:~ + In-memory version should also work — same pattern, no stored doc. +~:) +declare + %test:assertEquals(1) +function smp:inmemory-simple-map-contains-in-attr-predicate() { + let $doc := + + + + return count($doc//*[@*[name() ! contains(., 'DateTime')]]) +}; diff --git a/exist-core/src/test/xquery/xqsuite/xqsuite-tests.xql b/exist-core/src/test/xquery/xqsuite/xqsuite-tests.xql index 9ae03b138f0..c734ece8ce7 100644 --- a/exist-core/src/test/xquery/xqsuite/xqsuite-tests.xql +++ b/exist-core/src/test/xquery/xqsuite/xqsuite-tests.xql @@ -246,3 +246,12 @@ declare function t:args-assert-element($arg as element()) as element() { $arg }; + +(: https://github.com/eXist-db/exist/issues/4327 :) +declare + %test:assertEquals(' + Success! +') +function t:assertEquals-normalize-annotation-whitespace() as element(span) { + Success! +};