This is an automated email from the ASF dual-hosted git repository.

coheigea pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ws-xmlschema.git


The following commit(s) were added to refs/heads/master by this push:
     new df755e57 Fix the path finder refusing or failing on common valid 
documents (#172)
df755e57 is described below

commit df755e57d4e378d6d6b3ce46e236e3fb98670389
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Thu Sep 24 12:08:17 2026 +0100

    Fix the path finder refusing or failing on common valid documents (#172)
---
 .../schema/docpath/XmlSchemaNamespaceContext.java  |  13 +-
 .../schema/docpath/XmlSchemaPathFinder.java        | 119 +++++++++++---
 .../docpath/TestPathFinderValidDocuments.java      | 177 +++++++++++++++++++++
 .../docpath/TestXmlSchemaNamespaceContext.java     |  13 ++
 4 files changed, 296 insertions(+), 26 deletions(-)

diff --git 
a/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaNamespaceContext.java
 
b/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaNamespaceContext.java
index 012dc587..3edf9a91 100644
--- 
a/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaNamespaceContext.java
+++ 
b/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaNamespaceContext.java
@@ -137,18 +137,21 @@ public final class XmlSchemaNamespaceContext implements 
NamespacePrefixList {
     /**
      * Adds a new prefix mapping to the context. The prefix may be an empty
      * string to represent the default namespace, but it cannot be null. The
-     * namespace URI can never be empty or null.
+     * namespace URI cannot be null, and can only be empty for the default
+     * namespace, where it undeclares the default namespace for the scope of
+     * the mapping as <code>xmlns=""</code> does.
      *
      * @param prefix The prefix to represent the namespace URI.
      * @param namespaceUri the namespace URI represented by the prefix.
      * @throws IllegalArgumentException if the prefix is null, or if the
-     *             namespace URI is null or empty.
+     *             namespace URI is null, or empty for a non-empty prefix.
      */
     public void addNamespace(String prefix, String namespaceUri) {
-        if ((prefix == null) || (namespaceUri == null) || 
(namespaceUri.length() == 0)) {
+        if ((prefix == null) || (namespaceUri == null)
+            || ((namespaceUri.length() == 0) && (prefix.length() > 0))) {
 
-            throw new IllegalArgumentException("The prefix may not be null, 
and the namespace URI "
-                                               + "may neither be null nor 
empty.");
+            throw new IllegalArgumentException("The prefix may not be null, 
and the namespace URI may"
+                                               + " not be null, nor empty 
unless the prefix is.");
 
         } else if (isRecognizedPrefix(prefix)) {
             // These are already mapped.
diff --git 
a/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaPathFinder.java
 
b/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaPathFinder.java
index 0fa67992..a0cc239f 100644
--- 
a/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaPathFinder.java
+++ 
b/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaPathFinder.java
@@ -20,6 +20,7 @@
 package org.apache.ws.commons.schema.docpath;
 
 import java.util.ArrayList;
+import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -93,6 +94,22 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
     private ArrayList<QName> elementStack;
     private ArrayList<QName> anyStack;
 
+    /*
+     * The document node of the element most recently ended, until the next
+     * element starts. The current position can stay on an element after it
+     * ends, and a recursive type puts an element of the same name above it,
+     * so the name alone cannot say whether the current element is the one
+     * being ended.
+     */
+    private XmlSchemaDocumentNode<U> endedElement;
+
+    /*
+     * Whether a state machine node's content can match no elements at all,
+     * cached as the question is asked repeatedly for the same nodes.
+     */
+    private final Map<XmlSchemaStateMachineNode, Boolean> emptyContentCache =
+        new IdentityHashMap<XmlSchemaStateMachineNode, Boolean>();
+
     private XmlSchemaPathManager<U, V> pathMgr;
 
     /*
@@ -513,6 +530,7 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
     @Override
     public void startDocument() throws SAXException {
         currentPath = null;
+        endedElement = null;
 
         traversedElements.clear();
         elementStack.clear();
@@ -1008,8 +1026,8 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
         return rootPathNode;
     }
 
-    private static <U, V> Fulfillment isPositionFulfilled(XmlSchemaPathNode<U, 
V> currentPath,
-                                                          List<Integer> 
possiblePaths) {
+    private Fulfillment isPositionFulfilled(XmlSchemaPathNode<U, V> 
currentPath,
+                                            List<Integer> possiblePaths) {
         boolean completelyFulfilled = true;
         boolean partiallyFulfilled = true;
 
@@ -1018,7 +1036,9 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
         if (currentPath.getDocumentNode() == null) {
             // This is the root node. It is not fulfilled.
             partiallyFulfilled = false;
-        } else if (currentPath.getDocIteration() >= state.getMinOccurs()) {
+        } else if ((currentPath.getDocIteration() >= state.getMinOccurs())
+                   || canMatchEmptyContent(state)) {
+            // The remaining occurrences, if any, can each match nothing.
             partiallyFulfilled = true;
         } else {
             partiallyFulfilled = false;
@@ -1066,7 +1086,7 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
                 if ((children != null) && children.containsKey(stateIndex)) {
                     final XmlSchemaDocumentNode<U> child = 
children.get(stateIndex);
                     final int iteration = child.getIteration();
-                    if (iteration >= nextState.getMinOccurs()) {
+                    if ((iteration >= nextState.getMinOccurs()) || 
canMatchEmptyContent(nextState)) {
                         groupPartiallyFulfilled = true;
                         if (possiblePaths != null) {
                             possiblePaths.clear();
@@ -1081,7 +1101,7 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
                         possiblePaths.add(stateIndex);
                     }
                 } else {
-                    if (nextState.getMinOccurs() == 0) {
+                    if ((nextState.getMinOccurs() == 0) || 
canMatchEmptyContent(nextState)) {
                         groupPartiallyFulfilled = true;
                     }
                     if (nextState.getMaxOccurs() == 0) {
@@ -1139,7 +1159,8 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
 
                 if ((children != null) && children.containsKey(stateIndex)) {
                     final XmlSchemaDocumentNode<U> child = 
children.get(stateIndex);
-                    if (child.getIteration() < nextState.getMinOccurs()) {
+                    if ((child.getIteration() < nextState.getMinOccurs())
+                        && !canMatchEmptyContent(nextState)) {
                         partiallyFulfilled = false;
                     }
                     if (child.getIteration() < nextState.getMaxOccurs()) {
@@ -1149,7 +1170,7 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
                         }
                     }
                 } else {
-                    if (nextState.getMinOccurs() > 0) {
+                    if ((nextState.getMinOccurs() > 0) && 
!canMatchEmptyContent(nextState)) {
                         partiallyFulfilled = false;
                     }
                     if (nextState.getMaxOccurs() > 0) {
@@ -1375,14 +1396,10 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
             return null;
         }
 
-        // If this is a group, confirm it has children.
-        if (!state.getNodeType().equals(XmlSchemaStateMachineNode.Type.ELEMENT)
-            && !state.getNodeType().equals(XmlSchemaStateMachineNode.Type.ANY)
-            && ((state.getPossibleNextStates() == null) || 
state.getPossibleNextStates().isEmpty())) {
-
-            throw new IllegalStateException("Group " + state.getNodeType()
-                                            + " has no children.  Found when 
processing " + elemQName);
-        }
+        /*
+         * A group with no children, such as an empty xs:sequence, is legal:
+         * it matches no elements, which the loops below find for themselves.
+         */
 
         List<PathSegment<U, V>> choices = null;
 
@@ -1433,7 +1450,12 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
                                                     + 
startNode.getDocSequencePosition());
                 }
 
-                final boolean reachedMinOccurs = (nextPath.getDocIteration() 
>= nextPath.getMinOccurs());
+                /*
+                 * Content that can match nothing, such as a group of optional
+                 * elements, satisfies its remaining occurrences without any.
+                 */
+                final boolean reachedMinOccurs = (nextPath.getDocIteration() 
>= nextPath.getMinOccurs())
+                                                 || 
canMatchEmptyContent(nextPath.getStateMachineNode());
 
                 final List<PathSegment<U, V>> seqPaths = find(nextPath, 
elemQName, currDepth + 1);
 
@@ -1521,10 +1543,9 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
              * If the XmlSchemaAny namespace and processing rules apply, this
              * element matches. False otherwise.
              */
-            if (traversedElements.size() < 2) {
+            if (traversedElements.isEmpty()) {
                 throw new IllegalStateException("Reached a wildcard element 
while searching for " + elemQName
-                                                + ", but we've only seen " + 
traversedElements.size()
-                                                + " element(s)!");
+                                                + ", but that is the root 
element!");
             }
 
             final XmlSchemaAny any = state.getAny();
@@ -1557,7 +1578,7 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
                     if ("##targetNamespace".equals(namespace)) {
                         needTargetNamespace = true;
 
-                    } else if ("##local".equals(namespace) && 
(elemQName.getNamespaceURI() == null)) {
+                    } else if ("##local".equals(namespace) && 
(elemQName.getNamespaceURI().length() == 0)) {
 
                         matches = true;
 
@@ -1654,8 +1675,10 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
         XmlSchemaDocumentNode<U> iter = currentPath.getDocumentNode();
 
         if 
(iter.getStateMachineNode().getNodeType().equals(XmlSchemaStateMachineNode.Type.ELEMENT)
-            && 
iter.getStateMachineNode().getElement().getQName().equals(element)) {
+            && 
iter.getStateMachineNode().getElement().getQName().equals(element)
+            && (iter != endedElement)) {
             // We are already at the element!
+            endedElement = iter;
             return;
         }
 
@@ -1678,6 +1701,7 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
                                             + currentPath.getStateMachineNode()
                                             + ", which does not represent 
element " + element);
         }
+        endedElement = currentPath.getDocumentNode();
     }
 
     private void followPath(PathSegment<U, V> path) {
@@ -1699,6 +1723,59 @@ public final class XmlSchemaPathFinder<U, V> extends 
DefaultHandler {
         pathMgr.followPath(startNode);
 
         currentPath = path.getEnd();
+
+        // An element has started, so none is the one just ended.
+        endedElement = null;
+    }
+
+    /*
+     * Whether a node's content can match no elements at all: an xs:sequence
+     * or xs:all whose particles can each occur zero times, or an xs:choice
+     * or substitution group with one such option. An element or wildcard
+     * always matches an element. Group nesting ends at elements, which this
+     * does not look inside, so the recursion is bounded by the walker's own
+     * limit on nested groups.
+     */
+    private boolean canMatchEmptyContent(XmlSchemaStateMachineNode state) {
+        final Boolean cached = emptyContentCache.get(state);
+        if (cached != null) {
+            return cached.booleanValue();
+        }
+
+        boolean result;
+        final List<XmlSchemaStateMachineNode> children = 
state.getPossibleNextStates();
+
+        switch (state.getNodeType()) {
+        case SEQUENCE:
+        case ALL:
+            result = true;
+            if (children != null) {
+                for (XmlSchemaStateMachineNode child : children) {
+                    if ((child.getMinOccurs() > 0) && 
!canMatchEmptyContent(child)) {
+                        result = false;
+                        break;
+                    }
+                }
+            }
+            break;
+        case CHOICE:
+        case SUBSTITUTION_GROUP:
+            result = false;
+            if (children != null) {
+                for (XmlSchemaStateMachineNode child : children) {
+                    if ((child.getMinOccurs() == 0) || 
canMatchEmptyContent(child)) {
+                        result = true;
+                        break;
+                    }
+                }
+            }
+            break;
+        default:
+            result = false;
+        }
+
+        emptyContentCache.put(state, Boolean.valueOf(result));
+        return result;
     }
 
     /*
diff --git 
a/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestPathFinderValidDocuments.java
 
b/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestPathFinderValidDocuments.java
new file mode 100644
index 00000000..e318d3e3
--- /dev/null
+++ 
b/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestPathFinderValidDocuments.java
@@ -0,0 +1,177 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.ws.commons.schema.docpath;
+
+import java.io.StringReader;
+
+import javax.xml.namespace.QName;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+
+import org.apache.ws.commons.schema.XmlSchemaCollection;
+import org.apache.ws.commons.schema.walker.XmlSchemaWalker;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.xml.sax.InputSource;
+
+/**
+ * Documents that are valid against their schema - each checked against the 
JDK's own validator -
+ * must be walked without error, and ones that are not must still be refused.
+ */
+public class TestPathFinderValidDocuments extends Assert {
+
+    private DocumentBuilder docBuilder;
+
+    @Before
+    public void setUp() throws Exception {
+        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+        factory.setNamespaceAware(true);
+        docBuilder = factory.newDocumentBuilder();
+    }
+
+    private void walk(String schemaBody, String xml) throws Exception {
+        XmlSchemaCollection collection = new XmlSchemaCollection();
+        collection.read(new StringReader(
+            "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"; 
xmlns:t=\"urn:t\""
+            + " targetNamespace=\"urn:t\" elementFormDefault=\"qualified\">"
+            + schemaBody + "</xs:schema>"));
+
+        XmlSchemaStateMachineGenerator generator = new 
XmlSchemaStateMachineGenerator();
+        new XmlSchemaWalker(collection, generator)
+            .walk(collection.getElementByQName(new QName("urn:t", "root")));
+
+        XmlSchemaPathFinder<Object, Object> pathFinder =
+            new XmlSchemaPathFinder<Object, Object>(generator.getStartNode());
+        new SaxWalkerOverDom(pathFinder)
+            .walk(docBuilder.parse(new InputSource(new StringReader(xml))));
+    }
+
+    private void assertRefused(String schemaBody, String xml) throws Exception 
{
+        try {
+            walk(schemaBody, xml);
+        } catch (RuntimeException expected) {
+            return;
+        }
+        fail("expected the document to be refused: " + xml);
+    }
+
+    private static final String LOCAL_WILDCARD =
+        "<xs:element name=\"root\"><xs:complexType><xs:sequence>"
+        + "<xs:element name=\"a\" type=\"xs:string\"/>"
+        + "<xs:any namespace=\"##local\" processContents=\"skip\"/>"
+        + "</xs:sequence></xs:complexType></xs:element>";
+
+    /**
+     * xmlns="" undeclares the default namespace, which the namespace context 
used to refuse.
+     */
+    @Test
+    public void testUndeclaredDefaultNamespace() throws Exception {
+        walk(LOCAL_WILDCARD, "<root xmlns=\"urn:t\"><a>x</a><x 
xmlns=\"\"/></root>");
+    }
+
+    @Test
+    public void testLocalWildcardMatchesAnElementInNoNamespace() throws 
Exception {
+        walk(LOCAL_WILDCARD, "<t:root 
xmlns:t=\"urn:t\"><t:a>x</t:a><x/></t:root>");
+    }
+
+    @Test
+    public void testLocalWildcardRefusesAnElementInANamespace() throws 
Exception {
+        assertRefused(LOCAL_WILDCARD, "<root xmlns=\"urn:t\"><a>x</a><x 
xmlns=\"urn:other\"/></root>");
+    }
+
+    @Test
+    public void testWildcardAsTheFirstChildOfTheRoot() throws Exception {
+        walk("<xs:element name=\"root\"><xs:complexType><xs:sequence>"
+             + "<xs:any processContents=\"skip\"/>"
+             + "</xs:sequence></xs:complexType></xs:element>",
+             "<root xmlns=\"urn:t\"><x/></root>");
+    }
+
+    private static final String EMPTY_BASE_EXTENDED =
+        "<xs:complexType name=\"base\"><xs:sequence/>"
+        + "<xs:attribute name=\"id\" type=\"xs:string\"/></xs:complexType>"
+        + "<xs:complexType name=\"derived\"><xs:complexContent><xs:extension 
base=\"t:base\">"
+        + "<xs:sequence><xs:element name=\"a\" type=\"xs:int\"/></xs:sequence>"
+        + "</xs:extension></xs:complexContent></xs:complexType>"
+        + "<xs:element name=\"root\" type=\"t:derived\"/>";
+
+    @Test
+    public void testEmptyBaseSequenceExtendedWithContent() throws Exception {
+        walk(EMPTY_BASE_EXTENDED, "<root xmlns=\"urn:t\" 
id=\"1\"><a>1</a></root>");
+    }
+
+    @Test
+    public void testEmptyBaseSequenceExtendedWithContentStillChecksIt() throws 
Exception {
+        assertRefused(EMPTY_BASE_EXTENDED, "<root xmlns=\"urn:t\" 
id=\"1\"><a>notint</a></root>");
+    }
+
+    /**
+     * A required group whose content can match nothing - here a base type 
whose elements are all
+     * optional - must not stop the document moving on to what follows it.
+     */
+    @Test
+    public void testBaseOfOptionalElementsExtendedWithContent() throws 
Exception {
+        walk("<xs:complexType name=\"base\"><xs:sequence>"
+             + "<xs:element name=\"a\" type=\"xs:string\" minOccurs=\"0\"/>"
+             + "</xs:sequence></xs:complexType>"
+             + "<xs:complexType 
name=\"derived\"><xs:complexContent><xs:extension base=\"t:base\">"
+             + "<xs:sequence><xs:element name=\"b\" 
type=\"xs:string\"/></xs:sequence>"
+             + "</xs:extension></xs:complexContent></xs:complexType>"
+             + "<xs:element name=\"root\" type=\"t:derived\"/>",
+             "<root xmlns=\"urn:t\"><b>x</b></root>");
+    }
+
+    @Test
+    public void testChoiceWithAnEmptyOption() throws Exception {
+        String schema = "<xs:element 
name=\"root\"><xs:complexType><xs:sequence>"
+                        + "<xs:choice><xs:sequence/><xs:element name=\"a\" 
type=\"xs:string\"/></xs:choice>"
+                        + "<xs:element name=\"b\" type=\"xs:string\"/>"
+                        + "</xs:sequence></xs:complexType></xs:element>";
+        walk(schema, "<root xmlns=\"urn:t\"><b>x</b></root>");
+        walk(schema, "<root xmlns=\"urn:t\"><a>x</a><b>x</b></root>");
+        assertRefused(schema, "<root xmlns=\"urn:t\"><c>x</c></root>");
+    }
+
+    @Test
+    public void testChoiceWithAnOptionalOption() throws Exception {
+        walk("<xs:element name=\"root\"><xs:complexType><xs:sequence>"
+             + "<xs:choice><xs:element name=\"a\" type=\"xs:string\" 
minOccurs=\"0\"/>"
+             + "<xs:element name=\"c\" type=\"xs:string\"/></xs:choice>"
+             + "<xs:element name=\"b\" type=\"xs:string\"/>"
+             + "</xs:sequence></xs:complexType></xs:element>",
+             "<root xmlns=\"urn:t\"><b>x</b></root>");
+    }
+
+    /**
+     * Ending an element used to stop at the nearest element of the same name, 
which in a
+     * recursive type is the one just ended rather than its parent.
+     */
+    @Test
+    public void testSelfRecursiveType() throws Exception {
+        String schema = "<xs:complexType name=\"T0\"><xs:sequence>"
+                        + "<xs:element name=\"c\" type=\"t:T0\" 
minOccurs=\"0\" maxOccurs=\"3\"/>"
+                        + "</xs:sequence></xs:complexType>"
+                        + "<xs:element name=\"root\" type=\"t:T0\"/>";
+        walk(schema, "<root xmlns=\"urn:t\"><c><c/></c></root>");
+        walk(schema, "<root 
xmlns=\"urn:t\"><c><c><c/></c></c><c><c/></c><c/></root>");
+        assertRefused(schema, "<root xmlns=\"urn:t\"><c/><c/><c/><c/></root>");
+    }
+}
diff --git 
a/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestXmlSchemaNamespaceContext.java
 
b/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestXmlSchemaNamespaceContext.java
index 8f7b5222..a87ca7a2 100644
--- 
a/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestXmlSchemaNamespaceContext.java
+++ 
b/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestXmlSchemaNamespaceContext.java
@@ -188,6 +188,19 @@ public class TestXmlSchemaNamespaceContext {
         nsContext.addNamespace("avro", "");
     }
 
+    /**
+     * xmlns="" undeclares the default namespace for the scope of the mapping.
+     */
+    @Test
+    public void testUndeclareDefaultNamespace() {
+        XmlSchemaNamespaceContext nsContext = new XmlSchemaNamespaceContext();
+        nsContext.addNamespace("", "urn:outer");
+        nsContext.addNamespace("", "");
+        assertEquals("", nsContext.getNamespaceURI(""));
+        nsContext.removeNamespace("");
+        assertEquals("urn:outer", nsContext.getNamespaceURI(""));
+    }
+
     @Test(expected = IllegalArgumentException.class)
     public void testGetPrefixesWithNullNamespace() {
         XmlSchemaNamespaceContext nsContext = new XmlSchemaNamespaceContext();

Reply via email to