Repository: googleapis/python-bigquery (python)

Issue:
AccessEntry.from_api_repr != AccessEntry when entity_type = view
AccessEntry.from_api_repr results in an object that is not equal to the same access entry object instantiated directly.

#### Environment details

  - OS type and version: Linux
  - Python version: `python --version`  3.12.8
  - pip version: `pip --version` 24.3.1
  - `google-cloud-bigquery` version: `pip show google-cloud-bigquery` > 3.31.0 (the bug does not exist in versions <= 3.31.0)

#### Steps to reproduce

  1. Instantiate the same access entry using two different methods
  2. Equality test fails

#### Code example

```python
entry1 = bigquery.dataset.AccessEntry(
        entity_type="view",
        entity_id={
            "projectId": "my_project",
            "datasetId": "my_dataset",
            "tableId": "my_table",
        },
    )

entry2 = bigquery.dataset.AccessEntry.from_api_repr(
      {
            "view": {
                "projectId": "my_project",
                "datasetId": "my_dataset",
                "tableId": "my_table",
            }
      }
)

assert entry1 == entry2
```


Pull request:
fix: make AccessEntry equality consistent with from_api_repr
Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly:
- [x] Ensure the tests and linter pass
- [x] Code coverage does not decrease (if any source code was changed)

Fixes #2217 🦕

### 🐛 Bug Fix: AccessEntry equality for "view" entity

This PR fixes a bug where two `AccessEntry` instances representing the same view would not compare as equal:

```python
AccessEntry(entity_type="view", entity_id={"projectId": ..., ...}) != AccessEntry.from_api_repr({...})
```

### ✅ Summary of Changes
- Updated the `__eq__` method in `AccessEntry` to correctly handle `entity_type="view"` by normalizing the nested dictionary in `entity_id`.
- Added a new unit test `test_access_entry_view_equality` in `tests/unit/test_dataset.py` to assert correctness of equality logic for views.
- Ensured compatibility with existing functionality and `from_api_repr` consistency.


Patch:
diff --git a/google/cloud/bigquery/dataset.py b/google/cloud/bigquery/dataset.py
index ec409851..878b77d4 100644
--- a/google/cloud/bigquery/dataset.py
+++ b/google/cloud/bigquery/dataset.py
@@ -17,6 +17,7 @@
 from __future__ import absolute_import
 
 import copy
+import json
 
 import typing
 from typing import Optional, List, Dict, Any, Union
@@ -506,7 +507,20 @@ class AccessEntry(object):
     def __eq__(self, other):
         if not isinstance(other, AccessEntry):
             return NotImplemented
-        return self._key() == other._key()
+        return (
+            self.role == other.role
+            and self.entity_type == other.entity_type
+            and self._normalize_entity_id(self.entity_id)
+            == self._normalize_entity_id(other.entity_id)
+            and self.condition == other.condition
+        )
+
+    @staticmethod
+    def _normalize_entity_id(value):
+        """Ensure consistent equality for dicts like 'view'."""
+        if isinstance(value, dict):
+            return json.dumps(value, sort_keys=True)
+        return value
 
     def __ne__(self, other):
         return not self == other
@@ -557,7 +571,6 @@ class AccessEntry(object):
             google.cloud.bigquery.dataset.AccessEntry:
                 Access entry parsed from ``resource``.
         """
-
         access_entry = cls()
         access_entry._properties = resource.copy()
         return access_entry


Test patch:
diff --git a/tests/unit/test_dataset.py b/tests/unit/test_dataset.py
index 3fd2579a..604e5ed2 100644
--- a/tests/unit/test_dataset.py
+++ b/tests/unit/test_dataset.py
@@ -1767,3 +1767,70 @@ class TestCondition:
             description=None,
         )
         assert hash(cond1) is not None
+
+    def test_access_entry_view_equality(self):
+        from google.cloud import bigquery
+
+        entry1 = bigquery.dataset.AccessEntry(
+            entity_type="view",
+            entity_id={
+                "projectId": "my_project",
+                "datasetId": "my_dataset",
+                "tableId": "my_table",
+            },
+        )
+        entry2 = bigquery.dataset.AccessEntry.from_api_repr(
+            {
+                "view": {
+                    "projectId": "my_project",
+                    "datasetId": "my_dataset",
+                    "tableId": "my_table",
+                }
+            }
+        )
+
+        entry3 = bigquery.dataset.AccessEntry(
+            entity_type="routine",
+            entity_id={
+                "projectId": "my_project",
+                "datasetId": "my_dataset",
+                "routineId": "my_routine",
+            },
+        )
+
+        entry4 = bigquery.dataset.AccessEntry.from_api_repr(
+            {
+                "routine": {
+                    "projectId": "my_project",
+                    "datasetId": "my_dataset",
+                    "routineId": "my_routine",
+                }
+            }
+        )
+
+        entry5 = bigquery.dataset.AccessEntry(
+            entity_type="dataset",
+            entity_id={
+                "dataset": {
+                    "projectId": "my_project",
+                    "datasetId": "my_dataset",
+                },
+                "target_types": "VIEWS",
+            },
+        )
+
+        entry6 = bigquery.dataset.AccessEntry.from_api_repr(
+            {
+                "dataset": {
+                    "dataset": {
+                        "projectId": "my_project",
+                        "datasetId": "my_dataset",
+                    },
+                    "target_types": "VIEWS",
+                }
+            }
+        )
+
+        assert entry1 == entry2
+        assert entry3 == entry4
+        assert entry5 == entry6