Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/dom.md
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,35 @@ Document::fromUnsafeDocument(
);
```

#### promote_namespaces

This configurator moves every prefixed `xmlns:*` declaration found on a
descendant element up to the document element, keeping the original prefix
names intact. It is the counterpart of `optimize_namespaces` for cases where
you need eager namespace declarations without renaming prefixes (e.g. SOAP
servers that validate namespace placement on the envelope). Default
namespaces (`xmlns="..."`) and prefixes that conflict with a declaration
already on the document element are left untouched.

⚠️ Legacy DOM quirk: writing an xmlns declaration to the document element
triggers libxml's namespace reconciliation. When two descendants declare the
same prefix for different URIs, libxml rewrites the second subtree's
element prefixes (e.g. `a` -> `a1`) and pulls the extra xmlns up to the
root. Prefix references inside attribute *values* (e.g. `xsi:type="a:Thing"`)
are opaque strings and are not rewritten, so they may end up resolving
against the shadowing declaration on the original subtree. Upgrade to
`veewee/xml` 4.x if exact prefix preservation matters for your consumers.

```php
use VeeWee\Xml\Dom\Document;
use function VeeWee\Xml\Dom\Configurator\promote_namespaces;

Document::fromUnsafeDocument(
$document,
promote_namespaces()
);
```

#### pretty_print

Makes the output of the DOM document human-readable.
Expand Down Expand Up @@ -987,6 +1016,36 @@ $doc->manipulate(
);
```

#### promote_namespaces

Moves every prefixed `xmlns:*` declaration found on a descendant element up to
the document element while preserving the original prefix names. Default
namespaces and prefixes that conflict with a declaration already on the
document element are left untouched. Unlike `optimize_namespaces`, prefixes
are not renamed, which matters when consumers perform strict XSD validation.

⚠️ Legacy DOM quirk: writing an xmlns declaration to the document element
triggers libxml's namespace reconciliation. When two descendants declare the
same prefix for different URIs, libxml rewrites the second subtree's
element prefixes (e.g. `a` -> `a1`) and pulls the extra xmlns up to the
root. Prefix references inside attribute *values* (e.g. `xsi:type="a:Thing"`)
are opaque strings and are not rewritten, so they may end up resolving
against the shadowing declaration on the original subtree. Upgrade to
`veewee/xml` 4.x if exact prefix preservation matters for your consumers.

```php
use DOMDocument;
use VeeWee\Xml\Dom\Document;
use function VeeWee\Xml\Dom\Manipulator\Document\promote_namespaces;

$doc = Document::fromXmlString($xml);
$doc->manipulate(
static function (DOMDocument $document): void {
promote_namespaces($document);
}
);
```

### Element

Element specific manipulators operate on `DOMElement` instances.
Expand Down
21 changes: 21 additions & 0 deletions src/Xml/Dom/Configurator/promote_namespaces.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace VeeWee\Xml\Dom\Configurator;

use Closure;
use DOMDocument;
use function VeeWee\Xml\Dom\Manipulator\Document\promote_namespaces as promote_namespaces_manipulator;

/**
* @return Closure(DOMDocument): DOMDocument
*/
function promote_namespaces(): Closure
{
return static function (DOMDocument $document): DOMDocument {
promote_namespaces_manipulator($document);

return $document;
};
}
64 changes: 64 additions & 0 deletions src/Xml/Dom/Manipulator/Document/promote_namespaces.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace VeeWee\Xml\Dom\Manipulator\Document;

use DOMDocument;
use DOMNameSpaceNode;
use VeeWee\Xml\Exception\RuntimeException;
use function Psl\Dict\pull;
use function VeeWee\Xml\Dom\Builder\xmlns_attribute;
use function VeeWee\Xml\Dom\Locator\Attribute\xmlns_attributes_list;
use function VeeWee\Xml\Dom\Locator\document_element;
use function VeeWee\Xml\Dom\Manipulator\Node\remove_namespace;

/**
* Moves prefixed xmlns declarations from descendant nodes up to the document
* element while keeping the original prefix names. Default namespaces and
* prefixes that conflict with a declaration already on the document element
* are left untouched.
*
* Note — legacy DOM quirk: calling setAttributeNS with the xmlns URI triggers
* libxml's namespace reconciliation. In documents where two descendants
* declare the same prefix for different URIs, libxml rewrites the second
* subtree's element prefixes (e.g. `a` -> `a1`) and pulls the extra xmlns up
* to the root. The result is still semantically equivalent XML, but opaque
* prefix references inside attribute values (e.g. xsi:type="a:Thing") are
* not rewritten and may resolve against the shadowing declaration on the
* original subtree. Upgrade to veewee/xml 4.x if exact prefix preservation
* matters for your consumers.
*
* @throws RuntimeException
*/
function promote_namespaces(DOMDocument $document): void
{
$documentElement = document_element()($document);

/** @var array<string, string> $promoted prefix => URI */
$promoted = pull(
xmlns_attributes_list($documentElement)
->filter(static fn (DOMNameSpaceNode $attr): bool => $attr->prefix !== ''),
static fn (DOMNameSpaceNode $attr): string => $attr->namespaceURI,
static fn (DOMNameSpaceNode $attr): string => $attr->prefix,
);

foreach ($documentElement->getElementsByTagName('*') as $element) {
$prefixedXmlns = xmlns_attributes_list($element)
->filter(static fn (DOMNameSpaceNode $attr): bool => $attr->prefix !== '');

foreach ($prefixedXmlns as $attr) {
$prefix = $attr->prefix;
$uri = $attr->namespaceURI;

if (!array_key_exists($prefix, $promoted)) {
xmlns_attribute($prefix, $uri)($documentElement);
$promoted[$prefix] = $uri;
}

if ($promoted[$prefix] === $uri) {
remove_namespace($attr, $element);
}
}
}
}
2 changes: 2 additions & 0 deletions src/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
'Xml\Dom\Configurator\normalize' => __DIR__.'/Xml/Dom/Configurator/normalize.php',
'Xml\Dom\Configurator\optimize_namespaces' => __DIR__.'/Xml/Dom/Configurator/optimize_namespaces.php',
'Xml\Dom\Configurator\pretty_print' => __DIR__.'/Xml/Dom/Configurator/pretty_print.php',
'Xml\Dom\Configurator\promote_namespaces' => __DIR__.'/Xml/Dom/Configurator/promote_namespaces.php',
'Xml\Dom\Configurator\traverse' => __DIR__.'/Xml/Dom/Configurator/traverse.php',
'Xml\Dom\Configurator\trim_spaces' => __DIR__.'/Xml/Dom/Configurator/trim_spaces.php',
'Xml\Dom\Configurator\utf8' => __DIR__.'/Xml/Dom/Configurator/utf8.php',
Expand Down Expand Up @@ -58,6 +59,7 @@
'Xml\Dom\Locator\root_namespace' => __DIR__.'/Xml/Dom/Locator/root_namespace.php',
'Xml\Dom\Manipulator\Attribute\rename' => __DIR__.'/Xml/Dom/Manipulator/Attribute/rename.php',
'Xml\Dom\Manipulator\Document\optimize_namespaces' => __DIR__.'/Xml/Dom/Manipulator/Document/optimize_namespaces.php',
'Xml\Dom\Manipulator\Document\promote_namespaces' => __DIR__.'/Xml/Dom/Manipulator/Document/promote_namespaces.php',
'Xml\Dom\Manipulator\Element\copy_named_xmlns_attributes' => __DIR__.'/Xml/Dom/Manipulator/Element/copy_named_xmlns_attributes.php',
'Xml\Dom\Manipulator\Element\rename' => __DIR__.'/Xml/Dom/Manipulator/Element/rename.php',
'Xml\Dom\Manipulator\Node\append_external_node' => __DIR__.'/Xml/Dom/Manipulator/Node/append_external_node.php',
Expand Down
81 changes: 81 additions & 0 deletions tests/Xml/Dom/Configurator/PromoteNamespacesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace VeeWee\Tests\Xml\Dom\Configurator;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use VeeWee\Xml\Dom\Document;
use function VeeWee\Xml\Dom\Configurator\promote_namespaces;
use function VeeWee\Xml\Dom\Locator\document_element;
use function VeeWee\Xml\Dom\Mapper\xml_string;

final class PromoteNamespacesTest extends TestCase
{
#[DataProvider('provideXmls')]
public function test_it_can_promote_namespaces(string $input, string $expected): void
{
$doc = Document::fromXmlString($input, promote_namespaces());
$actual = xml_string()($doc->map(document_element()));

static::assertSame($expected, $actual);
}

public static function provideXmls(): iterable
{
yield 'no-action' => [
'<hello/>',
'<hello/>',
];

yield 'child-to-root' => [
<<<EOXML
<foo>
<bar xmlns:a="http://a"><a:baz/></bar>
</foo>
EOXML,
<<<EOXML
<foo xmlns:a="http://a">
<bar><a:baz/></bar>
</foo>
EOXML,
];

yield 'mixed-namespaces' => [
<<<EOXML
<foo>
<bar xmlns:a="http://a"><a:x/></bar>
<baz xmlns:b="http://b"><b:y/></baz>
</foo>
EOXML,
<<<EOXML
<foo xmlns:a="http://a" xmlns:b="http://b">
<bar><a:x/></bar>
<baz><b:y/></baz>
</foo>
EOXML,
];

yield 'soap-like' => [
<<<EOXML
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
<SOAP-ENV:Body>
<tns:getUser xmlns:tns="https://example.com">
<tns:id xmlns:tns="https://example.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:int" xmlns:xsd="http://www.w3.org/2001/XMLSchema">1</tns:id>
</tns:getUser>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>
EOXML,
<<<EOXML
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tns="https://example.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<SOAP-ENV:Body>
<tns:getUser>
<tns:id xsi:type="xsd:int">1</tns:id>
</tns:getUser>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>
EOXML,
];
}
}
Loading