From 4434be9a139a3f2c8db9d9a334f5a25614baed33 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 22 Mar 2026 14:47:08 -0400 Subject: [PATCH 1/8] [bugfix] Fix XPTY0004 vs FORG0001 error codes for impossible casts Per XPath F&O 3.1 Section 19, casting between types that have no valid conversion path (e.g., xs:time to xs:date, xs:anyURI to xs:hexBinary) should raise XPTY0004, not FORG0001. FORG0001 is reserved for when the cast IS allowed but the specific value is invalid. Add Type.isCastable() implementing the XQuery 3.1 casting table to pre-validate cast operations before attempting them. CastExpression now checks castability first and raises XPTY0004 for impossible casts. CastableExpression returns false immediately for impossible casts. Fixes ~580 XQTS 3.1 test failures in prod-CastExpr and related sets. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/org/exist/xquery/CastExpression.java | 7 ++ .../org/exist/xquery/CastableExpression.java | 6 + .../java/org/exist/xquery/value/Type.java | 111 ++++++++++++++++++ 3 files changed, 124 insertions(+) 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..bda292f1906 100644 --- a/exist-core/src/main/java/org/exist/xquery/CastExpression.java +++ b/exist-core/src/main/java/org/exist/xquery/CastExpression.java @@ -117,6 +117,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..73b8cf9063e 100644 --- a/exist-core/src/main/java/org/exist/xquery/CastableExpression.java +++ b/exist-core/src/main/java/org/exist/xquery/CastableExpression.java @@ -118,6 +118,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 +130,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/value/Type.java b/exist-core/src/main/java/org/exist/xquery/value/Type.java index f60c60d7255..dd3312120c9 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,117 @@ 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, or same type is always allowed + if (sourceType == targetType || targetType == ITEM || targetType == ANY_ATOMIC_TYPE) { + return true; + } + + // xs:untypedAtomic and xs:string can be cast to any atomic type + if (sourceType == UNTYPED_ATOMIC || sourceType == STRING || subTypeOf(sourceType, STRING)) { + return true; + } + + // Any atomic type can be cast to xs:untypedAtomic or xs:string + if (targetType == UNTYPED_ATOMIC || targetType == STRING || subTypeOf(targetType, STRING)) { + 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 + if (srcPrimitive == tgtPrimitive) { + return true; + } + + // XPath F&O 3.1, Section 19, Table 6 — Casting table by primitive type pairs. + // 'M' (may) or 'Y' (yes) entries return true; 'N' (no) entries return false. + return switch (srcPrimitive) { + case FLOAT, DOUBLE, DECIMAL -> + // Numerics can cast to: other numerics, boolean, duration subtypes + tgtPrimitive == FLOAT || tgtPrimitive == DOUBLE || tgtPrimitive == DECIMAL + || tgtPrimitive == BOOLEAN; + + case BOOLEAN -> + // Boolean can cast to: numerics + tgtPrimitive == FLOAT || tgtPrimitive == DOUBLE || tgtPrimitive == DECIMAL; + + case DURATION -> + // Duration can cast to: duration subtypes (yearMonthDuration, dayTimeDuration) + // duration subtypes share the same primitive: DURATION + // So this is already handled by srcPrimitive == tgtPrimitive above + false; + + case DATE_TIME -> + // dateTime can cast to: date, time, gYearMonth, gYear, gMonthDay, gDay, gMonth + tgtPrimitive == DATE || tgtPrimitive == TIME + || tgtPrimitive == G_YEAR_MONTH || tgtPrimitive == G_YEAR + || tgtPrimitive == G_MONTH_DAY || tgtPrimitive == G_DAY + || tgtPrimitive == G_MONTH; + + case DATE -> + // date can cast to: dateTime, gYearMonth, gYear, gMonthDay, gDay, gMonth + tgtPrimitive == DATE_TIME + || tgtPrimitive == G_YEAR_MONTH || tgtPrimitive == G_YEAR + || tgtPrimitive == G_MONTH_DAY || tgtPrimitive == G_DAY + || tgtPrimitive == G_MONTH; + + case TIME -> + // time CANNOT cast to anything except string/untypedAtomic (handled above) + false; + + case G_YEAR_MONTH, G_YEAR, G_MONTH_DAY, G_DAY, G_MONTH -> + // Gregorian types can only cast to: dateTime (if enough info), but spec says N + // except string/untypedAtomic (handled above) + false; + + case HEX_BINARY -> + // hexBinary can cast to: base64Binary + tgtPrimitive == BASE64_BINARY; + + case BASE64_BINARY -> + // base64Binary can cast to: hexBinary + tgtPrimitive == HEX_BINARY; + + case ANY_URI -> + // anyURI cannot cast to anything except string/untypedAtomic (handled above) + false; + + case QNAME -> + // QName cannot cast to anything except string/untypedAtomic (handled above) + // (and NOTATION, but that's rarely used) + tgtPrimitive == NOTATION; + + case NOTATION -> + tgtPrimitive == QNAME; + + default -> true; // Unknown — allow and let convertTo() decide + }; + } + /** * Get the XDM equivalent type of a DOM Node type (i.e. {@link Node#getNodeType()}). * From 97f7bde4e68615d69fe75df7868437563e6b3fd4 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 22 Mar 2026 14:47:33 -0400 Subject: [PATCH 2/8] [bugfix] Allow mixed numeric type comparisons in fn:min and fn:max fn:min and fn:max threw FORG0006 when given mixed numeric types (e.g., xs:integer and xs:double) because getCommonSuperType() returned ANY_ATOMIC_TYPE for cross-numeric-family types. Per XQuery 3.1, mixed numeric types should be promoted to a common type before comparison. Add a check: if both types are members of the xs:numeric union, allow the comparison to proceed to the existing numeric promotion code. Fixes ~60 XQTS 3.1 test failures in fn-min and fn-max. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/java/org/exist/xquery/functions/fn/FunMax.java | 4 +++- .../src/main/java/org/exist/xquery/functions/fn/FunMin.java | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) 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); } From 22b515d8a4b6291b91daf0324d0bd0d4e3561172 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 22 Mar 2026 14:47:48 -0400 Subject: [PATCH 3/8] [bugfix] Throw FOCA0002 for NaN/INF cast to integer/decimal types When casting xs:double NaN, INF, or -INF to xs:integer or xs:decimal, eXist incorrectly raised FORG0001. Per XPath F&O 3.1 Section 4.1.16, FOCA0002 should be raised when the cast value is outside the target type's value space (NaN and infinities have no integer/decimal representation). Fixes ~44 XQTS 3.1 test failures in prod-CastExpr. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/org/exist/xquery/value/DoubleValue.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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..6fcfbd51672 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,27 @@ public AtomicValue convertTo(final int requiredType) throws XPathException { public DecimalValue toDecimalValue() throws XPathException { if (isNaN() || isInfinite()) { - throw conversionError(Type.DECIMAL); + throw new XPathException(getExpression(), ErrorCodes.FOCA0002, + "Cannot convert " + Type.getTypeName(getType()) + "('" + getStringValue() + + "') to " + Type.getTypeName(Type.DECIMAL)); } return new DecimalValue(getExpression(), BigDecimal.valueOf(value)); } public IntegerValue toIntegerValue() throws XPathException { if (isNaN() || isInfinite()) { - throw conversionError(Type.INTEGER); + throw new XPathException(getExpression(), ErrorCodes.FOCA0002, + "Cannot convert " + Type.getTypeName(getType()) + "('" + getStringValue() + + "') to " + Type.getTypeName(Type.INTEGER)); } return new IntegerValue(getExpression(), (long) value); } public IntegerValue toIntegerSubType(final int subType) throws XPathException { if (isNaN() || isInfinite()) { - throw conversionError(subType); + throw new XPathException(getExpression(), ErrorCodes.FOCA0002, + "Cannot convert " + Type.getTypeName(getType()) + "('" + getStringValue() + + "') to " + Type.getTypeName(subType)); } if (subType != Type.INTEGER && value > Integer.MAX_VALUE) { throw new XPathException(getExpression(), ErrorCodes.FOCA0003, "Value is out of range for type " From 1f69c4868974ef4d56da395d77c749a8cfab645f Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 22 Mar 2026 14:48:44 -0400 Subject: [PATCH 4/8] [bugfix] Support casting xs:boolean to integer subtypes xs:boolean cast to integer subtypes like xs:nonPositiveInteger or xs:negativeInteger hit the default case and threw an incorrect error. Now routes through IntegerValue conversion which properly validates the value against the subtype's range (e.g., true=1 is invalid for xs:nonPositiveInteger, producing the correct FORG0001). Also fixes the default error code from XPTY0004 to FORG0001 for BooleanValue, since any cast that reaches convertTo() has already passed the casting table validation. Fixes ~5 XQTS 3.1 test failures in prod-CastExpr. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/java/org/exist/xquery/value/BooleanValue.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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)); } } From f92f3e6f02bbc5e12b770b0aa1964937ac961a55 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 22 Mar 2026 17:15:47 -0400 Subject: [PATCH 5/8] [refactor] Address PR review: reduce duplication and NPath complexity DoubleValue: reuse conversionError() with an error code parameter instead of inlining XPathException construction in toDecimalValue(), toIntegerValue(), and toIntegerSubType(). Type.isCastable(): extract primitive casting table lookup into isPrimitiveCastable() and factor repeated type-group checks into isNumericTarget(), isDateTimeTarget(), isGregorianTarget(), and isStringOrUntypedAtomic() helpers. Reduces NPath complexity from 8960 to below the 200 threshold. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../org/exist/xquery/value/DoubleValue.java | 18 ++-- .../java/org/exist/xquery/value/Type.java | 96 +++++++++---------- 2 files changed, 52 insertions(+), 62 deletions(-) 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 6fcfbd51672..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,27 +195,21 @@ public AtomicValue convertTo(final int requiredType) throws XPathException { public DecimalValue toDecimalValue() throws XPathException { if (isNaN() || isInfinite()) { - throw new XPathException(getExpression(), ErrorCodes.FOCA0002, - "Cannot convert " + Type.getTypeName(getType()) + "('" + getStringValue() - + "') to " + Type.getTypeName(Type.DECIMAL)); + throw conversionError(ErrorCodes.FOCA0002, Type.DECIMAL); } return new DecimalValue(getExpression(), BigDecimal.valueOf(value)); } public IntegerValue toIntegerValue() throws XPathException { if (isNaN() || isInfinite()) { - throw new XPathException(getExpression(), ErrorCodes.FOCA0002, - "Cannot convert " + Type.getTypeName(getType()) + "('" + getStringValue() - + "') to " + Type.getTypeName(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 new XPathException(getExpression(), ErrorCodes.FOCA0002, - "Cannot convert " + Type.getTypeName(getType()) + "('" + getStringValue() - + "') to " + Type.getTypeName(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 " @@ -225,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/Type.java b/exist-core/src/main/java/org/exist/xquery/value/Type.java index dd3312120c9..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 @@ -731,18 +731,9 @@ public static int primitiveTypeOf(final int type) throws IllegalArgumentExceptio * @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, or same type is always allowed - if (sourceType == targetType || targetType == ITEM || targetType == ANY_ATOMIC_TYPE) { - return true; - } - - // xs:untypedAtomic and xs:string can be cast to any atomic type - if (sourceType == UNTYPED_ATOMIC || sourceType == STRING || subTypeOf(sourceType, STRING)) { - return true; - } - - // Any atomic type can be cast to xs:untypedAtomic or xs:string - if (targetType == UNTYPED_ATOMIC || targetType == STRING || subTypeOf(targetType, STRING)) { + // 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; } @@ -757,76 +748,77 @@ public static boolean isCastable(final int sourceType, final int targetType) { return true; } - // Same primitive type is always castable - if (srcPrimitive == tgtPrimitive) { - 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); + } - // XPath F&O 3.1, Section 19, Table 6 — Casting table by primitive type pairs. - // 'M' (may) or 'Y' (yes) entries return true; 'N' (no) entries return false. + /** + * 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 -> - // Numerics can cast to: other numerics, boolean, duration subtypes - tgtPrimitive == FLOAT || tgtPrimitive == DOUBLE || tgtPrimitive == DECIMAL - || tgtPrimitive == BOOLEAN; + isNumericTarget(tgtPrimitive) || tgtPrimitive == BOOLEAN; case BOOLEAN -> - // Boolean can cast to: numerics - tgtPrimitive == FLOAT || tgtPrimitive == DOUBLE || tgtPrimitive == DECIMAL; + isNumericTarget(tgtPrimitive); case DURATION -> - // Duration can cast to: duration subtypes (yearMonthDuration, dayTimeDuration) - // duration subtypes share the same primitive: DURATION - // So this is already handled by srcPrimitive == tgtPrimitive above false; case DATE_TIME -> - // dateTime can cast to: date, time, gYearMonth, gYear, gMonthDay, gDay, gMonth - tgtPrimitive == DATE || tgtPrimitive == TIME - || tgtPrimitive == G_YEAR_MONTH || tgtPrimitive == G_YEAR - || tgtPrimitive == G_MONTH_DAY || tgtPrimitive == G_DAY - || tgtPrimitive == G_MONTH; + isDateTimeTarget(tgtPrimitive); case DATE -> - // date can cast to: dateTime, gYearMonth, gYear, gMonthDay, gDay, gMonth - tgtPrimitive == DATE_TIME - || tgtPrimitive == G_YEAR_MONTH || tgtPrimitive == G_YEAR - || tgtPrimitive == G_MONTH_DAY || tgtPrimitive == G_DAY - || tgtPrimitive == G_MONTH; - - case TIME -> - // time CANNOT cast to anything except string/untypedAtomic (handled above) - false; + tgtPrimitive == DATE_TIME || isGregorianTarget(tgtPrimitive); - case G_YEAR_MONTH, G_YEAR, G_MONTH_DAY, G_DAY, G_MONTH -> - // Gregorian types can only cast to: dateTime (if enough info), but spec says N - // except string/untypedAtomic (handled above) + case TIME, G_YEAR_MONTH, G_YEAR, G_MONTH_DAY, G_DAY, G_MONTH, ANY_URI -> false; case HEX_BINARY -> - // hexBinary can cast to: base64Binary tgtPrimitive == BASE64_BINARY; case BASE64_BINARY -> - // base64Binary can cast to: hexBinary tgtPrimitive == HEX_BINARY; - case ANY_URI -> - // anyURI cannot cast to anything except string/untypedAtomic (handled above) - false; - case QNAME -> - // QName cannot cast to anything except string/untypedAtomic (handled above) - // (and NOTATION, but that's rarely used) tgtPrimitive == NOTATION; case NOTATION -> tgtPrimitive == QNAME; - default -> true; // Unknown — allow and let convertTo() decide + 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()}). * From bd7ccdc59c9c09023d0f1a41d610387a95297ae0 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 22 Mar 2026 21:01:16 -0400 Subject: [PATCH 6/8] [bugfix] Fix generic ERROR codes to FORG0001 in type validation StringValue.checkType() threw XPathException without an ErrorCodes constant for xs:language, xs:Name, xs:NCName, xs:ID, xs:IDREF, xs:ENTITY, and xs:NMTOKEN validation failures. The runner reported these as exerr:ERROR instead of FORG0001. Base64BinaryValueType.verifyString() had the error code as a string in the message ("FORG0001: Invalid base64 data") instead of using ErrorCodes.FORG0001 as the structured error code parameter. Fixes ~58 XQTS HEAD test failures in prod-CastExpr and misc-CombinedErrorCodes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../org/exist/xquery/value/Base64BinaryValueType.java | 3 ++- .../src/main/java/org/exist/xquery/value/StringValue.java | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) 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/StringValue.java b/exist-core/src/main/java/org/exist/xquery/value/StringValue.java index 9b2fccf0c83..b1540cb7632 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 @@ -379,7 +379,7 @@ private void checkType() throws XPathException { case Type.LANGUAGE: final Matcher matcher = langPattern.matcher(value); if (!matcher.matches()) { - throw new XPathException(getExpression(), + throw new XPathException(getExpression(), ErrorCodes.FORG0001, "Type error: string " + value + " is not valid for type xs:language"); @@ -387,7 +387,7 @@ private void checkType() throws XPathException { 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, "Type error: string " + value + " is not a valid xs:Name"); } return; case Type.NCNAME: @@ -395,12 +395,12 @@ 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, "Type error: 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, "Type error: string " + value + " is not a valid xs:NMTOKEN"); } } } From a28275b3c67c3c562799af4201a8ade9d5976300 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 22 Mar 2026 21:01:42 -0400 Subject: [PATCH 7/8] [bugfix] Fix fn:json-doc URI resolution and error codes Two issues prevented fn:json-doc from working with relative URIs: 1. Relative URIs (no scheme) were unconditionally prefixed with the eXist database URI prefix, bypassing filesystem resolution. Now resolves relative URIs against the static base URI first, matching the behavior expected by the XQTS test suite. 2. JSON parse errors (JsonParseException) were caught by the generic IOException handler and reported as FOUT1170 (source not found) instead of FOJS0001 (JSON syntax error). Now catches JsonParseException separately. Fixes ~295 XQTS HEAD test failures in misc-JsonTestSuite (0% to 93%) and improves fn-json-doc results. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/org/exist/xquery/functions/fn/JSON.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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..d05df2c016f 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 @@ -22,6 +22,7 @@ package org.exist.xquery.functions.fn; import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; import org.exist.Namespaces; @@ -38,6 +39,8 @@ import java.io.IOException; import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; import static org.exist.xquery.FunctionDSL.*; import static org.exist.xquery.functions.fn.FnModule.functionSignatures; @@ -212,6 +215,17 @@ private Sequence parseResource(Sequence href, String handleDuplicates, JsonFacto } try { String url = href.getStringValue(); + // Resolve relative URIs against the static base URI + if (url.indexOf(':') == Constants.STRING_NOT_FOUND) { + final String baseURI = context.getBaseURI().getStringValue(); + if (baseURI != null && !baseURI.isEmpty()) { + try { + url = new URI(baseURI).resolve(url).toString(); + } catch (final URISyntaxException e) { + // fall through to default handling + } + } + } if (url.indexOf(':') == Constants.STRING_NOT_FOUND) { url = XmldbURI.EMBEDDED_SERVER_URI_PREFIX + url; } @@ -225,6 +239,8 @@ private Sequence parseResource(Sequence href, String handleDuplicates, JsonFacto final Item result = readValue(context, parser, handleDuplicates); return result == null ? Sequence.EMPTY_SEQUENCE : result.toSequence(); } + } catch (final JsonParseException e) { + throw new XPathException(this, ErrorCodes.FOJS0001, e.getMessage()); } catch (IOException | PermissionDeniedException e) { throw new XPathException(this, ErrorCodes.FOUT1170, e.getMessage()); } From 8b7a95acaf1401cfacfba2c90f77e4fcc04c0631 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 22 Mar 2026 21:02:20 -0400 Subject: [PATCH 8/8] [bugfix] Fix fn:parse-ietf-date case sensitivity and edge cases Several IETF date parsing issues: - Day and month names were case-sensitive (rejected "SAT", "aug"). Now uses case-insensitive matching. - Hours required exactly 2 digits but the IETF grammar allows 1-2 (digit digit?). Changed parseInt minimum from 2 to 1. - Whitespace between time and timezone was mandatory but should be optional per the grammar. - Seconds detection relied on whitespace check instead of colon check, failing when timezone immediately followed time. - 24:00:00 was rejected; now normalized to 00:00:00 of the next day. - Timezone is now optional (grammar: (S? timezone)?). Fixes ~8 XQTS HEAD test failures in fn-parse-ietf-date. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../xquery/functions/fn/FunParseIetfDate.java | 64 ++++++++++++++----- 1 file changed, 47 insertions(+), 17 deletions(-) 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(); }