This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/XmlSchemaStateMachineGenerator in repository https://gitbox.apache.org/repos/asf/ws-xmlschema.git
commit 1cb1f22cdc62f3a120253e895b7ae231721d7064 Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Mon Aug 24 10:35:23 2026 +0100 Fix state machine generation for shared schema types --- .../docpath/XmlSchemaStateMachineGenerator.java | 186 ++++++++++++-- .../schema/docpath/TestStateMachineSharedType.java | 280 +++++++++++++++++++++ 2 files changed, 451 insertions(+), 15 deletions(-) diff --git a/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaStateMachineGenerator.java b/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaStateMachineGenerator.java index c8db32bc..6ab63dfb 100644 --- a/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaStateMachineGenerator.java +++ b/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/docpath/XmlSchemaStateMachineGenerator.java @@ -21,6 +21,7 @@ package org.apache.ws.commons.schema.docpath; import java.util.ArrayList; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; @@ -46,6 +47,27 @@ public final class XmlSchemaStateMachineGenerator implements XmlSchemaVisitor { private XmlSchemaStateMachineNode startNode; private Map<QName, ElementInfo> elementInfoByQName; + /* + * The walker signals "previously visited" by type identity + * (XmlSchemaWalker.visitedTypes is reference-keyed), so keep a + * type-identity index alongside the QName index: two differently-named + * elements may legally share one named complex type, and two same-named + * local declarations may have different types. XmlSchemaTypeInfo + * identity is stable for named types because the walker caches one + * XmlSchemaScope per named type and XmlSchemaScope.getTypeInfo() returns + * a stored field. + */ + private Map<XmlSchemaTypeInfo, ElementInfo> elementInfoByType; + + /* + * Nodes whose possibleNextStates must be copied from a source node whose + * element is still on the stack -- i.e. whose children are still being + * walked because the type is recursive. The copy is deferred until the + * source node's element finally exits, at which point its content model + * is complete. + */ + private Map<XmlSchemaStateMachineNode, List<XmlSchemaStateMachineNode>> pendingNextStateCopies; + private static class ElementInfo { final List<XmlSchemaAttrInfo> attributes; final XmlSchemaTypeInfo typeInfo; @@ -73,6 +95,9 @@ public final class XmlSchemaStateMachineGenerator implements XmlSchemaVisitor { public XmlSchemaStateMachineGenerator() { stack = new ArrayList<XmlSchemaStateMachineNode>(); elementInfoByQName = new HashMap<QName, ElementInfo>(); + elementInfoByType = new IdentityHashMap<XmlSchemaTypeInfo, ElementInfo>(); + pendingNextStateCopies = + new IdentityHashMap<XmlSchemaStateMachineNode, List<XmlSchemaStateMachineNode>>(); startNode = null; } @@ -120,17 +145,57 @@ public final class XmlSchemaStateMachineGenerator implements XmlSchemaVisitor { * onEndAttributes() is called, so we can create an ElementInfo * entry for it, and wait until later to create the state machine * and add it to the stack. + * + * The QName entry is deliberately an overwrite: the in-flight + * element must stay reachable by QName from onVisitAttribute() + * and onEndAttributes(), even if a different declaration with + * the same QName was walked earlier. The previously-visited + * branch below therefore never trusts a QName hit alone -- it + * resolves by type identity first and validates any QName hit + * against the element's schema type. */ final ElementInfo info = new ElementInfo(element, typeInfo); elementInfoByQName.put(element.getQName(), info); + if (!elementInfoByType.containsKey(typeInfo)) { + elementInfoByType.put(typeInfo, info); + } } else { /* - * We have previously encountered this element, which means we have - * already collected all of the information we needed to build an - * XmlSchemaStateMachineNode. Likewise, we can just reference it. + * We have previously encountered this element's type (the walker + * tracks visits by type identity, not element QName), which + * means we have already collected all of the information we + * needed to build an XmlSchemaStateMachineNode for that type. + * + * Resolve the prior bookkeeping by type identity first. The + * QName index alone is unreliable here: a differently-named + * element may share a previously-visited named type (and have no + * QName entry of its own), and two distinct same-QName + * declarations overwrite each other's QName entries. */ - final ElementInfo elemInfo = elementInfoByQName.get(element.getQName()); + ElementInfo elemInfo = elementInfoByType.get(typeInfo); + + boolean sharedFromSibling = false; + if ((elemInfo != null) && (elemInfo.stateMachineNode != null)) { + sharedFromSibling = !element.getQName().equals(elemInfo.element.getQName()); + } else { + /* + * No type-identity entry: for anonymous types the walker + * builds a fresh XmlSchemaTypeInfo on every encounter (only + * named types' scopes are cached), so the identity lookup + * cannot match. Fall back to the QName index, but validate + * the hit against the element's schema type -- reference + * equality on the XmlSchemaType is exactly how the walker + * decided previouslyVisited -- so two distinct same-QName + * declarations can never cross-bind. + */ + final ElementInfo byQName = elementInfoByQName.get(element.getQName()); + if ((byQName != null) && (byQName.stateMachineNode != null) + && isSameSchemaType(byQName.element, element)) { + elemInfo = byQName; + } + } + if ((elemInfo == null) || (elemInfo.stateMachineNode == null)) { throw new IllegalStateException("Element " + element.getQName() + " was already visited, but we do not" @@ -142,17 +207,47 @@ public final class XmlSchemaStateMachineGenerator implements XmlSchemaVisitor { + " parent state machine node to attach it to!"); } - XmlSchemaStateMachineNode stateMachineNode = elemInfo.stateMachineNode; - - /* - * If this element is identical in every way except for the minimum - * and maximum number of occurrences, we want to create a new state - * machine node to represent this element. - */ - if ((stateMachineNode.getMinOccurs() != element.getMinOccurs()) - || (stateMachineNode.getMaxOccurs() != element.getMaxOccurs())) { - stateMachineNode = new XmlSchemaStateMachineNode(element, elemInfo.attributes, - elemInfo.typeInfo); + XmlSchemaStateMachineNode stateMachineNode; + + if (sharedFromSibling) { + /* + * A differently-named element sharing a previously-visited + * named type: build this element its own state machine node + * from the type-sharing sibling instead of failing on a + * legal schema shape. The walker will not re-walk this + * element's children (they were walked on the type's first + * visit), so the sibling node's possibleNextStates must be + * copied over as well -- otherwise this node would carry an + * empty content model and legal documents would fail + * downstream in XmlSchemaPathFinder. + */ + final ElementInfo newInfo = new ElementInfo(element, typeInfo); + newInfo.attributes.addAll(elemInfo.attributes); + newInfo.stateMachineNode = + new XmlSchemaStateMachineNode(element, newInfo.attributes, typeInfo); + copyPossibleNextStates(elemInfo.stateMachineNode, newInfo.stateMachineNode); + if (!elementInfoByQName.containsKey(element.getQName())) { + elementInfoByQName.put(element.getQName(), newInfo); + } + stateMachineNode = newInfo.stateMachineNode; + + } else { + stateMachineNode = elemInfo.stateMachineNode; + + /* + * If this element is identical in every way except for the + * minimum and maximum number of occurrences, we want to + * create a new state machine node to represent this element. + * The new node needs the original's possibleNextStates as + * well, for the same reason as above: the walker skips the + * children of a previously-visited type. + */ + if ((stateMachineNode.getMinOccurs() != element.getMinOccurs()) + || (stateMachineNode.getMaxOccurs() != element.getMaxOccurs())) { + stateMachineNode = new XmlSchemaStateMachineNode(element, elemInfo.attributes, + elemInfo.typeInfo); + copyPossibleNextStates(elemInfo.stateMachineNode, stateMachineNode); + } } stack.get(stack.size() - 1).addPossibleNextState(stateMachineNode); @@ -182,6 +277,21 @@ public final class XmlSchemaStateMachineGenerator implements XmlSchemaVisitor { + " is not the same in-memory copy we received on creation. Our" + " copy is of a " + node.getElement().getQName()); } + + /* + * If any nodes are waiting on this node's possibleNextStates (a + * recursive type was re-entered while this node's children were + * still being walked), and this node no longer appears anywhere on + * the stack, its content model is complete: perform the deferred + * copies now. + */ + final List<XmlSchemaStateMachineNode> waiting = pendingNextStateCopies.get(node); + if ((waiting != null) && !isOnStack(node)) { + pendingNextStateCopies.remove(node); + for (XmlSchemaStateMachineNode waiter : waiting) { + waiter.addPossibleNextStates(node.getPossibleNextStates()); + } + } } /** @@ -328,6 +438,52 @@ public final class XmlSchemaStateMachineGenerator implements XmlSchemaVisitor { // Ignored. } + /* + * XmlSchemaStateMachineNode.addPossibleNextStates() copies the elements + * of the collection (Collection.addAll), not the list reference, so a + * copy taken while the source's children are still being walked (a + * recursive type) could be an incomplete snapshot. Copy immediately when + * the source's element is no longer being walked; otherwise defer the + * copy until the source's element exits (see onExitElement()). + */ + private void copyPossibleNextStates(XmlSchemaStateMachineNode source, + XmlSchemaStateMachineNode target) { + if (isOnStack(source)) { + List<XmlSchemaStateMachineNode> waiting = pendingNextStateCopies.get(source); + if (waiting == null) { + waiting = new ArrayList<XmlSchemaStateMachineNode>(); + pendingNextStateCopies.put(source, waiting); + } + waiting.add(target); + } else { + target.addPossibleNextStates(source.getPossibleNextStates()); + } + } + + private boolean isOnStack(XmlSchemaStateMachineNode node) { + for (int index = stack.size() - 1; index >= 0; --index) { + if (stack.get(index) == node) { + return true; + } + } + return false; + } + + /* + * Reference equality on the schema type mirrors the walker's own + * previously-visited bookkeeping (XmlSchemaWalker.visitedTypes is + * keyed on the XmlSchemaType instance). When neither element carries a + * resolved schema type, fall back to the type's QName, which is unique + * per namespace for named types. + */ + private static boolean isSameSchemaType(XmlSchemaElement known, XmlSchemaElement current) { + if ((known.getSchemaType() != null) || (current.getSchemaType() != null)) { + return known.getSchemaType() == current.getSchemaType(); + } + return (known.getSchemaTypeName() != null) + && known.getSchemaTypeName().equals(current.getSchemaTypeName()); + } + private void pushGroup(XmlSchemaStateMachineNode.Type groupType, long minOccurs, long maxOccurs) { if (stack.isEmpty()) { diff --git a/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestStateMachineSharedType.java b/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestStateMachineSharedType.java new file mode 100644 index 00000000..df80fec5 --- /dev/null +++ b/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/docpath/TestStateMachineSharedType.java @@ -0,0 +1,280 @@ +/** + * 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 java.util.IdentityHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import javax.xml.namespace.QName; + +import org.apache.ws.commons.schema.XmlSchemaCollection; +import org.apache.ws.commons.schema.XmlSchemaElement; +import org.apache.ws.commons.schema.walker.XmlSchemaWalker; + +import org.junit.Assert; +import org.junit.Test; + +/** + * The walker reports "previously visited" by type identity while the state + * machine generator historically kept its books by element QName. These + * tests cover the legal schema shapes where the two disagree. + */ +public class TestStateMachineSharedType extends Assert { + + private static final String SHARED_TYPE_SCHEMA = + "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"" + + " xmlns:tns=\"urn:shared\" targetNamespace=\"urn:shared\">" + + "<xs:complexType name=\"T\"><xs:sequence>" + + "<xs:element name=\"leaf\" type=\"xs:string\"/>" + + "</xs:sequence></xs:complexType>" + + "<xs:element name=\"root\"><xs:complexType><xs:sequence>" + + "<xs:element name=\"a\" type=\"tns:T\"/>" + + "<xs:element name=\"b\" type=\"tns:T\"/>" + + "</xs:sequence></xs:complexType></xs:element>" + + "</xs:schema>"; + + /* + * Two same-QName local declarations (dup) with different named types, + * in different scopes (legal: the Element Declarations Consistent + * constraint applies per content model). The third wrapper revisits + * type A *after* the second declaration overwrote dup's QName entry. + */ + private static final String SAME_QNAME_SCHEMA = + "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"" + + " xmlns:tns=\"urn:dup\" targetNamespace=\"urn:dup\">" + + "<xs:complexType name=\"A\"><xs:sequence>" + + "<xs:element name=\"x\" type=\"xs:string\"/>" + + "</xs:sequence></xs:complexType>" + + "<xs:complexType name=\"B\"><xs:sequence>" + + "<xs:element name=\"y\" type=\"xs:string\"/>" + + "</xs:sequence></xs:complexType>" + + "<xs:element name=\"root\"><xs:complexType><xs:sequence>" + + "<xs:element name=\"w1\"><xs:complexType><xs:sequence>" + + "<xs:element name=\"dup\" type=\"tns:A\"/>" + + "</xs:sequence></xs:complexType></xs:element>" + + "<xs:element name=\"w2\"><xs:complexType><xs:sequence>" + + "<xs:element name=\"dup\" type=\"tns:B\"/>" + + "</xs:sequence></xs:complexType></xs:element>" + + "<xs:element name=\"w3\"><xs:complexType><xs:sequence>" + + "<xs:element name=\"dup\" type=\"tns:A\"/>" + + "</xs:sequence></xs:complexType></xs:element>" + + "</xs:sequence></xs:complexType></xs:element>" + + "</xs:schema>"; + + /* + * A global element with an anonymous type, referenced twice. The walker + * hands the generator a fresh element copy and a fresh XmlSchemaTypeInfo + * on the second reference, so this shape exercises the validated-QName + * fast path (schema-type reference equality). + */ + private static final String REFERENCED_ANONYMOUS_SCHEMA = + "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"" + + " xmlns:tns=\"urn:refanon\" targetNamespace=\"urn:refanon\">" + + "<xs:element name=\"e\"><xs:complexType><xs:sequence>" + + "<xs:element name=\"inner\" type=\"xs:string\"/>" + + "</xs:sequence></xs:complexType></xs:element>" + + "<xs:element name=\"root\"><xs:complexType><xs:sequence>" + + "<xs:element ref=\"tns:e\"/>" + + "<xs:element ref=\"tns:e\"/>" + + "</xs:sequence></xs:complexType></xs:element>" + + "</xs:schema>"; + + /* + * A recursive named type re-entered under a different element QName + * while its own children are still being walked: the transition copy + * must be deferred until the first-visit node's content model is + * complete. + */ + private static final String RECURSIVE_SHARED_SCHEMA = + "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"" + + " xmlns:tns=\"urn:recur\" targetNamespace=\"urn:recur\">" + + "<xs:complexType name=\"R\"><xs:sequence>" + + "<xs:element name=\"mid\" type=\"xs:string\"/>" + + "<xs:element name=\"next\" type=\"tns:R\" minOccurs=\"0\"/>" + + "</xs:sequence></xs:complexType>" + + "<xs:element name=\"root\"><xs:complexType><xs:sequence>" + + "<xs:element name=\"first\" type=\"tns:R\"/>" + + "</xs:sequence></xs:complexType></xs:element>" + + "</xs:schema>"; + + @Test + public void testTwoElementsSharingOneNamedComplexType() { + final XmlSchemaStateMachineGenerator generator = + walk(SHARED_TYPE_SCHEMA, new QName("urn:shared", "root")); + + assertNotNull(generator.getStartNode()); + + final Map<QName, XmlSchemaStateMachineNode> nodes = generator.getStateMachineNodesByQName(); + final XmlSchemaStateMachineNode nodeA = nodes.get(new QName("urn:shared", "a")); + final XmlSchemaStateMachineNode nodeB = nodes.get(new QName("urn:shared", "b")); + assertNotNull(nodeA); + assertNotNull(nodeB); + + /* + * The legal document <root><a><leaf/></a><b><leaf/></b></root> must + * be navigable: the walker skips b's children as previously visited, + * so b's node only has a content model if the transitions were + * copied from a's node. Non-null alone is not enough -- b's + * possibleNextStates must match a's. + */ + final List<XmlSchemaStateMachineNode> aNext = nodeA.getPossibleNextStates(); + final List<XmlSchemaStateMachineNode> bNext = nodeB.getPossibleNextStates(); + assertFalse("a's node should have a content model", aNext.isEmpty()); + assertEquals("b's node must carry the shared type's content model", aNext.size(), + bNext.size()); + for (int i = 0; i < aNext.size(); ++i) { + assertEquals(aNext.get(i).getNodeType(), bNext.get(i).getNodeType()); + if (XmlSchemaStateMachineNode.Type.ELEMENT.equals(aNext.get(i).getNodeType())) { + assertEquals(aNext.get(i).getElement().getQName(), + bNext.get(i).getElement().getQName()); + } + } + + final QName leaf = new QName("urn:shared", "leaf"); + assertNotNull(findElement(nodeA, leaf)); + assertNotNull("leaf must be reachable through b's content model", findElement(nodeB, leaf)); + } + + @Test + public void testSameQNameDistinctDeclarationsDoNotCrossBind() { + final XmlSchemaStateMachineGenerator generator = + walk(SAME_QNAME_SCHEMA, new QName("urn:dup", "root")); + + final XmlSchemaStateMachineNode start = generator.getStartNode(); + assertNotNull(start); + + final QName dup = new QName("urn:dup", "dup"); + final QName x = new QName("urn:dup", "x"); + final QName y = new QName("urn:dup", "y"); + + final XmlSchemaStateMachineNode w2 = findElement(start, new QName("urn:dup", "w2")); + final XmlSchemaStateMachineNode w3 = findElement(start, new QName("urn:dup", "w3")); + assertNotNull(w2); + assertNotNull(w3); + + // w2/dup is declared with type B: it leads to <y/>, never <x/>. + final XmlSchemaStateMachineNode dupUnderW2 = findElement(w2, dup); + assertNotNull(dupUnderW2); + assertNotNull(findElement(dupUnderW2, y)); + assertNull(findElement(dupUnderW2, x)); + + /* + * w3/dup is declared with type A. Resolving the revisit through the + * (overwritten) QName entry would silently bind it to type B's state + * machine; it must lead to <x/>, never <y/>. + */ + final XmlSchemaStateMachineNode dupUnderW3 = findElement(w3, dup); + assertNotNull(dupUnderW3); + assertNotNull("w3/dup must carry type A's content model", findElement(dupUnderW3, x)); + assertNull("w3/dup must not cross-bind to type B", findElement(dupUnderW3, y)); + } + + @Test + public void testElementWithAnonymousTypeReferencedTwice() { + final XmlSchemaStateMachineGenerator generator = + walk(REFERENCED_ANONYMOUS_SCHEMA, new QName("urn:refanon", "root")); + + final XmlSchemaStateMachineNode start = generator.getStartNode(); + assertNotNull(start); + + final XmlSchemaStateMachineNode nodeE = findElement(start, new QName("urn:refanon", "e")); + assertNotNull(nodeE); + assertFalse(nodeE.getPossibleNextStates().isEmpty()); + assertNotNull(findElement(nodeE, new QName("urn:refanon", "inner"))); + } + + @Test + public void testRecursiveTypeSharedUnderDifferentName() { + final XmlSchemaStateMachineGenerator generator = + walk(RECURSIVE_SHARED_SCHEMA, new QName("urn:recur", "root")); + + assertNotNull(generator.getStartNode()); + + final Map<QName, XmlSchemaStateMachineNode> nodes = generator.getStateMachineNodesByQName(); + final XmlSchemaStateMachineNode nodeFirst = nodes.get(new QName("urn:recur", "first")); + final XmlSchemaStateMachineNode nodeNext = nodes.get(new QName("urn:recur", "next")); + assertNotNull(nodeFirst); + assertNotNull(nodeNext); + + /* + * "next" re-entered type R while "first" was still being walked; the + * deferred copy must still deliver the complete content model. + */ + assertFalse(nodeFirst.getPossibleNextStates().isEmpty()); + assertEquals(nodeFirst.getPossibleNextStates().size(), + nodeNext.getPossibleNextStates().size()); + + final QName mid = new QName("urn:recur", "mid"); + final QName next = new QName("urn:recur", "next"); + assertNotNull(findElement(nodeNext, mid)); + assertNotNull(findElement(nodeNext, next)); + } + + private static XmlSchemaStateMachineGenerator walk(String schema, QName rootQName) { + final XmlSchemaCollection collection = new XmlSchemaCollection(); + collection.read(new StringReader(schema)); + + final XmlSchemaElement root = collection.getElementByQName(rootQName); + assertNotNull(root); + + final XmlSchemaStateMachineGenerator generator = new XmlSchemaStateMachineGenerator(); + final XmlSchemaWalker walker = new XmlSchemaWalker(collection, generator); + + // Throws IllegalStateException without the type-identity handling. + walker.walk(root); + + return generator; + } + + /** + * Breadth-first search of a node's content model for an element with the + * given QName. Does not descend through nested elements (an element + * boundary starts a nested content model) and is cycle-safe for + * recursive types. + */ + private static XmlSchemaStateMachineNode findElement(XmlSchemaStateMachineNode start, + QName qName) { + final Map<XmlSchemaStateMachineNode, Boolean> visited = + new IdentityHashMap<XmlSchemaStateMachineNode, Boolean>(); + final LinkedList<XmlSchemaStateMachineNode> queue = + new LinkedList<XmlSchemaStateMachineNode>(start.getPossibleNextStates()); + + while (!queue.isEmpty()) { + final XmlSchemaStateMachineNode node = queue.removeFirst(); + if (visited.containsKey(node)) { + continue; + } + visited.put(node, Boolean.TRUE); + + if (XmlSchemaStateMachineNode.Type.ELEMENT.equals(node.getNodeType())) { + if (qName.equals(node.getElement().getQName())) { + return node; + } + } else { + queue.addAll(node.getPossibleNextStates()); + } + } + + return null; + } +}
