From 0f3b1a783dfa36cb23aae0bb954756d0edcd9fc1 Mon Sep 17 00:00:00 2001
From: Olivier Tabone <olivier.tabone@ripplemotion.fr>
Date: Wed, 26 Jul 2023 23:18:29 +0200
Subject: [PATCH] Fixed #34739 -- Added GEOSGeometry.equals_identical() method.

---
 django/contrib/gis/geos/geometry.py           | 12 +++-
 .../contrib/gis/geos/prototypes/__init__.py   |  1 +
 .../contrib/gis/geos/prototypes/predicates.py |  1 +
 docs/ref/contrib/gis/geos.txt                 |  9 +++
 docs/releases/5.0.txt                         |  3 +
 tests/gis_tests/geos_tests/test_geos.py       | 72 ++++++++++++++++++-
 6 files changed, 96 insertions(+), 2 deletions(-)

diff --git a/django/contrib/gis/geos/geometry.py b/django/contrib/gis/geos/geometry.py
index 38e5fe554cb..00b36af0a69 100644
--- a/django/contrib/gis/geos/geometry.py
+++ b/django/contrib/gis/geos/geometry.py
@@ -11,7 +11,7 @@ from django.contrib.gis.geos import prototypes as capi
 from django.contrib.gis.geos.base import GEOSBase
 from django.contrib.gis.geos.coordseq import GEOSCoordSeq
 from django.contrib.gis.geos.error import GEOSException
-from django.contrib.gis.geos.libgeos import GEOM_PTR
+from django.contrib.gis.geos.libgeos import GEOM_PTR, geos_version_tuple
 from django.contrib.gis.geos.mutable_list import ListMixin
 from django.contrib.gis.geos.prepared import PreparedGeometry
 from django.contrib.gis.geos.prototypes.io import ewkb_w, wkb_r, wkb_w, wkt_r, wkt_w
@@ -318,6 +318,16 @@ class GEOSGeometryBase(GEOSBase):
         """
         return capi.geos_equalsexact(self.ptr, other.ptr, float(tolerance))
 
+    def equals_identical(self, other):
+        """
+        Return true if the two Geometries are point-wise equivalent.
+        """
+        if geos_version_tuple() < (3, 12):
+            raise GEOSException(
+                "GEOSGeometry.equals_identical() requires GEOS >= 3.12.0."
+            )
+        return capi.geos_equalsidentical(self.ptr, other.ptr)
+
     def intersects(self, other):
         "Return true if disjoint return false."
         return capi.geos_intersects(self.ptr, other.ptr)
diff --git a/django/contrib/gis/geos/prototypes/__init__.py b/django/contrib/gis/geos/prototypes/__init__.py
index 8fa98f98e75..3e980b9b892 100644
--- a/django/contrib/gis/geos/prototypes/__init__.py
+++ b/django/contrib/gis/geos/prototypes/__init__.py
@@ -51,6 +51,7 @@ from django.contrib.gis.geos.prototypes.predicates import (  # NOQA
     geos_disjoint,
     geos_equals,
     geos_equalsexact,
+    geos_equalsidentical,
     geos_hasz,
     geos_intersects,
     geos_isclosed,
diff --git a/django/contrib/gis/geos/prototypes/predicates.py b/django/contrib/gis/geos/prototypes/predicates.py
index d2e113a734e..32b790173a6 100644
--- a/django/contrib/gis/geos/prototypes/predicates.py
+++ b/django/contrib/gis/geos/prototypes/predicates.py
@@ -38,6 +38,7 @@ geos_equals = BinaryPredicate("GEOSEquals")
 geos_equalsexact = BinaryPredicate(
     "GEOSEqualsExact", argtypes=[GEOM_PTR, GEOM_PTR, c_double]
 )
+geos_equalsidentical = BinaryPredicate("GEOSEqualsIdentical")
 geos_intersects = BinaryPredicate("GEOSIntersects")
 geos_overlaps = BinaryPredicate("GEOSOverlaps")
 geos_relatepattern = BinaryPredicate(
diff --git a/docs/ref/contrib/gis/geos.txt b/docs/ref/contrib/gis/geos.txt
index 564d49bbb6d..471103bf974 100644
--- a/docs/ref/contrib/gis/geos.txt
+++ b/docs/ref/contrib/gis/geos.txt
@@ -483,6 +483,15 @@ return a boolean.
     ``poly1.equals_exact(poly2, 0.001)`` will compare equality to within
     one thousandth of a unit.
 
+.. method:: GEOSGeometry.equals_identical(other)
+
+    .. versionadded:: 5.0
+
+    Returns ``True`` if the two geometries are point-wise equivalent by
+    checking that the structure, ordering, and values of all vertices are
+    identical in all dimensions. ``NaN`` values are considered to be equal to
+    other ``NaN`` values. Requires GEOS 3.12.
+
 .. method:: GEOSGeometry.intersects(other)
 
     Returns ``True`` if :meth:`GEOSGeometry.disjoint` is ``False``.
diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt
index 030e16a054f..8f15171d178 100644
--- a/docs/releases/5.0.txt
+++ b/docs/releases/5.0.txt
@@ -192,6 +192,9 @@ Minor features
 
 * Added support for GEOS 3.12.
 
+* The new :meth:`.GEOSGeometry.equals_identical` method allows point-wise
+  equivalence checking of geometries.
+
 :mod:`django.contrib.messages`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/tests/gis_tests/geos_tests/test_geos.py b/tests/gis_tests/geos_tests/test_geos.py
index 9f1ba6d45f4..0051a88b072 100644
--- a/tests/gis_tests/geos_tests/test_geos.py
+++ b/tests/gis_tests/geos_tests/test_geos.py
@@ -1,11 +1,12 @@
 import ctypes
 import itertools
 import json
+import math
 import pickle
 import random
 from binascii import a2b_hex
 from io import BytesIO
-from unittest import mock
+from unittest import mock, skipIf
 
 from django.contrib.gis import gdal
 from django.contrib.gis.geos import (
@@ -241,6 +242,75 @@ class GEOSTest(SimpleTestCase, TestDataMixin):
         self.assertEqual(p0, "SRID=0;POINT (5 23)")
         self.assertNotEqual(p1, "SRID=0;POINT (5 23)")
 
+    @skipIf(geos_version_tuple() < (3, 12), "GEOS >= 3.12.0 is required")
+    def test_equals_identical(self):
+        tests = [
+            # Empty inputs of different types are not equals_identical.
+            ("POINT EMPTY", "LINESTRING EMPTY", False),
+            # Empty inputs of different dimensions are not equals_identical.
+            ("POINT EMPTY", "POINT Z EMPTY", False),
+            # Non-empty inputs of different dimensions are not equals_identical.
+            ("POINT Z (1 2 3)", "POINT M (1 2 3)", False),
+            ("POINT ZM (1 2 3 4)", "POINT Z (1 2 3)", False),
+            # Inputs with different structure are not equals_identical.
+            ("LINESTRING (1 1, 2 2)", "MULTILINESTRING ((1 1, 2 2))", False),
+            # Inputs with different types are not equals_identical.
+            (
+                "GEOMETRYCOLLECTION (LINESTRING (1 1, 2 2))",
+                "MULTILINESTRING ((1 1, 2 2))",
+                False,
+            ),
+            # Same lines are equals_identical.
+            ("LINESTRING M (1 1 0, 2 2 1)", "LINESTRING M (1 1 0, 2 2 1)", True),
+            # Different lines are not equals_identical.
+            ("LINESTRING M (1 1 0, 2 2 1)", "LINESTRING M (1 1 1, 2 2 1)", False),
+            # Same polygons are equals_identical.
+            ("POLYGON ((0 0, 1 0, 1 1, 0 0))", "POLYGON ((0 0, 1 0, 1 1, 0 0))", True),
+            # Different polygons are not equals_identical.
+            ("POLYGON ((0 0, 1 0, 1 1, 0 0))", "POLYGON ((1 0, 1 1, 0 0, 1 0))", False),
+            # Different polygons (number of holes) are not equals_identical.
+            (
+                "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (1 1, 2 1, 2 2, 1 1))",
+                (
+                    "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (1 1, 2 1, 2 2, 1 1), "
+                    "(3 3, 4 3, 4 4, 3 3))"
+                ),
+                False,
+            ),
+            # Same collections are equals_identical.
+            (
+                "MULTILINESTRING ((1 1, 2 2), (2 2, 3 3))",
+                "MULTILINESTRING ((1 1, 2 2), (2 2, 3 3))",
+                True,
+            ),
+            # Different collections (structure) are not equals_identical.
+            (
+                "MULTILINESTRING ((1 1, 2 2), (2 2, 3 3))",
+                "MULTILINESTRING ((2 2, 3 3), (1 1, 2 2))",
+                False,
+            ),
+        ]
+        for g1, g2, is_equal_identical in tests:
+            with self.subTest(g1=g1, g2=g2):
+                self.assertIs(
+                    fromstr(g1).equals_identical(fromstr(g2)), is_equal_identical
+                )
+
+    @skipIf(geos_version_tuple() < (3, 12), "GEOS >= 3.12.0 is required")
+    def test_infinite_values_equals_identical(self):
+        # Input with identical infinite values are equals_identical.
+        g1 = Point(x=float("nan"), y=math.inf)
+        g2 = Point(x=float("nan"), y=math.inf)
+        self.assertIs(g1.equals_identical(g2), True)
+
+    @mock.patch("django.contrib.gis.geos.libgeos.geos_version", lambda: b"3.11.0")
+    def test_equals_identical_geos_version(self):
+        g1 = fromstr("POINT (1 2 3)")
+        g2 = fromstr("POINT (1 2 3)")
+        msg = "GEOSGeometry.equals_identical() requires GEOS >= 3.12.0"
+        with self.assertRaisesMessage(GEOSException, msg):
+            g1.equals_identical(g2)
+
     def test_points(self):
         "Testing Point objects."
         prev = fromstr("POINT(0 0)")