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/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/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/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()); } 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/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..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"); } } } 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()}). *