diff --git a/exist-core/src/main/java/org/exist/dom/memtree/DocumentBuilderReceiver.java b/exist-core/src/main/java/org/exist/dom/memtree/DocumentBuilderReceiver.java index 38884a1fa50..4f03618bbeb 100644 --- a/exist-core/src/main/java/org/exist/dom/memtree/DocumentBuilderReceiver.java +++ b/exist-core/src/main/java/org/exist/dom/memtree/DocumentBuilderReceiver.java @@ -107,6 +107,7 @@ public XQueryContext getContext() { @Override public void setDocumentLocator(Locator locator) { + // no-op: in-memory builder does not surface source-location information. } @Override @@ -206,12 +207,127 @@ public void characters(final char[] ch, final int start, final int len) throws S @Override public void attribute(final QName qname, final String value) throws SAXException { try { - builder.addAttribute(checkNS(false, qname), value); + // Attribute namespace handling must be independent of the checkNS + // flag: when an attribute is copied into a new element constructor + // (XQuery 3.1 §3.9.1.3, default copy-namespaces preserve), the + // attribute's prefix MUST be rebound if it conflicts with an + // in-scope binding on the new element, and its (prefix, URI) + // mapping MUST be reflected as a namespace node on that element. + final QName resolved = qname.hasNamespace() + ? resolveAttributeQName(qname) + : qname; + builder.addAttribute(resolved, value); } catch(final DOMException e) { throw new SAXException(e.getMessage(), e); } } + /** + * Resolves a namespaced attribute QName against the current in-scope + * namespaces, rebinding to a freshly generated prefix on prefix-to-URI + * conflict, and emitting a namespace node on the parent element to make + * the binding visible to the serializer. + */ + private QName resolveAttributeQName(final QName qname) { + final XQueryContext context = builder.getContext(); + final String uri = qname.getNamespaceURI(); + String prefix = qname.getPrefix(); + if (prefix == null || prefix.isEmpty()) { + // Attribute with namespace but no prefix: pick an existing prefix + // mapped to this URI, or generate a fresh one. + final String existing = context == null ? null : context.getInScopePrefix(uri); + if (existing != null && !existing.isEmpty()) { + prefix = existing; + } else { + prefix = generatePrefix(context, null); + if (context != null) { + context.declareInScopeNamespace(prefix, uri); + } + emitNamespaceNode(prefix, uri); + return new QName(qname.getLocalPart(), uri, prefix); + } + } else if (context != null) { + final String boundUri = context.getInScopeNamespace(prefix); + if (boundUri == null) { + // Prefix is not in scope -> declare it + context.declareInScopeNamespace(prefix, uri); + } else if (!boundUri.equals(uri)) { + // Prefix is bound to a different URI -> generate a fresh prefix + String reuse = context.getInScopePrefix(uri); + if (reuse == null || reuse.isEmpty()) { + prefix = generatePrefix(context, null); + context.declareInScopeNamespace(prefix, uri); + } else { + prefix = reuse; + } + } + } + emitNamespaceNode(prefix, uri); + return new QName(qname.getLocalPart(), uri, prefix); + } + + /** + * Adds an xmlns:prefix=uri namespace node to the current element when not + * already declared there. No-op for the {@code xml} prefix or when the + * parent node is not an element. + */ + private void emitNamespaceNode(final String prefix, final String uri) { + if (prefix == null || prefix.isEmpty() || XMLConstants.XML_NS_PREFIX.equals(prefix)) { + return; + } + final DocumentImpl doc = builder.getDocument(); + final int parent = doc.getLastNode(); + if (!isElementParent(doc, parent)) { + return; + } + if (isParentSelfDeclaration(doc, parent, prefix, uri)) { + return; + } + if (hasExistingPrefixDeclaration(doc, parent, prefix)) { + return; + } + builder.namespaceNode(prefix, uri); + } + + private static boolean isElementParent(final DocumentImpl doc, final int parent) { + return parent >= 0 && doc.getNodeType(parent) == org.w3c.dom.Node.ELEMENT_NODE; + } + + /** + * The parent element already carries the prefix-to-uri binding via its + * own name (e.g. parent is {@code } and we're being + * asked to emit {@code xmlns:c="..."} for the same URI). The declaration + * is redundant. + */ + private static boolean isParentSelfDeclaration(final DocumentImpl doc, final int parent, + final String prefix, final String uri) { + final QName parentName = doc.nodeName[parent]; + return parentName != null + && prefix.equals(parentName.getPrefix()) + && uri.equals(parentName.getNamespaceURI()); + } + + /** + * Scan the namespace declarations already attached to {@code parent} and + * return true if any of them binds the same {@code prefix}. + */ + private static boolean hasExistingPrefixDeclaration(final DocumentImpl doc, final int parent, + final String prefix) { + final int firstNs = doc.alphaLen[parent]; + if (firstNs < 0) { + return false; + } + for (int ns = firstNs; + ns < doc.nextNamespace && doc.namespaceParent[ns] == parent; + ns++) { + final QName nsName = doc.namespaceCode[ns]; + if (nsName != null && prefix.equals(nsName.getLocalPart())) { + return true; + } + } + return false; + } + @Override public void ignorableWhitespace(final char[] ch, final int start, final int len) throws SAXException { if (!suppressWhitespace) { @@ -231,18 +347,22 @@ public void cdataSection(final char[] ch, final int start, final int len) throws @Override public void skippedEntity(final String name) throws SAXException { + // no-op: entity references are not surfaced through the in-memory builder. } @Override public void endCDATA() throws SAXException { + // no-op: CDATA boundaries are not surfaced through the in-memory builder. } @Override public void endDTD() throws SAXException { + // no-op: DTD declarations are not surfaced through the in-memory builder. } @Override public void startCDATA() throws SAXException { + // no-op: CDATA boundaries are not surfaced through the in-memory builder. } @Override @@ -257,14 +377,17 @@ public void comment(final char[] ch, final int start, final int length) throws S @Override public void endEntity(final String name) throws SAXException { + // no-op: entity boundaries are not surfaced through the in-memory builder. } @Override public void startEntity(final String name) throws SAXException { + // no-op: entity boundaries are not surfaced through the in-memory builder. } @Override public void startDTD(final String name, final String publicId, final String systemId) throws SAXException { + // no-op: DTD declarations are not surfaced through the in-memory builder. } @Override @@ -308,18 +431,18 @@ private QName checkNS(boolean isElement, final QName qname) { return qname; } - private String generatePrefix(final XQueryContext context, String prefix) { + private String generatePrefix(final XQueryContext context, final String requestedPrefix) { + if (requestedPrefix != null) { + return requestedPrefix; + } + // Generate "XXX", "XXX1", "XXX2", ... until we find one not already + // bound in scope. + String candidate = "XXX"; int i = 0; - while(prefix == null) { - prefix = "XXX"; - if(i > 0) { - prefix += String.valueOf(i); - } - if(context.getInScopeNamespace(prefix) != null) { - prefix = null; - i++; - } + while (context.getInScopeNamespace(candidate) != null) { + i++; + candidate = "XXX" + i; } - return prefix; + return candidate; } } \ No newline at end of file diff --git a/exist-core/src/test/java/org/exist/xquery/ElementConstructorAttrNamespaceTest.java b/exist-core/src/test/java/org/exist/xquery/ElementConstructorAttrNamespaceTest.java new file mode 100644 index 00000000000..ed928f962e1 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/ElementConstructorAttrNamespaceTest.java @@ -0,0 +1,102 @@ +/* + * 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; +import static org.junit.Assert.assertTrue; + +/** + * Regression tests for QT4 / XQTS 3.1 prod-DirElemContent.namespace + * Constr-inscope-{1..4}: when a namespaced attribute is copied into a + * direct element constructor via an enclosed expression, and the prefix + * the source attribute uses is already bound to a different namespace URI + * on the new constructor, eXist must rebind the attribute to a freshly + * generated prefix rather than silently dropping it. + * + *

XQuery 3.1 §3.9.3.4 (Direct Element Constructors) and §4.16 + * (Copy-Namespaces Declaration). The default copy-namespaces mode is + * {@code preserve, inherit}: an attribute's own in-scope namespace MUST + * be preserved on the constructed element.

+ */ +public class ElementConstructorAttrNamespaceTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = + new ExistXmldbEmbeddedServer(false, true, true); + + /** Constr-inscope-3: prefix on the new constructor conflicts with the copied attribute's prefix. */ + @Test + public void copiedAttributeWithConflictingPrefixIsPreserved() throws XMLDBException { + final String xquery = """ + for $x in + return {$x//@*:attr1}"""; + final ResourceSet result = existEmbeddedServer.executeQuery(xquery); + assertEquals(1, result.getSize()); + final String out = result.getResource(0).getContent().toString(); + // The attribute must survive the copy: its namespace URI is + // http://www.example.com/parent1 and its local name is attr1. + assertTrue("attribute attr1 must be present in: " + out, + out.contains(":attr1=\"attr1\"")); + assertTrue("source namespace must be declared on the new element: " + out, + out.contains("http://www.example.com/parent1")); + } + + /** Constr-inscope-4: two attributes with conflicting prefixes copied via enclosed expr. */ + @Test + public void multipleCopiedAttributesWithConflictingPrefixesArePreserved() throws XMLDBException { + final String xquery = """ + for $x in + + + + return {$x//@*:attr1, $x//@*:attr2}"""; + final ResourceSet result = existEmbeddedServer.executeQuery(xquery); + assertEquals(1, result.getSize()); + final String out = result.getResource(0).getContent().toString(); + assertTrue("attr1 must be present: " + out, out.contains(":attr1=\"attr1\"")); + assertTrue("attr2 must be present: " + out, out.contains(":attr2=\"attr2\"")); + assertTrue("parent1 namespace must be declared: " + out, + out.contains("http://www.example.com/parent1")); + assertTrue("parent2 namespace must be declared: " + out, + out.contains("http://www.example.com/parent2")); + } + + /** Simpler reproducer: rename in-scope namespace using a single copied attribute. */ + @Test + public void copiedAttributeNamespaceRebindsPrefix() throws XMLDBException { + final String xquery = """ + let $src := + return {$src/@*}"""; + final ResourceSet result = existEmbeddedServer.executeQuery(xquery); + assertEquals(1, result.getSize()); + final String out = result.getResource(0).getContent().toString(); + assertTrue("attribute k must be present: " + out, out.contains(":k=\"v\"")); + assertTrue("source URI A must be retained on the constructed element: " + out, + out.contains("http://example.com/A")); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/XQueryTest.java b/exist-core/src/test/java/org/exist/xquery/XQueryTest.java index 22846889895..e4a9a67cc57 100644 --- a/exist-core/src/test/java/org/exist/xquery/XQueryTest.java +++ b/exist-core/src/test/java/org/exist/xquery/XQueryTest.java @@ -1759,7 +1759,14 @@ public void attributeNamespace() throws XMLDBException { XPathQueryService.class); ResourceSet result = service.query(query); assertEquals(1, result.getSize()); - assertEquals("" + "ccc" + "", result.getResource(0).getContent().toString()); + // XQuery 3.1 §2: "the relative order of namespace nodes that share a parent is also implementation dependent." + final Source expected = Input.fromString("ccc").build(); + final Source actual = Input.fromString(result.getResource(0).getContent().toString()).build(); + final Diff diff = DiffBuilder.compare(expected) + .withTest(actual) + .checkForIdentical() + .build(); + assertFalse(diff.toString(), diff.hasDifferences()); } @Test @@ -2969,8 +2976,14 @@ public void noNamepaceDefinedForPrefix_1959010() throws XMLDBException { ResourceSet result = service.query(query); assertEquals(1, result.getSize()); - assertEquals(query, "ccc", - result.getResource(0).getContent().toString()); + // XQuery 3.1 §2: "the relative order of namespace nodes that share a parent is also implementation dependent." + final Source expected = Input.fromString("ccc").build(); + final Source actual = Input.fromString(result.getResource(0).getContent().toString()).build(); + final Diff diff = DiffBuilder.compare(expected) + .withTest(actual) + .checkForIdentical() + .build(); + assertFalse(query + "\n" + diff.toString(), diff.hasDifferences()); } /**