diff --git a/NEWS b/NEWS index 6d9df6bc389..6ca2a85f72c 100644 --- a/NEWS +++ b/NEWS @@ -28,6 +28,7 @@ PHP NEWS . Added DOMNode::isConnected and DOMNameSpaceNode::isConnected. (nielsdos) . Added DOMNode::parentElement and DOMNameSpaceNode::parentElement. (nielsdos) + . Added DOMNode::isEqualNode(). (nielsdos) - FPM: . Added warning to log when fpm socket was not registered on the expected diff --git a/UPGRADING b/UPGRADING index d551d68eb3c..18700b6872c 100644 --- a/UPGRADING +++ b/UPGRADING @@ -270,6 +270,7 @@ PHP 8.3 UPGRADE NOTES . Added DOMParentNode::replaceChildren(). . Added DOMNode::isConnected and DOMNameSpaceNode::isConnected. . Added DOMNode::parentElement and DOMNameSpaceNode::parentElement. + . Added DOMNode::isEqualNode(). - JSON: . Added json_validate(), which returns whether the json is valid for diff --git a/ext/dom/node.c b/ext/dom/node.c index c43f83ea8f6..e05e51ce4ba 100644 --- a/ext/dom/node.c +++ b/ext/dom/node.c @@ -1471,6 +1471,155 @@ PHP_METHOD(DOMNode, isSameNode) } /* }}} end dom_node_is_same_node */ +static bool php_dom_node_is_content_equal(const xmlNode *this, const xmlNode *other) +{ + xmlChar *this_content = xmlNodeGetContent(this); + xmlChar *other_content = xmlNodeGetContent(other); + bool result = xmlStrEqual(this_content, other_content); + xmlFree(this_content); + xmlFree(other_content); + return result; +} + +static bool php_dom_node_is_ns_uri_equal(const xmlNode *this, const xmlNode *other) +{ + const xmlChar *this_ns = this->ns ? this->ns->href : NULL; + const xmlChar *other_ns = other->ns ? other->ns->href : NULL; + return xmlStrEqual(this_ns, other_ns); +} + +static bool php_dom_node_is_ns_prefix_equal(const xmlNode *this, const xmlNode *other) +{ + const xmlChar *this_ns = this->ns ? this->ns->prefix : NULL; + const xmlChar *other_ns = other->ns ? other->ns->prefix : NULL; + return xmlStrEqual(this_ns, other_ns); +} + +static bool php_dom_node_is_equal_node(const xmlNode *this, const xmlNode *other); + +#define PHP_DOM_FUNC_CAT(prefix, suffix) prefix##_##suffix +/* xmlNode and xmlNs have incompatible struct layouts, i.e. the next field is in a different offset */ +#define PHP_DOM_DEFINE_LIST_EQUALITY_HELPER(type) \ + static size_t PHP_DOM_FUNC_CAT(php_dom_node_count_list_size, type)(const type *node) \ + { \ + size_t counter = 0; \ + while (node) { \ + counter++; \ + node = node->next; \ + } \ + return counter; \ + } \ + static bool PHP_DOM_FUNC_CAT(php_dom_node_list_equality_check, type)(const type *list1, const type *list2) \ + { \ + size_t count = PHP_DOM_FUNC_CAT(php_dom_node_count_list_size, type)(list1); \ + if (count != PHP_DOM_FUNC_CAT(php_dom_node_count_list_size, type)(list2)) { \ + return false; \ + } \ + for (size_t i = 0; i < count; i++) { \ + if (!php_dom_node_is_equal_node((const xmlNode *) list1, (const xmlNode *) list2)) { \ + return false; \ + } \ + list1 = list1->next; \ + list2 = list2->next; \ + } \ + return true; \ + } +PHP_DOM_DEFINE_LIST_EQUALITY_HELPER(xmlNode) +PHP_DOM_DEFINE_LIST_EQUALITY_HELPER(xmlNs) + +static bool php_dom_node_is_equal_node(const xmlNode *this, const xmlNode *other) +{ + ZEND_ASSERT(this != NULL); + ZEND_ASSERT(other != NULL); + + if (this->type != other->type) { + return false; + } + + /* Notes: + * - XML_DOCUMENT_TYPE_NODE is no longer created by libxml2, we only have to support XML_DTD_NODE. + * - element and attribute declarations are not exposed as nodes in DOM, so no comparison is needed for those. */ + if (this->type == XML_ELEMENT_NODE) { + return xmlStrEqual(this->name, other->name) + && php_dom_node_is_ns_prefix_equal(this, other) + && php_dom_node_is_ns_uri_equal(this, other) + /* Check attributes first, then namespace declarations, then children */ + && php_dom_node_list_equality_check_xmlNode((const xmlNode *) this->properties, (const xmlNode *) other->properties) + && php_dom_node_list_equality_check_xmlNs(this->nsDef, other->nsDef) + && php_dom_node_list_equality_check_xmlNode(this->children, other->children); + } else if (this->type == XML_DTD_NODE) { + /* Note: in the living spec entity declarations and notations are no longer compared because they're considered obsolete. */ + const xmlDtd *this_dtd = (const xmlDtd *) this; + const xmlDtd *other_dtd = (const xmlDtd *) other; + return xmlStrEqual(this_dtd->name, other_dtd->name) + && xmlStrEqual(this_dtd->ExternalID, other_dtd->ExternalID) + && xmlStrEqual(this_dtd->SystemID, other_dtd->SystemID); + } else if (this->type == XML_PI_NODE) { + return xmlStrEqual(this->name, other->name) && xmlStrEqual(this->content, other->content); + } else if (this->type == XML_TEXT_NODE || this->type == XML_COMMENT_NODE || this->type == XML_CDATA_SECTION_NODE) { + return xmlStrEqual(this->content, other->content); + } else if (this->type == XML_ATTRIBUTE_NODE) { + const xmlAttr *this_attr = (const xmlAttr *) this; + const xmlAttr *other_attr = (const xmlAttr *) other; + return xmlStrEqual(this_attr->name, other_attr->name) + && php_dom_node_is_ns_uri_equal(this, other) + && php_dom_node_is_content_equal(this, other); + } else if (this->type == XML_ENTITY_REF_NODE) { + return xmlStrEqual(this->name, other->name); + } else if (this->type == XML_ENTITY_DECL || this->type == XML_NOTATION_NODE || this->type == XML_ENTITY_NODE) { + const xmlEntity *this_entity = (const xmlEntity *) this; + const xmlEntity *other_entity = (const xmlEntity *) other; + return this_entity->etype == other_entity->etype + && xmlStrEqual(this_entity->name, other_entity->name) + && xmlStrEqual(this_entity->ExternalID, other_entity->ExternalID) + && xmlStrEqual(this_entity->SystemID, other_entity->SystemID) + && php_dom_node_is_content_equal(this, other); + } else if (this->type == XML_NAMESPACE_DECL) { + const xmlNs *this_ns = (const xmlNs *) this; + const xmlNs *other_ns = (const xmlNs *) other; + return xmlStrEqual(this_ns->prefix, other_ns->prefix) && xmlStrEqual(this_ns->href, other_ns->href); + } else if (this->type == XML_DOCUMENT_FRAG_NODE || this->type == XML_HTML_DOCUMENT_NODE || this->type == XML_DOCUMENT_NODE) { + return php_dom_node_list_equality_check_xmlNode(this->children, other->children); + } + + return false; +} + +/* {{{ URL: https://dom.spec.whatwg.org/#dom-node-isequalnode (for everything still in the living spec) +* URL: https://www.w3.org/TR/2004/REC-DOM-Level-3-Core-20040407/DOM3-Core.html#core-Node3-isEqualNode (for old nodes removed from the living spec) +Since: DOM Level 3 +*/ +PHP_METHOD(DOMNode, isEqualNode) +{ + zval *id, *node; + xmlNodePtr otherp, nodep; + dom_object *unused_intern; + + id = ZEND_THIS; + if (zend_parse_parameters(ZEND_NUM_ARGS(), "O!", &node, dom_node_class_entry) == FAILURE) { + RETURN_THROWS(); + } + + if (node == NULL) { + RETURN_FALSE; + } + + DOM_GET_THIS_OBJ(nodep, id, xmlNodePtr, unused_intern); + DOM_GET_OBJ(otherp, node, xmlNodePtr, unused_intern); + + if (nodep == otherp) { + RETURN_TRUE; + } + + /* Empty fragments/documents only match if they're both empty */ + if (UNEXPECTED(nodep == NULL || otherp == NULL)) { + RETURN_BOOL(nodep == NULL && otherp == NULL); + } + + RETURN_BOOL(php_dom_node_is_equal_node(nodep, otherp)); +} +/* }}} end DOMNode::isEqualNode */ + /* {{{ URL: http://www.w3.org/TR/2003/WD-DOM-Level-3-Core-20030226/DOM3-Core.html#Node3-lookupNamespacePrefix Since: DOM Level 3 */ diff --git a/ext/dom/php_dom.stub.php b/ext/dom/php_dom.stub.php index b637f211e75..4b73183e17f 100644 --- a/ext/dom/php_dom.stub.php +++ b/ext/dom/php_dom.stub.php @@ -381,6 +381,8 @@ class DOMNode /** @tentative-return-type */ public function isSameNode(DOMNode $otherNode): bool {} + public function isEqualNode(?DOMNode $otherNode): bool {} + /** @tentative-return-type */ public function isSupported(string $feature, string $version): bool {} diff --git a/ext/dom/php_dom_arginfo.h b/ext/dom/php_dom_arginfo.h index dddca2c3394..c2e2d8e0404 100644 --- a/ext/dom/php_dom_arginfo.h +++ b/ext/dom/php_dom_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: 02e8c582a3a7d88fc32ef8931c23c0c6de5a94e2 */ + * Stub hash: 7070b07b2dee16222242b7e516372a6562d87036 */ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_dom_import_simplexml, 0, 1, DOMElement, 0) ZEND_ARG_TYPE_INFO(0, node, IS_OBJECT, 0) @@ -77,6 +77,10 @@ ZEND_BEGIN_ARG_WITH_TENTATIVE_RETURN_TYPE_INFO_EX(arginfo_class_DOMNode_isSameNo ZEND_ARG_OBJ_INFO(0, otherNode, DOMNode, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_DOMNode_isEqualNode, 0, 1, _IS_BOOL, 0) + ZEND_ARG_OBJ_INFO(0, otherNode, DOMNode, 1) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_TENTATIVE_RETURN_TYPE_INFO_EX(arginfo_class_DOMNode_isSupported, 0, 2, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, feature, IS_STRING, 0) ZEND_ARG_TYPE_INFO(0, version, IS_STRING, 0) @@ -525,6 +529,7 @@ ZEND_METHOD(DOMNode, hasChildNodes); ZEND_METHOD(DOMNode, insertBefore); ZEND_METHOD(DOMNode, isDefaultNamespace); ZEND_METHOD(DOMNode, isSameNode); +ZEND_METHOD(DOMNode, isEqualNode); ZEND_METHOD(DOMNode, isSupported); ZEND_METHOD(DOMNode, lookupNamespaceURI); ZEND_METHOD(DOMNode, lookupPrefix); @@ -713,6 +718,7 @@ static const zend_function_entry class_DOMNode_methods[] = { ZEND_ME(DOMNode, insertBefore, arginfo_class_DOMNode_insertBefore, ZEND_ACC_PUBLIC) ZEND_ME(DOMNode, isDefaultNamespace, arginfo_class_DOMNode_isDefaultNamespace, ZEND_ACC_PUBLIC) ZEND_ME(DOMNode, isSameNode, arginfo_class_DOMNode_isSameNode, ZEND_ACC_PUBLIC) + ZEND_ME(DOMNode, isEqualNode, arginfo_class_DOMNode_isEqualNode, ZEND_ACC_PUBLIC) ZEND_ME(DOMNode, isSupported, arginfo_class_DOMNode_isSupported, ZEND_ACC_PUBLIC) ZEND_ME(DOMNode, lookupNamespaceURI, arginfo_class_DOMNode_lookupNamespaceURI, ZEND_ACC_PUBLIC) ZEND_ME(DOMNode, lookupPrefix, arginfo_class_DOMNode_lookupPrefix, ZEND_ACC_PUBLIC) diff --git a/ext/dom/tests/DOMNode_isEqualNode.phpt b/ext/dom/tests/DOMNode_isEqualNode.phpt new file mode 100644 index 00000000000..9e4fd228477 --- /dev/null +++ b/ext/dom/tests/DOMNode_isEqualNode.phpt @@ -0,0 +1,360 @@ +--TEST-- +DOMNode::isEqualNode() +--EXTENSIONS-- +dom +--FILE-- +loadXML(<<bartext'> + '> + +]> + + + +

text

text

other text

+

text

text

text

text

text

text

+ + abcdefabc + + +
+

A

+
+

B

+
+
+

A

+
+ + +XML); + +$xpath = new DOMXPath($dom1); + +function foreach_comparator($query) { + global $xpath; + $container = $xpath->query($query)[0]; + $childNodes = iterator_to_array($container->childNodes); + $firstChild = $childNodes[0]; + foreach ($childNodes as $child) { + var_dump($child->isEqualNode($firstChild)); + } +} + +function comparePairs($list1, $list2) { + $list1 = iterator_to_array($list1); + $list2 = iterator_to_array($list2); + usort($list1, function ($a, $b) { + return strcmp($a->nodeName, $b->nodeName); + }); + usort($list2, function ($a, $b) { + return strcmp($a->nodeName, $b->nodeName); + }); + foreach ($list1 as $entity1) { + foreach ($list2 as $entity2) { + echo "Comparing {$entity1->nodeName} with {$entity2->nodeName}\n"; + var_dump($entity1->isEqualNode($entity2)); + } + } +} + +echo "--- Test edge cases ---\n"; + +var_dump($dom1->doctype->isEqualNode(null)); +var_dump((new DOMDocument())->isEqualNode(new DOMDocument())); + +echo "--- Test doctype ---\n"; + +var_dump($dom1->doctype->isEqualNode($dom1->doctype)); +$dom2->loadXML(''); +var_dump($dom1->doctype->isEqualNode($dom2->doctype)); +$dom2->loadXML(''); +var_dump($dom1->doctype->isEqualNode($dom2->doctype)); +$dom2->loadXML(''); +var_dump($dom1->doctype->isEqualNode($dom2->doctype)); +$dom2->loadXML(''); +var_dump($dom1->doctype->isEqualNode($dom2->doctype)); + +echo "--- Test processing instruction ---\n"; + +foreach_comparator("//pi"); + +echo "--- Test comments ---\n"; + +foreach_comparator("//comments"); + +echo "--- Test texts ---\n"; + +foreach_comparator("//texts"); + +echo "--- Test CDATA ---\n"; + +foreach_comparator("//cdatas"); + +echo "--- Test attribute ---\n"; + +var_dump((new DOMAttr("name", "value"))->isEqualNode(new DOMAttr("name", "value"))); +var_dump((new DOMAttr("name", "value"))->isEqualNode(new DOMAttr("name", "value2"))); +var_dump((new DOMAttr("name", "value"))->isEqualNode(new DOMAttr("name2", "value"))); +var_dump((new DOMAttr("name", "value"))->isEqualNode(new DOMAttr("name2", "value2"))); +var_dump((new DOMAttr("name", "value"))->isEqualNode(new DOMAttr("ns:name", "value"))); + +echo "--- Test entity reference ---\n"; + +var_dump((new DOMEntityReference("ref"))->isEqualNode(new DOMEntityReference("ref"))); +var_dump((new DOMEntityReference("ref"))->isEqualNode(new DOMEntityReference("ref2"))); + +echo "--- Test entity declaration ---\n"; + +$dom2->loadXML(<<bartext'> + '> + bartext'> +]> + +XML); + +comparePairs($dom1->doctype->entities, $dom2->doctype->entities); + +echo "--- Test notation declaration ---\n"; + +$dom2->loadXML(<< + + +]> + +XML); + +comparePairs($dom1->doctype->notations, $dom2->doctype->notations); + +echo "--- Test element without attributes ---\n"; + +foreach_comparator("//ps"); + +echo "--- Test element with attributes ---\n"; + +foreach_comparator("//psattrs"); + +echo "--- Test element tree ---\n"; + +$tree = $xpath->query("//tree")[0]; +$dom2->loadXML(<< +
+

A

+
+

B

+
+
+

A

+ +XML); +var_dump($tree->isEqualNode($dom2->documentElement)); +$dom2->loadXML(<< +

A

+
+

A

+
+

B

+
+
+ +XML); +var_dump($tree->isEqualNode($dom2->documentElement)); +$dom2->loadXML(<< +
+

A

+
+
+
+

A

+ +XML); +var_dump($tree->isEqualNode($dom2->documentElement)); +$dom2->loadXML(<< +
+

A

+
+

B

+
+
+

A

+ +XML); +var_dump($tree->isEqualNode($dom2->documentElement)); + +echo "--- Test documents ---\n"; + +$dom1Clone = clone $dom1; +var_dump($dom1->documentElement->isEqualNode($dom1Clone->documentElement)); +var_dump($dom1->documentElement->isEqualNode($dom2->documentElement)); +var_dump($dom1->isEqualNode($dom1Clone)); +var_dump($dom1->isEqualNode($dom2)); +var_dump($dom1->documentElement->isEqualNode($dom1Clone)); +var_dump($dom1->documentElement->isEqualNode($dom2)); + +echo "--- Test document fragments ---\n"; + +$fragment1 = $dom1->createDocumentFragment(); +$fragment1->appendChild($dom1->createElement('em')); +$fragment2 = $dom1->createDocumentFragment(); +$fragment2->appendChild($dom1->createElement('em')); +$fragment3 = $dom1->createDocumentFragment(); +$fragment3->appendChild($dom1->createElement('b')); +$emptyFragment1 = $dom1->createDocumentFragment(); +$emptyFragment2 = $dom1->createDocumentFragment(); + +var_dump($fragment1->isEqualNode($fragment2)); +var_dump($fragment1->isEqualNode($fragment3)); +var_dump($emptyFragment1->isEqualNode($fragment1)); +var_dump($emptyFragment1->isEqualNode($emptyFragment2)); + +echo "--- Test document fragments with multiple child nodes ---\n"; + +$fragment1 = $dom1->createDocumentFragment(); +$fragment1->appendChild($dom1->createElement('a')); +$fragment1->appendChild($dom1->createElement('b')); +$fragment1->appendChild($dom1->createElement('c')); +$fragment2 = $dom2->createDocumentFragment(); +$fragment2->appendChild($dom2->createElement('a')); +$fragment2->appendChild($dom2->createElement('b')); +$fragment2->appendChild($dom2->createElement('c')); + +var_dump($fragment1->isEqualNode($fragment2)); + +$fragment2->firstChild->nextSibling->nextSibling->remove(); +var_dump($fragment1->isEqualNode($fragment2)); + +echo "--- Test x:includes ---\n"; + +// Adapted from https://www.php.net/manual/en/domdocument.xinclude.php +$dom = new DOMDocument(); +$dom->loadXML(<< + +

Hello

+ + + +

xinclude: book.xml not found

+
+
+
+

xinclude: book.xml not found

+
+XML); +@$dom->xinclude(); +$xpath = new DOMXPath($dom); + +$firstPara = $dom->documentElement->firstElementChild->nextElementSibling; +$secondPara = $dom->documentElement->firstElementChild->nextElementSibling->nextElementSibling; +var_dump($firstPara->isEqualNode($secondPara)); +var_dump($firstPara->firstElementChild->isEqualNode($secondPara->firstElementChild)); + +?> +--EXPECT-- +--- Test edge cases --- +bool(false) +bool(true) +--- Test doctype --- +bool(true) +bool(false) +bool(false) +bool(false) +bool(true) +--- Test processing instruction --- +bool(true) +bool(false) +bool(false) +bool(true) +--- Test comments --- +bool(true) +bool(false) +bool(true) +--- Test texts --- +bool(true) +bool(false) +bool(false) +bool(false) +bool(true) +--- Test CDATA --- +bool(true) +bool(false) +bool(false) +bool(false) +bool(true) +--- Test attribute --- +bool(true) +bool(false) +bool(false) +bool(false) +bool(false) +--- Test entity reference --- +bool(true) +bool(false) +--- Test entity declaration --- +Comparing bar with bar +bool(true) +Comparing bar with barbar +bool(false) +Comparing bar with foo +bool(false) +Comparing foo with bar +bool(false) +Comparing foo with barbar +bool(false) +Comparing foo with foo +bool(true) +--- Test notation declaration --- +Comparing myNotation with myNotation +bool(true) +Comparing myNotation with myNotation2 +bool(false) +Comparing myNotation with myNotation3 +bool(false) +--- Test element without attributes --- +bool(true) +bool(true) +bool(false) +--- Test element with attributes --- +bool(true) +bool(false) +bool(false) +bool(false) +bool(false) +bool(true) +--- Test element tree --- +bool(true) +bool(false) +bool(false) +bool(false) +--- Test documents --- +bool(true) +bool(false) +bool(true) +bool(false) +bool(false) +bool(false) +--- Test document fragments --- +bool(true) +bool(false) +bool(false) +bool(true) +--- Test document fragments with multiple child nodes --- +bool(true) +bool(false) +--- Test x:includes --- +bool(false) +bool(true)