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 3109d357158..4703370580e 100644 --- a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g +++ b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g @@ -267,15 +267,13 @@ throws PermissionDeniedException, EXistException, XPathException v:VERSION_DECL { final String version = v.getText(); - if (version.equals("3.1")) { - context.setXQueryVersion(31); - } else if (version.equals("3.0")) { - context.setXQueryVersion(30); - } else if (version.equals("1.0")) { - context.setXQueryVersion(10); - } else { - throw new XPathException(v, ErrorCodes.XQST0031, "Wrong XQuery version: require 1.0, 3.0 or 3.1"); - } + context.setXQueryVersion(switch (version) { + case "4.0" -> 40; + case "3.1" -> 31; + case "3.0" -> 30; + case "1.0" -> 10; + default -> throw new XPathException(v, ErrorCodes.XQST0031, "Wrong XQuery version: require 1.0, 3.0, 3.1, or 4.0"); + }); } ( enc:STRING_LITERAL )? { diff --git a/exist-core/src/main/java/org/exist/xquery/DynamicTypeCheck.java b/exist-core/src/main/java/org/exist/xquery/DynamicTypeCheck.java index 1f32cbca2a8..672c6978947 100644 --- a/exist-core/src/main/java/org/exist/xquery/DynamicTypeCheck.java +++ b/exist-core/src/main/java/org/exist/xquery/DynamicTypeCheck.java @@ -139,6 +139,9 @@ private void check(Sequence result, Item item) throws XPathException { } else if (type == Type.ANY_URI && requiredType == Type.STRING) { item = item.convertTo(Type.STRING); type = Type.STRING; + // XQuery 4.0 implicit casting (spec §3.4.1 item 4) + } else if (context.getXQueryVersion() >= 40) { + item = xq4ImplicitCast(item, type, requiredType); } else { if (!(Type.subTypeOf(type, requiredType))) { throw new XPathException(expression, ErrorCodes.XPTY0004, @@ -155,6 +158,104 @@ private void check(Sequence result, Item item) throws XPathException { {result.add(item);} } + /** + * XQuery 4.0 coercion rules per spec §3.4.1. + * Handles implicit casting (item 4) and relabeling (item 6). + */ + private Item xq4ImplicitCast(Item item, final int type, final int requiredType) throws XPathException { + // Item 4: Implicit Casting table + if (isXQ4ImplicitCast(type, requiredType)) { + try { + return item.convertTo(requiredType); + } catch (final XPathException e) { + throw new XPathException(expression, ErrorCodes.XPTY0004, + "Cannot coerce " + Type.getTypeName(type) + "(" + + item.getStringValue() + ") to " + Type.getTypeName(requiredType)); + } + } + // Item 6: Relabeling — if R is derived from primitive P, and J is an instance of P, + // relabel J as R if J's datum is within the value space of R. + if (isXQ4Relabeling(type, requiredType)) { + try { + return item.convertTo(requiredType); + } catch (final XPathException e) { + throw new XPathException(expression, ErrorCodes.XPTY0004, + "Cannot relabel " + Type.getTypeName(type) + "(" + + item.getStringValue() + ") as " + Type.getTypeName(requiredType) + + ": value is not in the value space of the target type"); + } + } + // Fall through to the standard type error + if (!(Type.subTypeOf(type, requiredType))) { + throw new XPathException(expression, ErrorCodes.XPTY0004, + Type.getTypeName(item.getType()) + "(" + item.getStringValue() + + ") is not a sub-type of " + Type.getTypeName(requiredType)); + } else { + throw new XPathException(expression, ErrorCodes.FOCH0002, "Required type is " + + Type.getTypeName(requiredType) + " but got '" + Type.getTypeName(item.getType()) + "(" + + item.getStringValue() + ")'"); + } + } + + /** + * Check if an implicit cast is allowed from a source type to a target type + * under XQuery 4.0 coercion rules (spec §3.4.1 item 4, Implicit Casting table). + * + * The "from" column matches if J is an instance of "from" (including subtypes). + * The "to" column must match R exactly (the required type must be the primitive type). + */ + static boolean isXQ4ImplicitCast(final int sourceType, final int requiredType) { + // xs:string → xs:anyURI + if (Type.subTypeOf(sourceType, Type.STRING) && requiredType == Type.ANY_URI) { + return true; + } + // xs:hexBinary ↔ xs:base64Binary + if (Type.subTypeOf(sourceType, Type.HEX_BINARY) && requiredType == Type.BASE64_BINARY) { + return true; + } + if (Type.subTypeOf(sourceType, Type.BASE64_BINARY) && requiredType == Type.HEX_BINARY) { + return true; + } + // Bidirectional numeric: xs:double → xs:decimal, xs:float → xs:decimal + // (Note: decimal→float, decimal→double, float→double already handled by XQ 3.1 rules) + if (Type.subTypeOf(sourceType, Type.DOUBLE) && requiredType == Type.DECIMAL) { + return true; + } + if (Type.subTypeOf(sourceType, Type.FLOAT) && requiredType == Type.DECIMAL) { + return true; + } + // XQ4 also allows any numeric → any other numeric + // "any numeric type to be implicitly converted to any other" + if (Type.subTypeOfUnion(sourceType, Type.NUMERIC) && Type.subTypeOfUnion(requiredType, Type.NUMERIC)) { + return true; + } + return false; + } + + /** + * Check if relabeling is allowed under XQuery 4.0 coercion rules (spec §3.4.1 item 6). + * Relabeling applies when R is derived from a primitive type P, and J is an instance of P + * (but not already an instance of R). The actual value check (whether the datum is in + * the value space of R) is deferred to convertTo(). + */ + static boolean isXQ4Relabeling(final int sourceType, final int requiredType) { + // Only applies to atomic types + if (!Type.subTypeOf(sourceType, Type.ANY_ATOMIC_TYPE) || !Type.subTypeOf(requiredType, Type.ANY_ATOMIC_TYPE)) { + return false; + } + try { + final int requiredPrimitive = Type.primitiveTypeOf(requiredType); + // Relabeling only applies when R is a derived type (not a primitive itself) + if (requiredPrimitive == requiredType) { + return false; + } + // J must be an instance of the same primitive type P + return Type.subTypeOf(sourceType, requiredPrimitive); + } catch (final IllegalArgumentException e) { + return false; + } + } + /* (non-Javadoc) * @see org.exist.xquery.Expression#dump(org.exist.xquery.util.ExpressionDumper) */ diff --git a/exist-core/src/main/java/org/exist/xquery/Function.java b/exist-core/src/main/java/org/exist/xquery/Function.java index 161cba2957b..31fbbebdb0c 100644 --- a/exist-core/src/main/java/org/exist/xquery/Function.java +++ b/exist-core/src/main/java/org/exist/xquery/Function.java @@ -305,7 +305,9 @@ private Expression checkArgumentType( if (returnType != Type.ITEM && !Type.subTypeOf(returnType, argType.getPrimaryType())) { if (!(Type.subTypeOf(argType.getPrimaryType(), returnType) || //Because () is seen as a node - (argType.getCardinality().isSuperCardinalityOrEqualOf(Cardinality.EMPTY_SEQUENCE) && returnType == Type.NODE))) { + (argType.getCardinality().isSuperCardinalityOrEqualOf(Cardinality.EMPTY_SEQUENCE) && returnType == Type.NODE) || + // XQuery 4.0: allow implicit casts and relabeling + (context.getXQueryVersion() >= 40 && (DynamicTypeCheck.isXQ4ImplicitCast(returnType, argType.getPrimaryType()) || DynamicTypeCheck.isXQ4Relabeling(returnType, argType.getPrimaryType()))))) { LOG.debug(ExpressionDumper.dump(argument)); throw new XPathException(this, ErrorCodes.XPTY0004, Messages.getMessage(Error.FUNC_PARAM_TYPE_STATIC, String.valueOf(argPosition), mySignature, argType.toString(), Type.getTypeName(returnType))); diff --git a/exist-core/src/main/java/org/exist/xquery/UntypedValueCheck.java b/exist-core/src/main/java/org/exist/xquery/UntypedValueCheck.java index 5e0775574f7..15362a1e7be 100644 --- a/exist-core/src/main/java/org/exist/xquery/UntypedValueCheck.java +++ b/exist-core/src/main/java/org/exist/xquery/UntypedValueCheck.java @@ -114,7 +114,10 @@ private Item convert(Item item) throws XPathException { if (Type.subTypeOf(item.getType(), requiredType)) { return item; } - if (item.getType() == Type.INTEGER && requiredType == Type.POSITIVE_INTEGER) { + // In XQuery 3.1, reject integer→positiveInteger conversion. + // In XQuery 4.0, relabeling allows this if the value is positive (§3.4.1 item 6). + if (item.getType() == Type.INTEGER && requiredType == Type.POSITIVE_INTEGER + && context.getXQueryVersion() < 40) { throw new XPathException(this, ErrorCodes.FORG0001, "cannot convert '" + Type.getTypeName(item.getType()) diff --git a/exist-core/src/test/xquery/xquery3/xq4-type-promotion-gating.xql b/exist-core/src/test/xquery/xquery3/xq4-type-promotion-gating.xql new file mode 100644 index 00000000000..9f7ae1232d6 --- /dev/null +++ b/exist-core/src/test/xquery/xquery3/xq4-type-promotion-gating.xql @@ -0,0 +1,38 @@ +(: + : eXist-db Open Source Native XML Database + : Copyright (C) 2001 The eXist-db Authors + : + : info@exist-db.org + : http://www.exist-db.org + : + : This library is free software; you can redistribute it and/or + : modify it under the terms of the GNU Lesser General Public + : License as published by the Free Software Foundation; either + : version 2.1 of the License, or (at your option) any later version. + : + : This library is distributed in the hope that it will be useful, + : but WITHOUT ANY WARRANTY; without even the implied warranty of + : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + : Lesser General Public License for more details. + : + : You should have received a copy of the GNU Lesser General Public + : License along with this library; if not, write to the Free Software + : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + :) +xquery version "3.1"; + +module namespace tpg="http://exist-db.org/xquery/test/type-promotion-gating"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +(: Helper function that expects xs:anyURI :) +declare function tpg:expect-uri($uri as xs:anyURI) as xs:string { + string($uri) +}; + +(: In XQuery 3.1, string -> anyURI coercion should NOT work :) +declare + %test:assertError("XPTY0004") +function tpg:string-to-anyuri-rejected-in-31() { + tpg:expect-uri("http://example.com") +}; diff --git a/exist-core/src/test/xquery/xquery3/xq4-type-promotion.xql b/exist-core/src/test/xquery/xquery3/xq4-type-promotion.xql new file mode 100644 index 00000000000..1475030ed8b --- /dev/null +++ b/exist-core/src/test/xquery/xquery3/xq4-type-promotion.xql @@ -0,0 +1,111 @@ +(: + : eXist-db Open Source Native XML Database + : Copyright (C) 2001 The eXist-db Authors + : + : info@exist-db.org + : http://www.exist-db.org + : + : This library is free software; you can redistribute it and/or + : modify it under the terms of the GNU Lesser General Public + : License as published by the Free Software Foundation; either + : version 2.1 of the License, or (at your option) any later version. + : + : This library is distributed in the hope that it will be useful, + : but WITHOUT ANY WARRANTY; without even the implied warranty of + : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + : Lesser General Public License for more details. + : + : You should have received a copy of the GNU Lesser General Public + : License along with this library; if not, write to the Free Software + : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + :) +xquery version "4.0"; + +module namespace tp="http://exist-db.org/xquery/test/type-promotion"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +(: Helper function that expects xs:anyURI :) +declare function tp:expect-uri($uri as xs:anyURI) as xs:string { + string($uri) +}; + +(: Helper function that expects xs:base64Binary :) +declare function tp:expect-base64($val as xs:base64Binary) as xs:string { + string($val) +}; + +(: Helper function that expects xs:hexBinary :) +declare function tp:expect-hex($val as xs:hexBinary) as xs:string { + string($val) +}; + +(: Helper function that expects xs:decimal :) +declare function tp:expect-decimal($val as xs:decimal) as xs:decimal { + $val +}; + +(: === xs:string -> xs:anyURI implicit casting === :) + +declare + %test:assertEquals("http://example.com") +function tp:string-to-anyuri-coercion() { + tp:expect-uri("http://example.com") +}; + +declare + %test:assertEquals("") +function tp:empty-string-to-anyuri() { + tp:expect-uri("") +}; + +(: === xs:hexBinary <-> xs:base64Binary implicit casting === :) + +declare + %test:assertEquals("AQID") +function tp:hex-to-base64-coercion() { + tp:expect-base64(xs:hexBinary("010203")) +}; + +declare + %test:assertEquals("010203") +function tp:base64-to-hex-coercion() { + tp:expect-hex(xs:base64Binary("AQID")) +}; + +(: === Bidirectional numeric implicit casting === :) + +declare + %test:assertEquals(3.14) +function tp:double-to-decimal-coercion() { + tp:expect-decimal(3.14e0) +}; + +(: === Relabeling: derived atomic types (§3.4.1 item 6) === :) + +(: Helper function that expects xs:positiveInteger :) +declare function tp:expect-positive-integer($val as xs:positiveInteger) as xs:integer { + $val +}; + +declare + %test:assertEquals(42) +function tp:integer-to-positive-integer-relabeling() { + tp:expect-positive-integer(42) +}; + +declare + %test:assertError("XPTY0004") +function tp:negative-integer-to-positive-integer-fails() { + tp:expect-positive-integer(-5) +}; + +(: === Version gating: ensure coercion only works in XQ4 === :) +(: These tests run in this 4.0 module, so they should pass :) + +declare + %test:assertTrue +function tp:string-to-anyuri-instance-check() { + let $result := tp:expect-uri("test") + return $result instance of xs:string +};