From 8ef23607972bbdd9e2e574a81243baf0119c701b Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Thu, 28 May 2026 23:34:36 -0400 Subject: [PATCH 1/2] [bugfix] RootNode: declare CONTEXT_ITEM dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RootNode (the lone-slash root step '/') inherited the default Dependency.CONTEXT_SET from AbstractExpression and never declared CONTEXT_ITEM. The predicate-hoisting path of the optimizer uses CONTEXT_ITEM to decide whether a sub-expression can be evaluated once outside the iteration. Without that bit, predicates whose only context-dependent leaf is '/' get hoisted out and evaluated with no context item. When that happens, RootNode.eval falls through to the static-context default branch — getStaticallyKnownDocuments() with a null staticDocumentPaths, which returns every XML resource the broker can see. Under guest that filters down to the empty set; under admin the sequence includes the admin user's own account record from /db/system/security/exist/accounts/. An expression like fn:count(.[5 * /]) then throws XPTY0004 ("Too many operands at the right of *") because the multi-item result of '/' atomizes to multiple untypedAtomic values. Surfaces in W3C qt3tests prod-PathExpr/PathExpr-1, -2, -15 and prod-StepExpr/Steps-leading-lone-slash-15, -17, -1a when measured under an admin broker — they pass under guest only because the fallback returns the empty sequence there. Adding CONTEXT_ITEM keeps the per-iteration context flowing through to the predicate. RootNode.eval is unchanged; top-level '/' still falls through to the static-context default in its no-context cases, so the long-standing 'absolute path without fn:doc' convention is preserved for legitimate uses. Co-Authored-By: Claude Opus 4.7 --- .../main/java/org/exist/xquery/RootNode.java | 14 +++ .../RootNodeContextItemDependencyTest.java | 97 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 exist-core/src/test/java/org/exist/xquery/RootNodeContextItemDependencyTest.java diff --git a/exist-core/src/main/java/org/exist/xquery/RootNode.java b/exist-core/src/main/java/org/exist/xquery/RootNode.java index 548e20699fe..ed8f8975467 100644 --- a/exist-core/src/main/java/org/exist/xquery/RootNode.java +++ b/exist-core/src/main/java/org/exist/xquery/RootNode.java @@ -54,6 +54,20 @@ public RootNode(XQueryContext context) { super(context, Constants.SELF_AXIS); } + @Override + public int getDependencies() { + // Declare CONTEXT_ITEM so the optimizer does not hoist predicates + // containing only / out of their iteration context. The default + // (CONTEXT_SET) is not enough on its own — the predicate optimizer + // looks at CONTEXT_ITEM to decide whether a sub-expression can be + // evaluated once outside the iteration. Without this override an + // expression like fn:count(.[5 * /]) is evaluated with no context + // item, falls through to getStaticallyKnownDocuments(), and returns + // arbitrary content from the static-context default (which under + // admin includes /db/system). + return Dependency.CONTEXT_ITEM | Dependency.CONTEXT_SET; + } + public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathException { if (context.getProfiler().isEnabled()) { context.getProfiler().start(this); diff --git a/exist-core/src/test/java/org/exist/xquery/RootNodeContextItemDependencyTest.java b/exist-core/src/test/java/org/exist/xquery/RootNodeContextItemDependencyTest.java new file mode 100644 index 00000000000..d151ab0b70b --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/RootNodeContextItemDependencyTest.java @@ -0,0 +1,97 @@ +/* + * 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 static org.junit.Assert.assertEquals; + +/** + * Regression test for RootNode dependency declaration. + * + *

{@link RootNode} represents the lone-slash root-step ({@code /}). Without + * declaring {@link Dependency#CONTEXT_ITEM} the optimizer's predicate hoister + * treats expressions containing only {@code /} as context-independent and + * evaluates them once outside the iteration context. With no context item to + * resolve against, {@code RootNode.eval} falls through to the static-context + * default (all statically known documents) and returns whatever the broker is + * allowed to see — including {@code /db/system/security} content for admin + * users.

+ * + *

These XPath cases come from W3C qt3tests {@code prod-PathExpr/PathExpr-1}, + * {@code -2}, {@code -15} (Nicolae Brinza, 2009): "Leading lone slash syntax + * constraints". Per the spec, with the bid element as context item, {@code /} + * resolves to its document node; arithmetic produces a single numeric value; + * the positional predicate has no match; count is zero.

+ */ +public class RootNodeContextItemDependencyTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = + new ExistXmldbEmbeddedServer(false, true, true); + + /** + * {@code 5 * /} inside a positional predicate. With proper context flow, + * {@code /} resolves to the bid element's owner document (single item), + * {@code 5 * 23} atomizes to {@code 5 * 23 = 115}, and the + * positional predicate {@code [115]} matches nothing. + */ + @Test + public void loneSlashInArithmeticPredicate_multiplyOnRight() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + "let $ctx := document { 23 }/bid return fn:count($ctx[5 * /])"); + assertEquals(1, result.getSize()); + assertEquals("0", result.getResource(0).getContent()); + } + + /** + * Mirror of the above with the operands swapped — {@code (/) * 5}. + */ + @Test + public void loneSlashInArithmeticPredicate_multiplyOnLeft() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + "let $ctx := document { 23 }/bid return fn:count($ctx[(/) * 5])"); + assertEquals(1, result.getSize()); + assertEquals("0", result.getResource(0).getContent()); + } + + /** + * Without the dependency fix, {@code /} in a hoisted predicate falls + * through to the static-context default — for an admin-level broker + * (the default in this test setup), that historically included + * {@code /db/system/security/exist/accounts/admin.xml}. Confirm the + * single-item document-node return path, not the multi-doc fallback, + * is the one that runs. + */ + @Test + public void loneSlashInPredicate_resolvesToSingleOwnerDocument() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + "let $ctx := document { 23 }/bid " + + "return fn:count($ctx[fn:count(/) eq 1])"); + assertEquals(1, result.getSize()); + assertEquals("1", result.getResource(0).getContent()); + } +} From 7eeb631ede0c37413794902b7a606588e84f6cbe Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Fri, 29 May 2026 13:16:43 -0400 Subject: [PATCH 2/2] [test] RootNode regression test: camelCase method names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PMD JUnit naming convention requires [a-z][a-zA-Z0-9]* — no underscores. Co-Authored-By: Claude Opus 4.7 --- .../org/exist/xquery/RootNodeContextItemDependencyTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/exist-core/src/test/java/org/exist/xquery/RootNodeContextItemDependencyTest.java b/exist-core/src/test/java/org/exist/xquery/RootNodeContextItemDependencyTest.java index d151ab0b70b..f4bf30505ba 100644 --- a/exist-core/src/test/java/org/exist/xquery/RootNodeContextItemDependencyTest.java +++ b/exist-core/src/test/java/org/exist/xquery/RootNodeContextItemDependencyTest.java @@ -60,7 +60,7 @@ public class RootNodeContextItemDependencyTest { * positional predicate {@code [115]} matches nothing. */ @Test - public void loneSlashInArithmeticPredicate_multiplyOnRight() throws XMLDBException { + public void loneSlashInArithmeticPredicateMultiplyOnRight() throws XMLDBException { final ResourceSet result = existEmbeddedServer.executeQuery( "let $ctx := document { 23 }/bid return fn:count($ctx[5 * /])"); assertEquals(1, result.getSize()); @@ -71,7 +71,7 @@ public void loneSlashInArithmeticPredicate_multiplyOnRight() throws XMLDBExcepti * Mirror of the above with the operands swapped — {@code (/) * 5}. */ @Test - public void loneSlashInArithmeticPredicate_multiplyOnLeft() throws XMLDBException { + public void loneSlashInArithmeticPredicateMultiplyOnLeft() throws XMLDBException { final ResourceSet result = existEmbeddedServer.executeQuery( "let $ctx := document { 23 }/bid return fn:count($ctx[(/) * 5])"); assertEquals(1, result.getSize()); @@ -87,7 +87,7 @@ public void loneSlashInArithmeticPredicate_multiplyOnLeft() throws XMLDBExceptio * is the one that runs. */ @Test - public void loneSlashInPredicate_resolvesToSingleOwnerDocument() throws XMLDBException { + public void loneSlashInPredicateResolvesToSingleOwnerDocument() throws XMLDBException { final ResourceSet result = existEmbeddedServer.executeQuery( "let $ctx := document { 23 }/bid " + "return fn:count($ctx[fn:count(/) eq 1])");