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..3b156f59ddf 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 @@ -2338,6 +2338,7 @@ throws PermissionDeniedException, EXistException, XPathException #( ABSOLUTE_SLASH { + path.setHasSlash(); RootNode root= new RootNode(context); path.add(root); } @@ -2348,6 +2349,7 @@ throws PermissionDeniedException, EXistException, XPathException #( ABSOLUTE_DSLASH { + path.setHasSlash(); RootNode root= new RootNode(context); path.add(root); } @@ -2948,6 +2950,9 @@ throws PermissionDeniedException, EXistException, XPathException | #( SLASH step=expr [path] + { + path.setHasSlash(); + } ( rightStep=expr [path] { @@ -2972,6 +2977,9 @@ throws PermissionDeniedException, EXistException, XPathException | #( DSLASH step=expr [path] + { + path.setHasSlash(); + } ( rightStep=expr [path] { diff --git a/exist-core/src/main/java/org/exist/dom/memtree/NodeImpl.java b/exist-core/src/main/java/org/exist/dom/memtree/NodeImpl.java index 47f03d4096b..0e35a2a5360 100644 --- a/exist-core/src/main/java/org/exist/dom/memtree/NodeImpl.java +++ b/exist-core/src/main/java/org/exist/dom/memtree/NodeImpl.java @@ -309,10 +309,16 @@ public int compareTo(final NodeImpl other) { } else { return Constants.SUPERIOR; } - } else if(document.docId < other.document.docId) { - return Constants.INFERIOR; } else { - return Constants.SUPERIOR; + final long thisDocId = document != null ? document.docId : 0; + final long otherDocId = other.document != null ? other.document.docId : 0; + if (thisDocId < otherDocId) { + return Constants.INFERIOR; + } else if (thisDocId > otherDocId) { + return Constants.SUPERIOR; + } else { + return Constants.EQUAL; + } } } diff --git a/exist-core/src/main/java/org/exist/xquery/PathExpr.java b/exist-core/src/main/java/org/exist/xquery/PathExpr.java index 8e096376cc5..c0da53cd23d 100644 --- a/exist-core/src/main/java/org/exist/xquery/PathExpr.java +++ b/exist-core/src/main/java/org/exist/xquery/PathExpr.java @@ -53,6 +53,14 @@ public class PathExpr extends AbstractExpression implements CompiledXQuery, protected boolean inPredicate = false; + /** + * Set to true when this PathExpr represents an actual XPath path + * expression with '/' or '//' steps, as opposed to a generic expression + * container. When true, duplicate node elimination is applied per + * XPath 3.1 §3.3.1.1. + */ + private boolean hasSlash = false; + protected Expression parent; public PathExpr(final XQueryContext context) { @@ -298,7 +306,8 @@ public Sequence eval(Sequence contextSequence, final Item contextItem) throws XP !Type.subTypeOf(result.getItemType(), Type.NODE)) { gotAtomicResult = true; } - if (steps.size() > 1 && getLastExpression() instanceof Step) { + if (hasSlash && !result.isEmpty() + && Type.subTypeOf(result.getItemType(), Type.NODE)) { // remove duplicate nodes if this is a path // expression with more than one step result.removeDuplicates(); @@ -375,6 +384,14 @@ public Expression getLastExpression() { return steps.isEmpty() ? null : steps.getLast(); } + /** + * Marks this PathExpr as containing a '/' or '//' path operator. + * Called from the grammar tree walker when SLASH or DSLASH is encountered. + */ + public void setHasSlash() { + this.hasSlash = true; + } + /** * Get the length. * diff --git a/exist-core/src/test/java/org/exist/xquery/PathExprDedupTest.java b/exist-core/src/test/java/org/exist/xquery/PathExprDedupTest.java new file mode 100644 index 00000000000..0601f08880f --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/PathExprDedupTest.java @@ -0,0 +1,147 @@ +/* + * 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; + +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.XQueryService; + +import static org.junit.Assert.assertEquals; + +/** + * Tests for duplicate node elimination in path expressions. + * + * Per XPath 3.1 §3.3.1.1, the path operator '/' must eliminate duplicate + * nodes (by identity) and return results in document order when every + * evaluation of E2 returns nodes. This must apply regardless of whether + * E2 is an axis step, function call, or other PostfixExpr. + * + * @see XPath 3.1 §3.3.1.1 + */ +public class PathExprDedupTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = + new ExistXmldbEmbeddedServer(false, true, true); + + private String query(final String xquery) throws XMLDBException { + final XQueryService xqs = existEmbeddedServer.getRoot().getService(XQueryService.class); + final ResourceSet result = xqs.query(xquery); + return result.getResource(0).getContent().toString(); + } + + /** + * XQTS K2-Steps-31 (explicit expansion): function call in path where + * multiple context items produce the same result node. The path operator + * must deduplicate results per §3.3.1.1. + */ + @Test + public void functionCallInPathDedup() throws XMLDBException { + final String result = query(""" + declare variable $root := ; + declare function local:function($arg) { $root[$arg] }; + count($root/descendant-or-self::node()/local:function(.))"""); + assertEquals("1", result); + } + + /** + * Simpler case: child::* / function that always returns the same node. + */ + @Test + public void functionReturnsConstantNodeDedup() throws XMLDBException { + final String result = query(""" + declare variable $root := ; + declare function local:getroot($x) { $root }; + count($root/*/local:getroot(.))"""); + assertEquals("1", result); + } + + /** + * Ensure for-loop results are NOT incorrectly deduplicated. + * This is the scenario from the 2009 bug fix (SF #2880394). + */ + @Test + public void forLoopPreservesDuplicates() throws XMLDBException { + final String result = query(""" + declare variable $root := ; + count(for $x in (1, 2, 3) return $root)"""); + assertEquals("3", result); + } + + /** + * Global variable with for-loop should preserve all results. + */ + @Test + public void globalVarForLoopPreservesDuplicates() throws XMLDBException { + final String result = query(""" + declare variable $data := ; + count(for $i in 1 to 5 return $data)"""); + assertEquals("5", result); + } + + /** + * XQTS K2-Axes-48: path expression ending with integer literal. + * Must not NPE when removeDuplicates is called on atomic results. + */ + @Test + public void pathEndingWithIntegerLiteral() throws XMLDBException { + final XQueryService xqs = existEmbeddedServer.getRoot().getService(XQueryService.class); + final ResourceSet result = xqs.query(""" + declare variable $myVar := ; + $myVar/(, , , , attribute name {}, document {()})/3"""); + assertEquals(6, (int) result.getSize()); + for (int i = 0; i < 6; i++) { + assertEquals("3", result.getResource(i).getContent().toString()); + } + } + + /** + * XQTS K2-Axes-49: path expression ending with number() function. + * Must not NPE when removeDuplicates is called on atomic results. + */ + @Test + public void pathEndingWithNumberFunction() throws XMLDBException { + final XQueryService xqs = existEmbeddedServer.getRoot().getService(XQueryService.class); + final ResourceSet result = xqs.query(""" + declare variable $myVar := ; + $myVar/(, , , , attribute name {}, document {()})/number()"""); + assertEquals(6, (int) result.getSize()); + for (int i = 0; i < 6; i++) { + assertEquals("NaN", result.getResource(i).getContent().toString()); + } + } + + /** + * Path with axis step followed by function call — dedup should apply. + */ + @Test + public void axisStepThenFunctionCallDedup() throws XMLDBException { + final String result = query(""" + declare variable $doc := ; + declare function local:parent($n) { $n/.. }; + count($doc/*/local:parent(.))"""); + assertEquals("1", result); + } +}