From b40b4c384069087ba12121d41051d62202e9bb6d Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 26 Oct 2026 10:09:29 +1100 Subject: [PATCH] Resolve createElement() prefixes against the first match, not the root QPXML::createElement() looked an element name's prefix up on the owner document, which only sees declarations on the document element, so a prefix declared on the matched element or an ancestor below the root threw "Undefined namespace". Look it up on the element instead; root declarations are in scope there too. The foreach returned on its first pass, so only the first match was ever used. Use get(0) so the code says so. Fixes #94 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/Extension/QPXML.php | 43 +++++++++++++------------ tests/QueryPath/Extension/QPXMLTest.php | 37 +++++++++++++++++++++ 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7c710..e7cf2d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ QueryPath Changelog - Fix `QPXML::createElement()` emitting an "Only variables should be passed by reference" notice for a namespace-prefixed element name without an explicit namespace URI (issue #82) - Fix the `:text` pseudo-class so it matches jQuery: it selects `input` elements whose `type` attribute is absent or is `text` (case-insensitively). It never indicated, and still does not indicate, whether a node is a text node - `find('*')` now matches the nodes in the match set as well as their descendants, so that a selector can be tested against an element already in hand +- Fix `QPXML::createElement()` throwing "Undefined namespace" for a prefixed element name whose prefix is declared on the matched element or an ancestor below the document element. The prefix is now resolved against the namespaces in scope on the first matched element (issue #94) - `writeHTML5()` now returns the `DOMQuery` like the other `write*()` methods, and throws `QueryPath\IOException` instead of a `TypeError` when the file cannot be written (issue #80) - Fix `QueryPath\ParseException` and `QueryPath\IOException` losing the PHP warning they were raised from (issue #91). The message was the error number, as in `2 (/path/to/src/DOMQuery.php: 1343)`, and the code was always `0`. The message now carries the warning text and the code is the PHP error level (for example `E_WARNING`), so code that matches on the old message text will need updating - Fix `tag()` emitting an "Undefined property" warning and returning `NULL` when the first match is not an element, such as a text, comment or processing instruction node returned by `contents()`. It now returns an empty string, as it does for an empty match set (issue #93) diff --git a/src/Extension/QPXML.php b/src/Extension/QPXML.php index 85d7f34..ec227da 100644 --- a/src/Extension/QPXML.php +++ b/src/Extension/QPXML.php @@ -174,40 +174,41 @@ public function createNilElement($text, $value) /** * Create an element with the given namespace. * + * The element is created in the document of the first matched element. If + * $text has a prefix and no $nsUri is given, the prefix is resolved against + * the namespaces in scope on that element. + * * @param string $text * @param string $nsUri * The namespace URI for the given element. * * @return DOMQuery + * The new element, or an empty DOMQuery if there are no matches. + * @throws Exception + * If the prefix of $text is not bound to a namespace on the first match. */ public function createElement($text, $nsUri = null) { - if (isset($text)) { - foreach ($this->qp->get() as $element) { - if ($nsUri === null && strpos($text, ':') !== false) { - $ns = strstr($text, ':', true); - $nsUri = $element->ownerDocument->lookupNamespaceURI($ns); - - if ($nsUri === null) { - throw new Exception("Undefined namespace for: " . $text); - } - } + $element = $this->qp->get(0); + if (! isset($text) || $element === null) { + return new DOMQuery(); + } - $node = null; - if ($nsUri !== null) { - $node = $element->ownerDocument->createElementNS( - $nsUri, - $text - ); - } else { - $node = $element->ownerDocument->createElement($text); - } + if ($nsUri === null && strpos($text, ':') !== false) { + $nsUri = $element->lookupNamespaceURI(strstr($text, ':', true)); - return QueryPath::with($node); + if ($nsUri === null) { + throw new Exception("Undefined namespace for: " . $text); } } - return new DOMQuery(); + if ($nsUri !== null) { + $node = $element->ownerDocument->createElementNS($nsUri, $text); + } else { + $node = $element->ownerDocument->createElement($text); + } + + return QueryPath::with($node); } /** diff --git a/tests/QueryPath/Extension/QPXMLTest.php b/tests/QueryPath/Extension/QPXMLTest.php index 37671c4..9cef09e 100644 --- a/tests/QueryPath/Extension/QPXMLTest.php +++ b/tests/QueryPath/Extension/QPXMLTest.php @@ -38,6 +38,43 @@ public function testCreateElementWithPrefix() $this->assertEquals('x:item', $element->nodeName); } + public function testCreateElementWithPrefixDeclaredBelowRoot() + { + $xml = ''; + + $element = qp($xml, 'child')->createElement('y:item')->get(0); + $this->assertEquals('urn:y', $element->namespaceURI); + $this->assertEquals('y:item', $element->nodeName); + + $element = qp($xml, 'child')->createElement('x:item')->get(0); + $this->assertEquals('urn:x', $element->namespaceURI); + } + + public function testCreateElementWithPrefixOutOfScope() + { + $xml = ''; + + $this->expectException(\QueryPath\Exception::class); + $this->expectExceptionMessage('Undefined namespace for: y:item'); + qp($xml, 'b')->createElement('y:item'); + } + + public function testCreateElementUsesFirstMatch() + { + $xml = ''; + + $element = qp($xml, 'a, b')->createElement('y:item'); + $this->assertCount(1, $element); + $this->assertEquals('urn:a', $element->get(0)->namespaceURI); + } + + public function testCreateElementWithNoMatches() + { + $xml = ''; + + $this->assertCount(0, qp($xml, 'missing')->createElement('x:item')); + } + public function testComment() { $this->assertEquals('This is a comment.', trim(qp($this->file, 'root')->comment()));