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 41e356645b1..c1d87cdd397 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 @@ -24,7 +24,6 @@ import com.ibm.icu.text.Collator; import org.exist.dom.QName; import org.exist.xquery.Cardinality; -import org.exist.xquery.Constants; import org.exist.xquery.Dependency; import org.exist.xquery.ErrorCodes; import org.exist.xquery.Function; @@ -33,11 +32,14 @@ import org.exist.xquery.XPathException; import org.exist.xquery.XQueryContext; import org.exist.xquery.value.AtomicValue; +import org.exist.xquery.value.DoubleValue; import org.exist.xquery.value.DurationValue; +import org.exist.xquery.value.FloatValue; import org.exist.xquery.value.FunctionParameterSequenceType; import org.exist.xquery.value.FunctionReturnSequenceType; import org.exist.xquery.value.Item; import org.exist.xquery.value.NumericValue; +import org.exist.xquery.value.QNameValue; import org.exist.xquery.value.Sequence; import org.exist.xquery.value.SequenceIterator; import org.exist.xquery.value.SequenceType; @@ -127,34 +129,49 @@ private Collator getOptionalCollator(Sequence contextSequence, Item contextItem) private Sequence findMax(Sequence arg, Collator collator) throws XPathException { final SequenceIterator iter = arg.unorderedIterator(); AtomicValue max = null; - boolean hasNaN = false; - AtomicValue nanValue = null; while (iter.hasNext()) { final Item item = iter.nextItem(); AtomicValue value = item.atomize(); + // XQ 3.1: xs:QName has no order + if (value instanceof QNameValue) { + throw new XPathException(this, ErrorCodes.FORG0006, + "Cannot compare " + Type.getTypeName(value.getType()), value); + } + // Cast untypedAtomic to double if (value.getType() == Type.UNTYPED_ATOMIC) { value = value.convertTo(Type.DOUBLE); } - // Wrap duration subtypes + // Validate and wrap duration subtypes if (Type.subTypeOf(value.getType(), Type.DURATION)) { - value = ((DurationValue) value).wrap(); + value = validateAndWrapDuration((DurationValue) value, max); } - // Track NaN: if any value is NaN, result is NaN + // XQ 3.1 numeric type promotion: ensure both operands share the + // least common numeric type that supports comparison, so that + // the returned value carries the promoted type (e.g. max((1, xs:float(2))) + // is xs:float, not xs:integer). + if (value instanceof NumericValue && max instanceof NumericValue) { + max = max.promote(value); + value = value.promote(max); + } + + // NaN propagation: any NaN in the input forces the result to be NaN, + // typed at the highest numeric type seen so far. if (value instanceof NumericValue && ((NumericValue) value).isNaN()) { - if (!hasNaN) { - hasNaN = true; - nanValue = value; - } + max = promoteNaN(value, max); continue; } if (max == null) { max = value; + } else if (max instanceof NumericValue && ((NumericValue) max).isNaN()) { + // max is already NaN and won't be displaced; keep it but + // upgrade its type if value introduced a wider numeric type. + max = promoteNaN(max, value); } else { try { final int cmp = FunCompare.compare(value, max, collator); @@ -169,9 +186,47 @@ private Sequence findMax(Sequence arg, Collator collator) throws XPathException } } - if (hasNaN) { - return nanValue; - } return max; } + + /** + * Validate and wrap a duration value per XQ 3.1 fn:min/fn:max rules: + * only xs:yearMonthDuration or xs:dayTimeDuration are accepted, and all + * durations in the sequence must share the same subtype. + */ + private AtomicValue validateAndWrapDuration(final DurationValue value, final AtomicValue accumulator) + throws XPathException { + final DurationValue wrapped = value.wrap(); + final int wrappedType = wrapped.getType(); + if (wrappedType != Type.YEAR_MONTH_DURATION && wrappedType != Type.DAY_TIME_DURATION) { + throw new XPathException(this, ErrorCodes.FORG0006, + "Cannot compare " + Type.getTypeName(wrappedType), wrapped); + } + if (accumulator != null + && Type.subTypeOf(accumulator.getType(), Type.DURATION) + && accumulator.getType() != wrappedType) { + throw new XPathException(this, ErrorCodes.FORG0006, + "Cannot compare " + Type.getTypeName(accumulator.getType()) + + " and " + Type.getTypeName(wrappedType), wrapped); + } + return wrapped; + } + + /** + * Return a NaN typed at the widest numeric type between the incoming NaN + * value and the current accumulator. Used so that + * max((xs:float("NaN"), xs:double(2))) returns xs:double NaN. + */ + private static AtomicValue promoteNaN(final AtomicValue nan, final AtomicValue other) { + if (other == null) { + return nan; + } + if (nan.getType() == Type.DOUBLE || other.getType() == Type.DOUBLE) { + return DoubleValue.NaN; + } + if (nan.getType() == Type.FLOAT || other.getType() == Type.FLOAT) { + return FloatValue.NaN; + } + return nan; + } } 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 10d58c59c4d..bd4fc0adefb 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 @@ -24,7 +24,6 @@ import com.ibm.icu.text.Collator; import org.exist.dom.QName; import org.exist.xquery.Cardinality; -import org.exist.xquery.Constants; import org.exist.xquery.Dependency; import org.exist.xquery.ErrorCodes; import org.exist.xquery.Function; @@ -33,11 +32,14 @@ import org.exist.xquery.XPathException; import org.exist.xquery.XQueryContext; import org.exist.xquery.value.AtomicValue; +import org.exist.xquery.value.DoubleValue; import org.exist.xquery.value.DurationValue; +import org.exist.xquery.value.FloatValue; import org.exist.xquery.value.FunctionParameterSequenceType; import org.exist.xquery.value.FunctionReturnSequenceType; import org.exist.xquery.value.Item; import org.exist.xquery.value.NumericValue; +import org.exist.xquery.value.QNameValue; import org.exist.xquery.value.Sequence; import org.exist.xquery.value.SequenceIterator; import org.exist.xquery.value.SequenceType; @@ -127,34 +129,49 @@ private Collator getOptionalCollator(Sequence contextSequence, Item contextItem) private Sequence findMin(Sequence arg, Collator collator) throws XPathException { final SequenceIterator iter = arg.unorderedIterator(); AtomicValue min = null; - boolean hasNaN = false; - AtomicValue nanValue = null; while (iter.hasNext()) { final Item item = iter.nextItem(); AtomicValue value = item.atomize(); + // XQ 3.1: xs:QName has no order + if (value instanceof QNameValue) { + throw new XPathException(this, ErrorCodes.FORG0006, + "Cannot compare " + Type.getTypeName(value.getType()), value); + } + // Cast untypedAtomic to double if (value.getType() == Type.UNTYPED_ATOMIC) { value = value.convertTo(Type.DOUBLE); } - // Wrap duration subtypes + // Validate and wrap duration subtypes if (Type.subTypeOf(value.getType(), Type.DURATION)) { - value = ((DurationValue) value).wrap(); + value = validateAndWrapDuration((DurationValue) value, min); } - // Track NaN: if any value is NaN, result is NaN + // XQ 3.1 numeric type promotion: ensure both operands share the + // least common numeric type that supports comparison, so that + // the returned value carries the promoted type (e.g. min((1, xs:float(2))) + // is xs:float, not xs:integer). + if (value instanceof NumericValue && min instanceof NumericValue) { + min = min.promote(value); + value = value.promote(min); + } + + // NaN propagation: any NaN in the input forces the result to be NaN, + // typed at the highest numeric type seen so far. if (value instanceof NumericValue && ((NumericValue) value).isNaN()) { - if (!hasNaN) { - hasNaN = true; - nanValue = value; - } + min = promoteNaN(value, min); continue; } if (min == null) { min = value; + } else if (min instanceof NumericValue && ((NumericValue) min).isNaN()) { + // min is already NaN and won't be displaced; keep it but + // upgrade its type if value introduced a wider numeric type. + min = promoteNaN(min, value); } else { try { final int cmp = FunCompare.compare(value, min, collator); @@ -169,9 +186,47 @@ private Sequence findMin(Sequence arg, Collator collator) throws XPathException } } - if (hasNaN) { - return nanValue; - } return min; } + + /** + * Validate and wrap a duration value per XQ 3.1 fn:min/fn:max rules: + * only xs:yearMonthDuration or xs:dayTimeDuration are accepted, and all + * durations in the sequence must share the same subtype. + */ + private AtomicValue validateAndWrapDuration(final DurationValue value, final AtomicValue accumulator) + throws XPathException { + final DurationValue wrapped = value.wrap(); + final int wrappedType = wrapped.getType(); + if (wrappedType != Type.YEAR_MONTH_DURATION && wrappedType != Type.DAY_TIME_DURATION) { + throw new XPathException(this, ErrorCodes.FORG0006, + "Cannot compare " + Type.getTypeName(wrappedType), wrapped); + } + if (accumulator != null + && Type.subTypeOf(accumulator.getType(), Type.DURATION) + && accumulator.getType() != wrappedType) { + throw new XPathException(this, ErrorCodes.FORG0006, + "Cannot compare " + Type.getTypeName(accumulator.getType()) + + " and " + Type.getTypeName(wrappedType), wrapped); + } + return wrapped; + } + + /** + * Return a NaN typed at the widest numeric type between the incoming NaN + * value and the current accumulator. Used so that + * min((xs:float("NaN"), xs:double(2))) returns xs:double NaN. + */ + private static AtomicValue promoteNaN(final AtomicValue nan, final AtomicValue other) { + if (other == null) { + return nan; + } + if (nan.getType() == Type.DOUBLE || other.getType() == Type.DOUBLE) { + return DoubleValue.NaN; + } + if (nan.getType() == Type.FLOAT || other.getType() == Type.FLOAT) { + return FloatValue.NaN; + } + return nan; + } } diff --git a/exist-core/src/test/java/org/exist/xquery/functions/fn/FunMinMaxTest.java b/exist-core/src/test/java/org/exist/xquery/functions/fn/FunMinMaxTest.java new file mode 100644 index 00000000000..1d2b6c5c724 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/fn/FunMinMaxTest.java @@ -0,0 +1,158 @@ +/* + * 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.ClassRule; +import org.junit.Test; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; +import org.xmldb.api.modules.XPathQueryService; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Regression tests for fn:min and fn:max XQ 3.1 type-promotion semantics. + * These tests mirror W3C XQTS cases that flagged as regressions when the + * fn:min / fn:max implementations were rewritten for XQuery 4.0 in PR #6218. + */ +public class FunMinMaxTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer server = new ExistXmldbEmbeddedServer(true, true, true); + + private String firstResult(final String query) throws XMLDBException { + final XPathQueryService q = server.getRoot().getService(XPathQueryService.class); + final ResourceSet rs = q.query(query); + assertEquals("expected one result for: " + query, 1, rs.getSize()); + return rs.getResource(0).getContent().toString(); + } + + private void expectError(final String code, final String query) { + try { + final XPathQueryService q = server.getRoot().getService(XPathQueryService.class); + q.query(query); + fail("expected " + code + " for: " + query); + } catch (final XMLDBException e) { + final String msg = e.getMessage() == null ? "" : e.getMessage(); + assertTrue("expected " + code + " in error, got: " + msg, msg.contains(code)); + } + } + + // K-SeqMINFunc-14: integer + float + decimal must promote to xs:float + @Test + public void min_integerFloatDecimal_promotesToFloat() throws XMLDBException { + assertEquals("true", + firstResult("min((1, xs:float(2), xs:decimal(3))) instance of xs:float")); + } + + // K2-SeqMINFunc-7 + @Test + public void min_integerDouble_promotesToDouble() throws XMLDBException { + assertEquals("true", + firstResult("min((5, 5.0e0)) instance of xs:double")); + } + + // K2-SeqMINFunc-9 + @Test + public void min_integerDouble_promotesToDouble2() throws XMLDBException { + assertEquals("true", + firstResult("min((3, 5.0e0)) instance of xs:double")); + } + + // K-SeqMINFunc-18 + @Test + public void min_floatNaN_untyped_double_isDouble() throws XMLDBException { + assertEquals("true", + firstResult("min((xs:float('NaN'), xs:untypedAtomic('3'), xs:double(2))) instance of xs:double")); + } + + // K-SeqMINFunc-19 + @Test + public void min_floatNaN_doubleNaN_isDouble() throws XMLDBException { + assertEquals("true", + firstResult("min((xs:float('NaN'), 1, 1, 2, xs:double('NaN'))) instance of xs:double")); + } + + // K-SeqMINFunc-38 + @Test + public void min_qname_raisesError() { + expectError("FORG0006", + "min(QName('example.com/', 'ncname'))"); + } + + // cbcl-min-009: numeric promotion through FLWOR + @Test + public void min_flwor_mixedNumeric_isDouble() throws XMLDBException { + final String q = "declare function local:f($x as xs:integer) { " + + "(xs:decimal(1.1), xs:float(2.2), xs:double(1.4), xs:integer(2))[$x] }; " + + "min(for $x in (1,2,3) return local:f($x)) instance of xs:double"; + assertEquals("true", firstResult(q)); + } + + // fn-min-8: incompatible duration subtypes -> FORG0006 + @Test + public void min_yearMonthAndDayTimeDuration_raisesError() { + expectError("FORG0006", + "min((xs:yearMonthDuration('P1Y'), xs:dayTimeDuration('P1D')))"); + } + + // fn-min-9: plain xs:duration -> FORG0006 + @Test + public void min_plainDuration_raisesError() { + expectError("FORG0006", + "min(xs:duration('P1Y1M1D'))"); + } + + // Mirror: fn:max behaves the same way for the typing + @Test + public void max_integerFloatDecimal_promotesToFloat() throws XMLDBException { + assertEquals("true", + firstResult("max((1, xs:float(2), xs:decimal(3))) instance of xs:float")); + } + + @Test + public void max_integerDouble_promotesToDouble() throws XMLDBException { + assertEquals("true", + firstResult("max((5, 5.0e0)) instance of xs:double")); + } + + @Test + public void max_qname_raisesError() { + expectError("FORG0006", + "max(QName('example.com/', 'ncname'))"); + } + + @Test + public void max_yearMonthAndDayTimeDuration_raisesError() { + expectError("FORG0006", + "max((xs:yearMonthDuration('P1Y'), xs:dayTimeDuration('P1D')))"); + } + + @Test + public void max_plainDuration_raisesError() { + expectError("FORG0006", + "max(xs:duration('P1Y1M1D'))"); + } +}