diff --git a/README.md b/README.md index 37a7010..46404fa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## Overview -s9e\\SweetDOM is a library that extends [PHP's DOM extension](https://www.php.net/manual/en/book.dom.php) with a set of methods designed to simplify and facilitate the manipulation of XSLT 1.0 templates. +s9e\\SweetDOM is a library that extends [PHP's DOM extension](https://www.php.net/manual/en/book.dom.php) to make DOM manipulation easier, with a particular emphasis on XSLT 1.0 templates. It adds syntactic sugar for the most common DOM operations, [improves compatibility](#backward-and-forward-compatibility-with-older-and-future-versions-of-php) across PHP versions, and implements polyfills for some of the newer methods. [![Code Coverage](https://scrutinizer-ci.com/g/s9e/SweetDOM/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/s9e/SweetDOM/?branch=master) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/s9e/SweetDOM/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/s9e/SweetDOM/?branch=master) @@ -173,6 +173,7 @@ In order to improve compatibility with older versions of PHP as well as future v Polyfills for the following methods are provided for PHP < 8.3: + - `Node::isEqualNode` - `ParentNode::insertAdjacentElement` - `ParentNode::insertAdjacentText` - `ParentNode::replaceChildren` diff --git a/src/Document.php b/src/Document.php index 6876ebe..711f645 100644 --- a/src/Document.php +++ b/src/Document.php @@ -13,7 +13,7 @@ use DOMXPath; use RuntimeException; use const PHP_VERSION; -use function func_get_args, libxml_get_last_error, trim, version_compare; +use function func_get_args, method_exists, libxml_get_last_error, trim, version_compare; /** * @method Attr|false createAttribute(string $localName) @@ -66,6 +66,13 @@ public function firstOf(string $expression, ?DOMNode $contextNode = null, bool $ return $this->query(...func_get_args())->item(0); } + public function isEqualNode(?DOMNode $otherNode): bool + { + return method_exists('DOMDocument', 'isEqualNode') + ? parent::isEqualNode($otherNode) + : NodeComparator::isEqualNode($this, $otherNode); + } + /** * Evaluate and return the result of a given XPath query */ diff --git a/src/ForwardCompatibleNodes/Attr.php b/src/ForwardCompatibleNodes/Attr.php index 1141bdd..50e05fb 100644 --- a/src/ForwardCompatibleNodes/Attr.php +++ b/src/ForwardCompatibleNodes/Attr.php @@ -8,7 +8,9 @@ namespace s9e\SweetDOM\ForwardCompatibleNodes; use s9e\SweetDOM\Attr as ParentClass; +use s9e\SweetDOM\NodeTraits\NodePolyfill; class Attr extends ParentClass { + use NodePolyfill; } \ No newline at end of file diff --git a/src/ForwardCompatibleNodes/CdataSection.php b/src/ForwardCompatibleNodes/CdataSection.php index 10763e4..222570d 100644 --- a/src/ForwardCompatibleNodes/CdataSection.php +++ b/src/ForwardCompatibleNodes/CdataSection.php @@ -9,8 +9,10 @@ use s9e\SweetDOM\CdataSection as ParentClass; use s9e\SweetDOM\NodeTraits\ChildNodeForwardCompatibility; +use s9e\SweetDOM\NodeTraits\NodePolyfill; class CdataSection extends ParentClass { use ChildNodeForwardCompatibility; + use NodePolyfill; } \ No newline at end of file diff --git a/src/ForwardCompatibleNodes/Comment.php b/src/ForwardCompatibleNodes/Comment.php index 143448c..dcb889a 100644 --- a/src/ForwardCompatibleNodes/Comment.php +++ b/src/ForwardCompatibleNodes/Comment.php @@ -9,8 +9,10 @@ use s9e\SweetDOM\Comment as ParentClass; use s9e\SweetDOM\NodeTraits\ChildNodeForwardCompatibility; +use s9e\SweetDOM\NodeTraits\NodePolyfill; class Comment extends ParentClass { use ChildNodeForwardCompatibility; + use NodePolyfill; } \ No newline at end of file diff --git a/src/ForwardCompatibleNodes/DocumentFragment.php b/src/ForwardCompatibleNodes/DocumentFragment.php index 67b1963..db93738 100644 --- a/src/ForwardCompatibleNodes/DocumentFragment.php +++ b/src/ForwardCompatibleNodes/DocumentFragment.php @@ -8,9 +8,11 @@ namespace s9e\SweetDOM\ForwardCompatibleNodes; use s9e\SweetDOM\DocumentFragment as ParentClass; +use s9e\SweetDOM\NodeTraits\NodePolyfill; use s9e\SweetDOM\NodeTraits\ParentNodePolyfill; class DocumentFragment extends ParentClass { + use NodePolyfill; use ParentNodePolyfill; } \ No newline at end of file diff --git a/src/ForwardCompatibleNodes/Element.php b/src/ForwardCompatibleNodes/Element.php index 876622e..16604ba 100644 --- a/src/ForwardCompatibleNodes/Element.php +++ b/src/ForwardCompatibleNodes/Element.php @@ -9,10 +9,12 @@ use s9e\SweetDOM\Element as ParentClass; use s9e\SweetDOM\NodeTraits\ChildNodeForwardCompatibility; +use s9e\SweetDOM\NodeTraits\NodePolyfill; use s9e\SweetDOM\NodeTraits\ParentNodePolyfill; class Element extends ParentClass { use ChildNodeForwardCompatibility; + use NodePolyfill; use ParentNodePolyfill; } \ No newline at end of file diff --git a/src/ForwardCompatibleNodes/Text.php b/src/ForwardCompatibleNodes/Text.php index 54f32d1..c03a743 100644 --- a/src/ForwardCompatibleNodes/Text.php +++ b/src/ForwardCompatibleNodes/Text.php @@ -8,9 +8,11 @@ namespace s9e\SweetDOM\ForwardCompatibleNodes; use s9e\SweetDOM\NodeTraits\ChildNodeForwardCompatibility; +use s9e\SweetDOM\NodeTraits\NodePolyfill; use s9e\SweetDOM\Text as ParentClass; class Text extends ParentClass { use ChildNodeForwardCompatibility; + use NodePolyfill; } \ No newline at end of file diff --git a/src/NodeComparator.php b/src/NodeComparator.php new file mode 100644 index 0000000..053fa3c --- /dev/null +++ b/src/NodeComparator.php @@ -0,0 +1,177 @@ +nodeType !== $otherNode->nodeType) + { + return false; + } + $classes = [ + 'DOMElement', + 'DOMCharacterData', + 'DOMProcessingInstruction', + 'DOMAttr', + 'DOMDocument', + 'DOMDocumentFragment', + 'DOMDocumentType', + 'DOMEntityReference', + 'DOMEntity', + 'DOMNotation' + ]; + foreach ($classes as $className) + { + if ($node instanceof $className && $otherNode instanceof $className) + { + $methodName = 'isEqual' . substr($className, 3); + + return static::$methodName($node, $otherNode); + } + } + + // @codeCoverageIgnoreStart + return $node->isSameNode($otherNode); + // @codeCoverageIgnoreEnd + } + + /** + * @return array + */ + protected static function getNamespaceDeclarations(DOMElement $element): array + { + $namespaces = []; + $xpath = new DOMXPath($element->ownerDocument); + foreach ($xpath->query('namespace::*', $element) as $node) + { + if ($element->hasAttribute($node->nodeName)) + { + $namespaces[$node->nodeName] = $node->nodeValue; + } + } + + return $namespaces; + } + + protected static function hasEqualNamespaceDeclarations(DOMElement $element, DOMElement $otherElement): bool + { + return static::getNamespaceDeclarations($element) == static::getNamespaceDeclarations($otherElement); + } + + protected static function isEqualAttr(DOMAttr $node, DOMAttr $otherNode): bool + { + return $node->namespaceURI === $otherNode->namespaceURI + && $node->localName === $otherNode->localName + && $node->value === $otherNode->value; + } + + protected static function isEqualCharacterData(DOMCharacterData $node, DOMCharacterData $otherNode): bool + { + // Covers DOMCdataSection, DOMComment, and DOMText + return $node->data === $otherNode->data; + } + + protected static function isEqualDocument(DOMDocument $node, DOMDocument $otherNode): bool + { + return static::isEqualNodeList($node->childNodes, $otherNode->childNodes); + } + + protected static function isEqualDocumentFragment(DOMDocumentFragment $node, DOMDocumentFragment $otherNode): bool + { + return static::isEqualNodeList($node->childNodes, $otherNode->childNodes); + } + + protected static function isEqualDocumentType(DOMDocumentType $node, DOMDocumentType $otherNode): bool + { + return $node->name === $otherNode->name + && $node->publicId === $otherNode->publicId + && $node->systemId === $otherNode->systemId; + } + + protected static function isEqualElement(DOMElement $element, DOMElement $otherElement): bool + { + if ($element->namespaceURI !== $otherElement->namespaceURI + || $element->nodeName !== $otherElement->nodeName + || $element->attributes->length !== $otherElement->attributes->length + || $element->childNodes->length !== $otherElement->childNodes->length) + { + return false; + } + + foreach ($element->attributes as $attribute) + { + if ($attribute->value !== $otherElement->attributes->getNamedItem($attribute->name)?->value) + { + return false; + } + } + + return static::isEqualNodeList($element->childNodes, $otherElement->childNodes) + && static::hasEqualNamespaceDeclarations($element, $otherElement); + } + + protected static function isEqualEntity(DOMEntity $node, DOMEntity $otherNode): bool + { + return $node->nodeName === $otherNode->nodeName + && $node->publicId === $otherNode->publicId + && $node->systemId === $otherNode->systemId; + } + + protected static function isEqualEntityReference(DOMEntityReference $node, DOMEntityReference $otherNode): bool + { + return $node->nodeName === $otherNode->nodeName; + } + + protected static function isEqualNodeList(DOMNodeList $list, DOMNodeList $otherList): bool + { + if ($list->length !== $otherList->length) + { + return false; + } + foreach ($list as $i => $node) + { + if (!static::isEqualNode($node, $otherList->item($i))) + { + return false; + } + } + + return true; + } + + protected static function isEqualNotation(DOMNotation $node, DOMNotation $otherNode): bool + { + return $node->nodeName === $otherNode->nodeName + && $node->publicId === $otherNode->publicId + && $node->systemId === $otherNode->systemId; + } + + protected static function isEqualProcessingInstruction(DOMProcessingInstruction $node, DOMProcessingInstruction $otherNode): bool + { + return $node->target === $otherNode->target && $node->data === $otherNode->data; + } +} \ No newline at end of file diff --git a/src/NodeTraits/NodePolyfill.php b/src/NodeTraits/NodePolyfill.php new file mode 100644 index 0000000..fea5ed4 --- /dev/null +++ b/src/NodeTraits/NodePolyfill.php @@ -0,0 +1,22 @@ +loadXML(''); + + $this->assertFalse($dom->firstOf('//y/@a')->isEqualNode($dom->firstOf('//z/@a'))); + $this->assertFalse($dom->firstOf('//y/@a')->isEqualNode($dom->firstOf('//z/@b'))); + $this->assertFalse($dom->firstOf('//y/@a')->isEqualNode($dom->firstOf('//z/@c'))); + $this->assertFalse($dom->firstOf('//y/@b')->isEqualNode($dom->firstOf('//z/@a'))); + $this->assertTrue($dom->firstOf('//y/@b')->isEqualNode($dom->firstOf('//z/@b'))); + $this->assertFalse($dom->firstOf('//y/@b')->isEqualNode($dom->firstOf('//z/@c'))); + $this->assertFalse($dom->firstOf('//y/@c')->isEqualNode($dom->firstOf('//z/@a'))); + $this->assertFalse($dom->firstOf('//y/@c')->isEqualNode($dom->firstOf('//z/@b'))); + $this->assertFalse($dom->firstOf('//y/@c')->isEqualNode($dom->firstOf('//z/@c'))); + } +} \ No newline at end of file diff --git a/tests/CdataSectionTest.php b/tests/CdataSectionTest.php index 7badf87..ff094f5 100644 --- a/tests/CdataSectionTest.php +++ b/tests/CdataSectionTest.php @@ -81,4 +81,13 @@ public function testQuery() $this->assertEquals('z', $dom->firstOf('//text()')->query('.//following-sibling::x')->item(0)->getAttribute('id')); } + + public function testIsEqualNode() + { + $dom = new Document; + $dom->loadXML(''); + + $this->assertTrue($dom->firstOf('//x')->firstChild->isEqualNode($dom->firstOf('//x')->lastChild)); + $this->assertFalse($dom->firstOf('//x')->firstChild->isEqualNode($dom->firstOf('//y'))); + } } \ No newline at end of file diff --git a/tests/CommentTest.php b/tests/CommentTest.php index 1c5f3b8..d816940 100644 --- a/tests/CommentTest.php +++ b/tests/CommentTest.php @@ -81,4 +81,13 @@ public function testQuery() $this->assertEquals('z', $dom->firstOf('//comment()')->query('.//following-sibling::x')->item(0)->getAttribute('id')); } + + public function testIsEqualNode() + { + $dom = new Document; + $dom->loadXML(''); + + $this->assertTrue($dom->firstOf('//x')->firstChild->isEqualNode($dom->firstOf('//x')->lastChild)); + $this->assertFalse($dom->firstOf('//x')->firstChild->isEqualNode($dom->firstOf('//y')->firstChild)); + } } \ No newline at end of file diff --git a/tests/DocumentFragmentTest.php b/tests/DocumentFragmentTest.php index 82b6fa9..1b823bb 100644 --- a/tests/DocumentFragmentTest.php +++ b/tests/DocumentFragmentTest.php @@ -83,4 +83,17 @@ public function testQuery() $this->assertEquals('xx', $fragment->query('x')->item(0)->getAttribute('value')); } + + public function testIsEqualNode() + { + $dom = new Document; + + $frag1 = $dom->createDocumentFragment(); + $frag2 = $dom->createDocumentFragment(); + $frag1->appendElement('x')->setAttribute('value', 'xx'); + $frag2->appendElement('x')->setAttribute('value', 'xx'); + $this->assertTrue($frag1->isEqualNode($frag2)); + $frag2->appendElement('x'); + $this->assertFalse($frag1->isEqualNode($frag2)); + } } \ No newline at end of file diff --git a/tests/DocumentTest.php b/tests/DocumentTest.php index 03366af..a049915 100644 --- a/tests/DocumentTest.php +++ b/tests/DocumentTest.php @@ -244,4 +244,15 @@ public static function getClassMapsTestCases(): array ], ]; } + + public function testIsEqualNode() + { + $dom1 = new Document; + $dom1->loadXML(''); + $dom2 = new Document; + $dom2->loadXML(''); + $this->assertTrue($dom1->isEqualNode($dom2)); + $dom2->loadXML(''); + $this->assertFalse($dom1->isEqualNode($dom2)); + } } \ No newline at end of file diff --git a/tests/ElementTest.php b/tests/ElementTest.php index 358e705..415f894 100644 --- a/tests/ElementTest.php +++ b/tests/ElementTest.php @@ -17,6 +17,7 @@ #[CoversClass('s9e\SweetDOM\NodeTraits\ChildNodeWorkarounds')] #[CoversClass('s9e\SweetDOM\NodeTraits\DeprecatedMethods')] #[CoversClass('s9e\SweetDOM\NodeTraits\MagicMethods')] +#[CoversClass('s9e\SweetDOM\NodeTraits\NodePolyfill')] #[CoversClass('s9e\SweetDOM\NodeTraits\ParentNodePolyfill')] #[CoversClass('s9e\SweetDOM\NodeTraits\ParentNodeWorkarounds')] #[CoversClass('s9e\SweetDOM\NodeTraits\XPathMethods')] diff --git a/tests/NodeComparatorTest.php b/tests/NodeComparatorTest.php new file mode 100644 index 0000000..1ecf140 --- /dev/null +++ b/tests/NodeComparatorTest.php @@ -0,0 +1,443 @@ +assertSame($expected, NodeComparator::isEqualNode($node, $otherNode)); + if ($compareNative ?? version_compare(PHP_VERSION, '8.3.0', '>=')) + { + $this->assertSame($expected, $node->isEqualNode($otherNode), 'Does not match ext/dom'); + } + } + + #[DataProvider('getIsEqualNodeCases')] + public function testIsEqualNode(bool $expected, string $xml1, string $xpath1, string $xml2, string $xpath2, bool $compareNative = null): void + { + $node = $this->getNodeFromXML($xml1, $xpath1); + $otherNode = $this->getNodeFromXML($xml2, $xpath2); + + $this->assertIsEqualNode($expected, $node, $otherNode, $compareNative); + } + + protected function getNodeFromXML(string $xml, string $query): DOMNode + { + $dom = new DOMDocument; + $dom->loadXML($xml); + + return (new DOMXPath($dom))->query($query)->item(0); + } + + public static function getIsEqualNodeCases(): array + { + return [ + [ + true, + '', + '//x', + '', + '//x' + ], + [ + false, + '', + '//x', + '', + '//y' + ], + [ + false, + '', + '//x', + '', + '//X' + ], + [ + true, + '', + '//@a', + '', + '//@a' + ], + [ + false, + '', + '//@a', + '', + '//@a' + ], + [ + false, + '', + '//@a', + '', + '//@b' + ], + [ + false, + '', + '//x', + '', + '//*' + ], + [ + false, + '', + '//x', + '', + '//x' + ], + [ + false, + '', + '//x', + '', + '//x' + ], + [ + true, + '', + '//x', + '', + '//x' + ], + [ + true, + '', + '//x', + '', + '//x', + version_compare(PHP_VERSION, '8.3.2', '>=') + ], + [ + false, + '', + '//x', + '', + '//x' + ], + [ + false, + '', + '//x', + '', + '//x' + ], + [ + false, + '', + '//x', + '', + '//x' + ], + [ + false, + '', + '//x', + '', + '//x' + ], + [ + true, + '', + '//x', + '', + '//x' + ], + [ + true, + '', + '//x', + '', + '//x', + version_compare(PHP_VERSION, '8.3.2', '>=') + ], + [ + false, + '', + '//y', + '', + '//*/*' + ], + [ + true, + '', + '//y', + '', + '//*/*' + ], + [ + true, + '', + '//x', + '', + '//x' + ], + [ + true, + '..', + '//x', + '..', + '//x' + ], + [ + true, + '...', + '//x', + '...', + '//x' + ], + [ + true, + '', + '//x', + '', + '//x' + ], + [ + false, + '...', + '//x', + '._.', + '//x' + ], + [ + false, + '...', + '//x', + '..', + '//x' + ], + [ + false, + '..', + '//x', + '..', + '//x' + ], + [ + true, + '', + '//x', + '', + '//x' + ], + [ + true, + '', + '//comment()', + '', + '//comment()' + ], + [ + false, + '', + '//x', + '', + '//x' + ], + [ + true, + '', + '//x', + '', + '//x' + ], + [ + false, + '', + '//x', + '', + '//x' + ], + [ + true, + '', + '//x', + '', + '//x' + ], + [ + false, + '', + '//x', + '', + '//x' + ], + [ + false, + '..', + '//x', + '', + '//x' + ], + [ + false, + '', + '//x', + '', + '//@x' + ], + ]; + } + + // https://github.com/php/php-src/blob/master/ext/dom/tests/DOMNode_isEqualNode.phpt + public function testIsEqualDocumentNode() + { + $this->assertIsEqualNode(true, new DOMDocument, new DOMDocument); + } + + public function testIsEqualDocumentNodeClone() + { + $dom1 = new DOMDocument; + $dom1->loadXML(<<<'EOT' + bartext'> + '> + + ]> + + +

...

+ + + EOT); + $this->assertIsEqualNode(true, $dom1, clone $dom1); + } + + public function testIsEqualDocumentTypeNode() + { + $dom1 = new DOMDocument; + $dom1->loadXML(<<<'EOT' + bartext'> + '> + + ]> + + +

...

+ + + EOT); + $this->assertIsEqualNode(true, $dom1->doctype, $dom1->doctype); + + $dom2 = new DOMDocument; + $dom2->loadXML(''); + $this->assertIsEqualNode(false, $dom1->doctype, $dom2->doctype); + $dom2->loadXML(''); + $this->assertIsEqualNode(false, $dom1->doctype, $dom2->doctype); + $dom2->loadXML(''); + $this->assertIsEqualNode(false, $dom1->doctype, $dom2->doctype); + $dom2->loadXML(''); + $this->assertIsEqualNode(true, $dom1->doctype, $dom2->doctype); + } + + public function testIsEqualEntityReference() + { + $this->assertIsEqualNode(false, new DOMEntityReference('ref'), new DOMEntityReference('ref2')); + $this->assertIsEqualNode(true, new DOMEntityReference('ref'), new DOMEntityReference('ref')); + } + + public function testIsEqualEntityDeclarationNode() + { + $dom1 = new DOMDocument; + $dom1->loadXML(<<<'EOT' + bartext'> + '> + + ]> + + +

...

+ + + EOT); + + $dom2 = new DOMDocument; + $dom2->loadXML(<<<'EOT' + bartext'> + '> + bartext'> + ]> + + +

...

+ + + EOT); + + $this->assertIsEqualNode(true, $dom1->doctype->entities->getNamedItem('bar'), $dom2->doctype->entities->getNamedItem('bar')); + $this->assertIsEqualNode(false, $dom1->doctype->entities->getNamedItem('bar'), $dom2->doctype->entities->getNamedItem('barbar')); + $this->assertIsEqualNode(false, $dom1->doctype->entities->getNamedItem('bar'), $dom2->doctype->entities->getNamedItem('foo')); + $this->assertIsEqualNode(false, $dom1->doctype->entities->getNamedItem('foo'), $dom2->doctype->entities->getNamedItem('bar')); + $this->assertIsEqualNode(false, $dom1->doctype->entities->getNamedItem('foo'), $dom2->doctype->entities->getNamedItem('barbar')); + $this->assertIsEqualNode(true, $dom1->doctype->entities->getNamedItem('foo'), $dom2->doctype->entities->getNamedItem('foo')); + } + + public function testIsEqualEntityNotationNode() + { + $dom1 = new DOMDocument; + $dom1->loadXML(<<<'EOT' + bartext'> + '> + + ]> + + +

...

+ + + EOT); + + $dom2 = new DOMDocument; + $dom2->loadXML(<<<'EOT' + + + + ]> +

...

+ EOT); + + $this->assertIsEqualNode(true, $dom1->doctype->notations->getNamedItem('myNotation'), $dom2->doctype->notations->getNamedItem('myNotation')); + $this->assertIsEqualNode(false, $dom1->doctype->notations->getNamedItem('myNotation'), $dom2->doctype->notations->getNamedItem('myNotation2')); + $this->assertIsEqualNode(false, $dom1->doctype->notations->getNamedItem('myNotation'), $dom2->doctype->notations->getNamedItem('myNotation3')); + } + + public function testIsEqualDocumentFragmentNode() + { + $xml = ''; + + $dom = new DOMDocument; + $frag1 = $dom->createDocumentFragment(); + $frag2 = $dom->createDocumentFragment(); + $frag3 = $dom->createDocumentFragment(); + $frag4 = $dom->createDocumentFragment(); + + $frag1->appendXML($xml); + $frag2->appendXML($xml); + $frag3->appendXML(''); + $frag4->appendXML(''); + + $this->assertIsEqualNode(true, $frag1, $frag2); + $this->assertIsEqualNode(false, $frag1, $frag3); + $this->assertIsEqualNode(false, $frag1, $frag4); + } +} \ No newline at end of file