From fd8b9db89c7f8d54a621ab68cfb84c3a46e33bd8 Mon Sep 17 00:00:00 2001
From: Davide Silvestri <davide@baserow.io>
Date: Tue, 18 Mar 2025 20:32:47 +0100
Subject: [PATCH] Resolve "Relationship change of the reversed relationship
 should be visible in row history"

---
 .../contrib/database/fields/field_types.py    |   5 +-
 .../contrib/database/fields/registries.py     |   4 +-
 .../baserow/contrib/database/rows/history.py  | 262 ++++++++--
 .../src/baserow/test_utils/fixtures/table.py  |   3 +
 .../database/api/rows/test_row_views.py       |   3 +
 .../field/test_link_row_field_type.py         |   6 +
 .../contrib/database/rows/test_row_history.py | 474 +++++++++++++++++-
 ...ionship_changes_in_row_change_history.json |   8 +
 8 files changed, 709 insertions(+), 56 deletions(-)
 create mode 100644 changelog/entries/unreleased/feature/3264_show_relationship_changes_in_row_change_history.json

diff --git a/backend/src/baserow/contrib/database/fields/field_types.py b/backend/src/baserow/contrib/database/fields/field_types.py
index fbf893c95..b0163bbe3 100755
--- a/backend/src/baserow/contrib/database/fields/field_types.py
+++ b/backend/src/baserow/contrib/database/fields/field_types.py
@@ -3456,6 +3456,9 @@ class LinkRowFieldType(
                 **already_serialized_linked_rows,
                 **new_serialized_linked_rows,
             },
+            "linked_table_id": field.link_row_table_id,
+            "linked_field_id": field.link_row_related_field_id,
+            "primary_value": str(row),
         }
 
     def are_row_values_equal(self, value1: any, value2: any) -> bool:
@@ -6839,7 +6842,7 @@ class PasswordFieldType(FieldType):
         # `False` as string depending on whether the value is set.
         return bool(value)
 
-    def prepare_row_history_value_from_action_meta_data(self, value):
+    def prepare_value_for_row_history(self, value):
         # We don't want to expose the hash of the password, so we just show `True` or
         # `False` as string depending on whether the value is set.
         return bool(value)
diff --git a/backend/src/baserow/contrib/database/fields/registries.py b/backend/src/baserow/contrib/database/fields/registries.py
index 986c8e829..ea0e30b0b 100644
--- a/backend/src/baserow/contrib/database/fields/registries.py
+++ b/backend/src/baserow/contrib/database/fields/registries.py
@@ -284,9 +284,9 @@ class FieldType(
 
         return getattr(row, field_name)
 
-    def prepare_row_history_value_from_action_meta_data(self, value):
+    def prepare_value_for_row_history(self, value):
         """
-        Prepare the row action update action meta data value for the row history.
+        Prepare the value for the row history.
         This can be used to change the value to a different format if needed. It's
         for example used by the password field to mask the hash.
         """
diff --git a/backend/src/baserow/contrib/database/rows/history.py b/backend/src/baserow/contrib/database/rows/history.py
index 34ff4318f..60b91854f 100644
--- a/backend/src/baserow/contrib/database/rows/history.py
+++ b/backend/src/baserow/contrib/database/rows/history.py
@@ -1,4 +1,7 @@
+from collections import defaultdict
+from dataclasses import dataclass
 from datetime import datetime
+from itertools import groupby
 from typing import Any, Dict, List, NamedTuple, NewType, Optional
 
 from django.conf import settings
@@ -21,6 +24,19 @@ tracer = trace.get_tracer(__name__)
 
 FieldName = NewType("FieldName", str)
 
+# Dict of table_id -> row_id -> field_name ->
+# {added: List[row_id], removed:List[row_id], metadata: Dict}
+RelatedRowsDiff = Dict[int, Dict[int, Dict[str, Dict[str, Any]]]]
+
+
+@dataclass
+class ActionData:
+    uuid: str
+    type: str
+    timestamp: datetime
+    command_type: ActionCommandType
+    params: Dict[str, Any]
+
 
 class RowChangeDiff(NamedTuple):
     """
@@ -29,6 +45,8 @@ class RowChangeDiff(NamedTuple):
     and after values of those fields.
     """
 
+    row_id: int
+    table_id: int
     changed_field_names: List[FieldName]
     before_values: Dict[FieldName, Any]
     after_values: Dict[FieldName, Any]
@@ -38,38 +56,34 @@ class RowHistoryHandler:
     @classmethod
     def _construct_entry_from_action_and_diff(
         cls,
-        user,
-        table_id,
-        row_id,
-        field_names,
-        row_fields_metadata,
-        action_type,
-        action_uuid,
-        action_timestamp,
-        action_command_type,
-        diff,
+        user: AbstractBaseUser,
+        action: ActionData,
+        fields_metadata: Dict[str, Any],
+        row_diff: RowChangeDiff,
     ):
         return RowHistory(
             user_id=user.id,
             user_name=user.first_name,
-            table_id=table_id,
-            row_id=row_id,
-            field_names=field_names,
-            fields_metadata=row_fields_metadata,
-            action_uuid=action_uuid,
-            action_command_type=action_command_type.value,
-            action_timestamp=action_timestamp,
-            action_type=action_type,
-            before_values=diff.before_values,
-            after_values=diff.after_values,
+            table_id=row_diff.table_id,
+            row_id=row_diff.row_id,
+            field_names=row_diff.changed_field_names,
+            fields_metadata=fields_metadata,
+            action_uuid=action.uuid,
+            action_command_type=action.command_type.value,
+            action_timestamp=action.timestamp,
+            action_type=action.type,
+            before_values=row_diff.before_values,
+            after_values=row_diff.after_values,
         )
 
     @classmethod
     def _extract_row_diff(
         cls,
+        table_id: int,
+        row_id: int,
+        fields_metadata: Dict[str, Any],
         before_values: Dict[str, Any],
         after_values: Dict[str, Any],
-        fields_metadata,
     ) -> Optional[RowChangeDiff]:
         """
         Extracts the fields that have changed between the before and after values of a
@@ -94,18 +108,20 @@ class RowHistoryHandler:
         before_fields = {
             k: field_type_registry.get(
                 fields_metadata[k]["type"]
-            ).prepare_row_history_value_from_action_meta_data(v)
+            ).prepare_value_for_row_history(v)
             for k, v in before_values.items()
             if k in changed_fields
         }
         after_fields = {
             k: field_type_registry.get(
                 fields_metadata[k]["type"]
-            ).prepare_row_history_value_from_action_meta_data(v)
+            ).prepare_value_for_row_history(v)
             for k, v in after_values.items()
             if k in changed_fields
         }
-        return RowChangeDiff(list(changed_fields), before_fields, after_fields)
+        return RowChangeDiff(
+            row_id, table_id, list(changed_fields), before_fields, after_fields
+        )
 
     @classmethod
     def _raise_if_ids_mismatch(cls, before_values, after_values, fields_metadata):
@@ -120,62 +136,205 @@ class RowHistoryHandler:
                 "are the same. This should never happen."
             )
 
+    @classmethod
+    def _update_related_tables_entries(
+        cls,
+        related_rows_diff: RelatedRowsDiff,
+        fields_metadata: Dict[str, Any],
+        row_diff: RowChangeDiff,
+    ) -> RelatedRowsDiff:
+        """
+        Updates the record of changes in related tables when link_row fields are
+        modified.
+
+        When a row's link_row field is updated (adding or removing connections to rows
+        in another table), this method tracks those changes from the perspective of the
+        rows in the related table, so that history can be properly displayed for both
+        sides of the relationship.
+
+        The method updates related_rows_diff in-place, maintaining a record of which
+        rows were added or removed from each link relationship.
+
+        :param related_rows_diff: Nested dictionary tracking changes for each affected
+            related row
+        :param fields_metadata: Metadata about the fields that were changed in
+            this update
+        :param row_diff: The changes made to the current row, including before/after
+            values
+        :return: The updated related_rows_diff dictionary
+        """
+
+        def _init_linked_row_diff(linked_field_id):
+            return {
+                "added": [],
+                "removed": [],
+                "metadata": {
+                    "id": linked_field_id,
+                    "type": "link_row",
+                    "linked_rows": {},
+                },
+            }
+
+        def _update_linked_row_diff(
+            field_metadata: Dict[str, Any], row_ids_set: set[int], key: str
+        ):
+            linked_table_id = field_metadata["linked_table_id"]
+            linked_field_id = field_metadata["linked_field_id"]
+            linked_field_name = f"field_{linked_field_id}"
+
+            for linked_row_id in row_ids_set:
+                linked_diff = related_rows_diff[linked_table_id][linked_row_id][
+                    linked_field_name
+                ]
+                if not linked_diff:
+                    linked_diff = _init_linked_row_diff(linked_field_id)
+                    related_rows_diff[linked_table_id][linked_row_id][
+                        linked_field_name
+                    ] = linked_diff
+                linked_diff[key].append(row_id)
+                linked_diff["metadata"]["linked_rows"][row_id] = {
+                    "value": field_metadata["primary_value"]
+                }
+
+        row_id = row_diff.row_id
+        for field_name in row_diff.changed_field_names:
+            field_metadata = fields_metadata[field_name]
+
+            # Ignore fields that are not link_row fields or that doesn't have a related
+            # field in the linked table.
+            if (
+                field_metadata["type"] != "link_row"
+                or not field_metadata["linked_field_id"]
+            ):
+                continue
+
+            after_set = set(row_diff.after_values[field_name])
+            before_set = set(row_diff.before_values[field_name])
+
+            row_ids_added = after_set - before_set
+            _update_linked_row_diff(field_metadata, row_ids_added, "added")
+
+            row_ids_removed = before_set - after_set
+            _update_linked_row_diff(field_metadata, row_ids_removed, "removed")
+
+        return related_rows_diff
+
+    @classmethod
+    def _construct_related_rows_entries(
+        cls,
+        related_rows_diff: RelatedRowsDiff,
+        user: AbstractBaseUser,
+        action: ActionData,
+    ) -> List[RowHistory]:
+        """
+        Creates RowHistory entries for rows in related tables that were affected by
+        changes to the current row. Specifically, when a link_row field is updated,
+        this method ensures that the changes are also tracked from the perspective of
+        the related rows.
+
+        :param related_rows_diff: A nested dictionary that tracks changes for each
+            affected related row. It includes details about rows added or removed
+            from link_row relationships.
+        :param user: The user who performed the action that triggered the changes.
+        :param action: The action metadata that describes the operation performed.
+        :return: A list of RowHistory entries representing the changes for the
+            related rows.
+        """
+
+        entries = []
+        for linked_table_id, table_changes in related_rows_diff.items():
+            for linked_row_id, row_changes in table_changes.items():
+                field_names = list(row_changes.keys())
+                fields_metadata, before_values, after_values = {}, {}, {}
+
+                for field_name in field_names:
+                    row_field_changes = row_changes[field_name]
+                    fields_metadata[field_name] = row_field_changes["metadata"]
+                    before_values[field_name] = row_field_changes["removed"]
+                    after_values[field_name] = row_field_changes["added"]
+
+                linked_entry = RowHistory(
+                    user_id=user.id,
+                    user_name=user.first_name,
+                    table_id=linked_table_id,
+                    row_id=linked_row_id,
+                    field_names=field_names,
+                    fields_metadata=fields_metadata,
+                    action_uuid=action.uuid,
+                    action_command_type=action.command_type.value,
+                    action_timestamp=action.timestamp,
+                    action_type=action.type,
+                    before_values=before_values,
+                    after_values=after_values,
+                )
+                entries.append(linked_entry)
+        return entries
+
     @classmethod
     @baserow_trace(tracer)
     def record_history_from_update_rows_action(
         cls,
         user: AbstractBaseUser,
-        action_uuid: str,
-        action_params: Dict[str, Any],
-        action_timestamp: datetime,
-        action_command_type: ActionCommandType,
+        action: ActionData,
     ):
-        params = UpdateRowsActionType.serialized_to_params(action_params)
+        params = UpdateRowsActionType.serialized_to_params(action.params)
+        table_id = params.table_id
         after_values = params.row_values
         before_values = [
             params.original_rows_values_by_id[r["id"]] for r in after_values
         ]
 
-        if action_command_type == ActionCommandType.UNDO:
+        if action.command_type == ActionCommandType.UNDO:
             before_values, after_values = after_values, before_values
 
         row_history_entries = []
+        related_rows_diff: RelatedRowsDiff = defaultdict(
+            lambda: defaultdict(lambda: defaultdict(dict))
+        )
         for i, after in enumerate(after_values):
             before = before_values[i]
             fields_metadata = params.updated_fields_metadata_by_row_id[after["id"]]
             cls._raise_if_ids_mismatch(before, after, fields_metadata)
 
-            diff = cls._extract_row_diff(before, after, fields_metadata)
-            if diff is None:
+            row_id = after["id"]
+            row_diff = cls._extract_row_diff(
+                table_id, row_id, fields_metadata, before, after
+            )
+            if row_diff is None:
                 continue
 
             changed_fields_metadata = {
                 k: v
                 for k, v in fields_metadata.items()
-                if k in diff.changed_field_names
+                if k in row_diff.changed_field_names
             }
-            row_id = after["id"]
+
             entry = cls._construct_entry_from_action_and_diff(
                 user,
-                params.table_id,
-                row_id,
-                diff.changed_field_names,
+                action,
                 changed_fields_metadata,
-                UpdateRowsActionType.type,
-                action_uuid,
-                action_timestamp,
-                action_command_type,
-                diff,
+                row_diff,
             )
             row_history_entries.append(entry)
+            cls._update_related_tables_entries(
+                related_rows_diff, changed_fields_metadata, row_diff
+            )
+
+        related_entries = cls._construct_related_rows_entries(
+            related_rows_diff, user, action
+        )
+        row_history_entries.extend(related_entries)
 
         if row_history_entries:
             row_history_entries = RowHistory.objects.bulk_create(row_history_entries)
-            rows_history_updated.send(
-                RowHistoryHandler,
-                table_id=params.table_id,
-                row_history_entries=row_history_entries,
-            )
+            for table_id, per_table_row_history_entries in groupby(
+                row_history_entries, lambda e: e.table_id
+            ):
+                rows_history_updated.send(
+                    RowHistoryHandler,
+                    table_id=table_id,
+                    row_history_entries=list(per_table_row_history_entries),
+                )
 
     @classmethod
     @baserow_trace(tracer)
@@ -233,5 +392,12 @@ def on_action_done_update_row_history(
     if action_type and action_type.type in ROW_HISTORY_ACTIONS:
         add_entry_handler = ROW_HISTORY_ACTIONS[action_type.type]
         add_entry_handler(
-            user, action_uuid, action_params, action_timestamp, action_command_type
+            user,
+            ActionData(
+                action_uuid,
+                action_type.type,
+                action_timestamp,
+                action_command_type,
+                action_params,
+            ),
         )
diff --git a/backend/src/baserow/test_utils/fixtures/table.py b/backend/src/baserow/test_utils/fixtures/table.py
index 8d615713f..65e56c830 100644
--- a/backend/src/baserow/test_utils/fixtures/table.py
+++ b/backend/src/baserow/test_utils/fixtures/table.py
@@ -101,12 +101,15 @@ class TableFixtures:
         if not table_b.field_set.filter(primary=True).exists():
             self.create_text_field(table=table_b, name="primary", primary=True)
 
+        has_related_field = kwargs.pop("has_related_field", True)
+
         link_field = FieldHandler().create_field(
             user=user,
             table=table_a,
             type_name="link_row",
             name="link",
             link_row_table=table_b,
+            has_related_field=has_related_field,
         )
 
         return table_a, table_b, link_field
diff --git a/backend/tests/baserow/contrib/database/api/rows/test_row_views.py b/backend/tests/baserow/contrib/database/api/rows/test_row_views.py
index 71897f9b7..9f8a779e4 100644
--- a/backend/tests/baserow/contrib/database/api/rows/test_row_views.py
+++ b/backend/tests/baserow/contrib/database/api/rows/test_row_views.py
@@ -3960,6 +3960,9 @@ def test_list_row_history_for_different_fields(data_fixture, api_client):
                                 "value": f"unnamed row {table2_row2.id}"
                             },
                         },
+                        "linked_table_id": linkrow_field.link_row_table_id,
+                        "linked_field_id": linkrow_field.link_row_related_field_id,
+                        "primary_value": "unnamed row 1",
                     },
                 },
             },
diff --git a/backend/tests/baserow/contrib/database/field/test_link_row_field_type.py b/backend/tests/baserow/contrib/database/field/test_link_row_field_type.py
index 34c5a28d5..e0cdacdf4 100644
--- a/backend/tests/baserow/contrib/database/field/test_link_row_field_type.py
+++ b/backend/tests/baserow/contrib/database/field/test_link_row_field_type.py
@@ -2067,6 +2067,9 @@ def test_link_row_serialize_metadata_for_row_history(
                 table2_row3.id: {"value": f"unnamed row {table2_row3.id}"},
             },
             "type": "link_row",
+            "linked_table_id": field.link_row_table_id,
+            "linked_field_id": field.link_row_related_field_id,
+            "primary_value": "unnamed row 1",
         }
 
     # empty values
@@ -2085,6 +2088,9 @@ def test_link_row_serialize_metadata_for_row_history(
             "id": AnyInt(),
             "linked_rows": {},
             "type": "link_row",
+            "linked_table_id": field.link_row_table_id,
+            "linked_field_id": field.link_row_related_field_id,
+            "primary_value": "unnamed row 2",
         }
 
 
diff --git a/backend/tests/baserow/contrib/database/rows/test_row_history.py b/backend/tests/baserow/contrib/database/rows/test_row_history.py
index c8b03dd83..f823d4d1f 100644
--- a/backend/tests/baserow/contrib/database/rows/test_row_history.py
+++ b/backend/tests/baserow/contrib/database/rows/test_row_history.py
@@ -1,4 +1,5 @@
 from datetime import datetime, timezone
+from unittest.mock import patch
 
 import pytest
 from freezegun import freeze_time
@@ -28,8 +29,8 @@ def test_update_rows_insert_multiple_entries_in_row_history(data_fixture):
 
     row_handler = RowHandler()
 
-    row_one = row_handler.create_row(user, table, {name_field.id: "Original 1"})
-    row_two = row_handler.create_row(user, table, {name_field.id: "Original 2"})
+    row_one = row_handler.force_create_row(user, table, {name_field.id: "Original 1"})
+    row_two = row_handler.force_create_row(user, table, {name_field.id: "Original 2"})
 
     with freeze_time("2021-01-01 12:00"):
         action_type_registry.get_by_type(UpdateRowsActionType).do(
@@ -107,7 +108,7 @@ def test_history_handler_only_save_changed_fields(data_fixture):
 
     row_handler = RowHandler()
 
-    row = row_handler.create_row(user, table, {name_field.id: "Original 1"})
+    row = row_handler.force_create_row(user, table, {name_field.id: "Original 1"})
 
     with freeze_time("2021-01-01 12:00"):
         action_type_registry.get_by_type(UpdateRowsActionType).do(
@@ -172,8 +173,8 @@ def test_update_rows_action_doesnt_insert_entries_if_row_doesnt_change(data_fixt
 
     row_handler = RowHandler()
 
-    row_one = row_handler.create_row(user, table, {name_field.id: "Original 1"})
-    row_two = row_handler.create_row(user, table, {name_field.id: "Original 2"})
+    row_one = row_handler.force_create_row(user, table, {name_field.id: "Original 1"})
+    row_two = row_handler.force_create_row(user, table, {name_field.id: "Original 2"})
 
     with freeze_time("2021-01-01 12:00"):
         action_type_registry.get_by_type(UpdateRowsActionType).do(
@@ -362,3 +363,466 @@ def test_row_history_not_recorded_with_retention_zero_days(settings, data_fixtur
     )
 
     assert RowHistory.objects.count() == 1
+
+
+@pytest.mark.django_db
+@pytest.mark.row_history
+def test_update_rows_insert_entries_in_linked_rows_history(data_fixture):
+    user = data_fixture.create_user()
+    database = data_fixture.create_database_application(user=user)
+    table_a, table_b, link_a_to_b = data_fixture.create_two_linked_tables(
+        user=user, database=database
+    )
+    primary_a = table_a.get_primary_field()
+    primary_b = table_b.get_primary_field()
+    link_b_to_a = link_a_to_b.link_row_related_field
+
+    row_handler = RowHandler()
+
+    row_b1, row_b2 = row_handler.force_create_rows(
+        user, table_b, [{primary_b.db_column: "b1"}, {primary_b.db_column: "b2"}]
+    )
+    row_a1 = row_handler.force_create_row(user, table_a, {primary_a.id: "a1"})
+
+    with freeze_time("2021-01-01 12:00"):
+        action_type_registry.get_by_type(UpdateRowsActionType).do(
+            user,
+            table_a,
+            [
+                {"id": row_a1.id, link_a_to_b.db_column: [row_b1.id, row_b2.id]},
+            ],
+        )
+    assert RowHistory.objects.count() == 3
+
+    history_entries = RowHistory.objects.order_by("table_id", "row_id").values(
+        "user_id",
+        "user_name",
+        "table_id",
+        "row_id",
+        "action_timestamp",
+        "action_type",
+        "before_values",
+        "after_values",
+        "fields_metadata",
+    )
+
+    expected_entries = [
+        {
+            "user_id": user.id,
+            "user_name": user.first_name,
+            "table_id": table_a.id,
+            "row_id": 1,
+            "action_timestamp": datetime(2021, 1, 1, 12, 0, tzinfo=timezone.utc),
+            "action_type": "update_rows",
+            "before_values": {link_a_to_b.db_column: []},
+            "after_values": {link_a_to_b.db_column: [1, 2]},
+            "fields_metadata": {
+                link_a_to_b.db_column: {
+                    "id": link_a_to_b.id,
+                    "type": "link_row",
+                    "linked_rows": {"1": {"value": "b1"}, "2": {"value": "b2"}},
+                    "primary_value": "a1",
+                    "linked_field_id": link_b_to_a.id,
+                    "linked_table_id": table_b.id,
+                }
+            },
+        },
+        {
+            "user_id": user.id,
+            "user_name": user.first_name,
+            "table_id": table_b.id,
+            "row_id": 1,
+            "action_timestamp": datetime(2021, 1, 1, 12, 0, tzinfo=timezone.utc),
+            "action_type": "update_rows",
+            "before_values": {link_b_to_a.db_column: []},
+            "after_values": {link_b_to_a.db_column: [1]},
+            "fields_metadata": {
+                link_b_to_a.db_column: {
+                    "id": link_b_to_a.id,
+                    "type": "link_row",
+                    "linked_rows": {"1": {"value": "a1"}},
+                }
+            },
+        },
+        {
+            "user_id": user.id,
+            "user_name": user.first_name,
+            "table_id": table_b.id,
+            "row_id": 2,
+            "action_timestamp": datetime(2021, 1, 1, 12, 0, tzinfo=timezone.utc),
+            "action_type": "update_rows",
+            "before_values": {link_b_to_a.db_column: []},
+            "after_values": {link_b_to_a.db_column: [1]},
+            "fields_metadata": {
+                link_b_to_a.db_column: {
+                    "id": link_b_to_a.id,
+                    "type": "link_row",
+                    "linked_rows": {"1": {"value": "a1"}},
+                }
+            },
+        },
+    ]
+
+    assert list(history_entries) == expected_entries
+
+    # Now remove one link
+
+    with freeze_time("2021-01-01 12:30"):
+        action_type_registry.get_by_type(UpdateRowsActionType).do(
+            user,
+            table_a,
+            [
+                {"id": row_a1.id, link_a_to_b.db_column: [row_b2.id]},
+            ],
+        )
+
+    history_entries = RowHistory.objects.order_by(
+        "-action_timestamp", "table_id", "row_id"
+    ).values(
+        "user_id",
+        "user_name",
+        "table_id",
+        "row_id",
+        "action_timestamp",
+        "action_type",
+        "before_values",
+        "after_values",
+        "fields_metadata",
+    )
+    assert RowHistory.objects.count() == 5
+
+    last_entries = list(history_entries)[:2]
+    expected_entries = [
+        {
+            "user_id": user.id,
+            "user_name": user.first_name,
+            "table_id": table_a.id,
+            "row_id": 1,
+            "action_timestamp": datetime(2021, 1, 1, 12, 30, tzinfo=timezone.utc),
+            "action_type": "update_rows",
+            "before_values": {link_a_to_b.db_column: [1, 2]},
+            "after_values": {link_a_to_b.db_column: [2]},
+            "fields_metadata": {
+                link_a_to_b.db_column: {
+                    "id": link_a_to_b.id,
+                    "type": "link_row",
+                    "linked_rows": {"1": {"value": "b1"}, "2": {"value": "b2"}},
+                    "primary_value": "a1",
+                    "linked_field_id": link_b_to_a.id,
+                    "linked_table_id": table_b.id,
+                }
+            },
+        },
+        {
+            "user_id": user.id,
+            "user_name": user.first_name,
+            "table_id": table_b.id,
+            "row_id": 1,
+            "action_timestamp": datetime(2021, 1, 1, 12, 30, tzinfo=timezone.utc),
+            "action_type": "update_rows",
+            "before_values": {link_b_to_a.db_column: [1]},
+            "after_values": {link_b_to_a.db_column: []},
+            "fields_metadata": {
+                link_b_to_a.db_column: {
+                    "id": link_b_to_a.id,
+                    "type": "link_row",
+                    "linked_rows": {"1": {"value": "a1"}},
+                }
+            },
+        },
+    ]
+
+    assert last_entries == expected_entries
+
+
+@pytest.mark.django_db
+@pytest.mark.row_history
+def test_update_rows_dont_insert_entries_in_linked_rows_history_without_related_field(
+    data_fixture,
+):
+    user = data_fixture.create_user()
+    database = data_fixture.create_database_application(user=user)
+    table_a, table_b, link_a_to_b = data_fixture.create_two_linked_tables(
+        user=user, database=database, has_related_field=False
+    )
+    primary_a = table_a.get_primary_field()
+    primary_b = table_b.get_primary_field()
+
+    row_handler = RowHandler()
+
+    row_b1, row_b2 = row_handler.force_create_rows(
+        user, table_b, [{primary_b.db_column: "b1"}, {primary_b.db_column: "b2"}]
+    )
+    row_a1 = row_handler.force_create_row(user, table_a, {primary_a.id: "a1"})
+
+    with freeze_time("2021-01-01 12:00"):
+        action_type_registry.get_by_type(UpdateRowsActionType).do(
+            user,
+            table_a,
+            [
+                {"id": row_a1.id, link_a_to_b.db_column: [row_b1.id, row_b2.id]},
+            ],
+        )
+    assert RowHistory.objects.count() == 1
+
+    history_entries = RowHistory.objects.values(
+        "user_id",
+        "user_name",
+        "table_id",
+        "row_id",
+        "action_timestamp",
+        "action_type",
+        "before_values",
+        "after_values",
+        "fields_metadata",
+    )
+
+    expected_entries = [
+        {
+            "user_id": user.id,
+            "user_name": user.first_name,
+            "table_id": table_a.id,
+            "row_id": 1,
+            "action_timestamp": datetime(2021, 1, 1, 12, 0, tzinfo=timezone.utc),
+            "action_type": "update_rows",
+            "before_values": {link_a_to_b.db_column: []},
+            "after_values": {link_a_to_b.db_column: [1, 2]},
+            "fields_metadata": {
+                link_a_to_b.db_column: {
+                    "id": link_a_to_b.id,
+                    "type": "link_row",
+                    "linked_rows": {"1": {"value": "b1"}, "2": {"value": "b2"}},
+                    "primary_value": "a1",
+                    "linked_field_id": None,
+                    "linked_table_id": table_b.id,
+                }
+            },
+        }
+    ]
+    assert list(history_entries) == expected_entries
+
+
+@pytest.mark.django_db
+@pytest.mark.row_history
+def test_update_rows_insert_entries_in_linked_rows_history_in_multiple_tables(
+    data_fixture,
+):
+    user = data_fixture.create_user()
+    database = data_fixture.create_database_application(user=user)
+    table_a, table_b, link_a_to_b = data_fixture.create_two_linked_tables(
+        user=user, database=database
+    )
+    table_c, _, link_c_to_a = data_fixture.create_two_linked_tables(
+        user=user, database=database, table_b=table_a
+    )
+    primary_a = table_a.get_primary_field()
+    primary_b = table_b.get_primary_field()
+    primary_c = table_c.get_primary_field()
+    link_b_to_a = link_a_to_b.link_row_related_field
+    link_a_to_c = link_c_to_a.link_row_related_field
+
+    row_handler = RowHandler()
+
+    row_b1, row_b2 = row_handler.force_create_rows(
+        user, table_b, [{primary_b.db_column: "b1"}, {primary_b.db_column: "b2"}]
+    )
+    row_c1, row_c2 = row_handler.force_create_rows(
+        user, table_c, [{primary_c.db_column: "c1"}, {primary_c.db_column: "c2"}]
+    )
+    row_a1, row_a2 = row_handler.force_create_rows(
+        user, table_a, [{primary_a.db_column: "a1"}, {primary_a.db_column: "a2"}]
+    )
+
+    with freeze_time("2021-01-01 12:00"), patch(
+        "baserow.contrib.database.rows.signals.rows_history_updated.send"
+    ) as mock_signal:
+        action_type_registry.get_by_type(UpdateRowsActionType).do(
+            user,
+            table_a,
+            [
+                {
+                    "id": row_a1.id,
+                    link_a_to_b.db_column: [row_b1.id, row_b2.id],
+                    link_a_to_c.db_column: [row_c1.id, row_c2.id],
+                },
+                {
+                    "id": row_a2.id,
+                    link_a_to_b.db_column: [row_b1.id, row_b2.id],
+                    link_a_to_c.db_column: [row_c1.id, row_c2.id],
+                },
+            ],
+        )
+
+    assert RowHistory.objects.count() == 6
+
+    history_entries = RowHistory.objects.order_by("table_id", "row_id").values(
+        "user_id",
+        "user_name",
+        "table_id",
+        "row_id",
+        "action_timestamp",
+        "action_type",
+        "before_values",
+        "after_values",
+        "fields_metadata",
+    )
+
+    # Signal should be called once per table with row history entries for that table
+    entry_ids = [rhe.id for rhe in RowHistory.objects.order_by("table_id", "row_id")]
+    assert mock_signal.call_count == 3
+
+    per_table_args = {}
+    for args in mock_signal.call_args_list:
+        per_table_args[args[1]["table_id"]] = [
+            rhe.id for rhe in args[1]["row_history_entries"]
+        ]
+
+    assert len(per_table_args) == 3
+    assert len(entry_ids) == 6
+
+    # table_a
+    assert table_a.id in per_table_args
+    assert per_table_args[table_a.id] == entry_ids[:2]
+
+    # table_b
+    assert table_b.id in per_table_args
+    assert per_table_args[table_b.id] == entry_ids[2:4]
+
+    # table_c
+    assert table_c.id in per_table_args
+    assert per_table_args[table_c.id] == entry_ids[4:]
+
+    expected_entries = [
+        {
+            "user_id": user.id,
+            "user_name": user.first_name,
+            "table_id": table_a.id,
+            "row_id": 1,
+            "action_timestamp": datetime(2021, 1, 1, 12, 0, tzinfo=timezone.utc),
+            "action_type": "update_rows",
+            "before_values": {link_a_to_b.db_column: [], link_a_to_c.db_column: []},
+            "after_values": {
+                link_a_to_b.db_column: [1, 2],
+                link_a_to_c.db_column: [1, 2],
+            },
+            "fields_metadata": {
+                link_a_to_b.db_column: {
+                    "id": link_a_to_b.id,
+                    "type": "link_row",
+                    "linked_rows": {"1": {"value": "b1"}, "2": {"value": "b2"}},
+                    "primary_value": "a1",
+                    "linked_field_id": link_b_to_a.id,
+                    "linked_table_id": table_b.id,
+                },
+                link_a_to_c.db_column: {
+                    "id": link_a_to_c.id,
+                    "type": "link_row",
+                    "linked_rows": {"1": {"value": "c1"}, "2": {"value": "c2"}},
+                    "primary_value": "a1",
+                    "linked_field_id": link_c_to_a.id,
+                    "linked_table_id": table_c.id,
+                },
+            },
+        },
+        {
+            "user_id": user.id,
+            "user_name": user.first_name,
+            "table_id": table_a.id,
+            "row_id": 2,
+            "action_timestamp": datetime(2021, 1, 1, 12, 0, tzinfo=timezone.utc),
+            "action_type": "update_rows",
+            "before_values": {link_a_to_b.db_column: [], link_a_to_c.db_column: []},
+            "after_values": {
+                link_a_to_b.db_column: [1, 2],
+                link_a_to_c.db_column: [1, 2],
+            },
+            "fields_metadata": {
+                link_a_to_b.db_column: {
+                    "id": link_a_to_b.id,
+                    "type": "link_row",
+                    "linked_rows": {"1": {"value": "b1"}, "2": {"value": "b2"}},
+                    "primary_value": "a2",
+                    "linked_field_id": link_b_to_a.id,
+                    "linked_table_id": table_b.id,
+                },
+                link_a_to_c.db_column: {
+                    "id": link_a_to_c.id,
+                    "type": "link_row",
+                    "linked_rows": {"1": {"value": "c1"}, "2": {"value": "c2"}},
+                    "primary_value": "a2",
+                    "linked_field_id": link_c_to_a.id,
+                    "linked_table_id": table_c.id,
+                },
+            },
+        },
+        {
+            "user_id": user.id,
+            "user_name": user.first_name,
+            "table_id": table_b.id,
+            "row_id": 1,
+            "action_timestamp": datetime(2021, 1, 1, 12, 0, tzinfo=timezone.utc),
+            "action_type": "update_rows",
+            "before_values": {link_b_to_a.db_column: []},
+            "after_values": {link_b_to_a.db_column: [1, 2]},
+            "fields_metadata": {
+                link_b_to_a.db_column: {
+                    "id": link_b_to_a.id,
+                    "type": "link_row",
+                    "linked_rows": {"1": {"value": "a1"}, "2": {"value": "a2"}},
+                }
+            },
+        },
+        {
+            "user_id": user.id,
+            "user_name": user.first_name,
+            "table_id": table_b.id,
+            "row_id": 2,
+            "action_timestamp": datetime(2021, 1, 1, 12, 0, tzinfo=timezone.utc),
+            "action_type": "update_rows",
+            "before_values": {link_b_to_a.db_column: []},
+            "after_values": {link_b_to_a.db_column: [1, 2]},
+            "fields_metadata": {
+                link_b_to_a.db_column: {
+                    "id": link_b_to_a.id,
+                    "type": "link_row",
+                    "linked_rows": {"1": {"value": "a1"}, "2": {"value": "a2"}},
+                }
+            },
+        },
+        {
+            "user_id": user.id,
+            "user_name": user.first_name,
+            "table_id": table_c.id,
+            "row_id": 1,
+            "action_timestamp": datetime(2021, 1, 1, 12, 0, tzinfo=timezone.utc),
+            "action_type": "update_rows",
+            "before_values": {link_c_to_a.db_column: []},
+            "after_values": {link_c_to_a.db_column: [1, 2]},
+            "fields_metadata": {
+                link_c_to_a.db_column: {
+                    "id": link_c_to_a.id,
+                    "type": "link_row",
+                    "linked_rows": {"1": {"value": "a1"}, "2": {"value": "a2"}},
+                }
+            },
+        },
+        {
+            "user_id": user.id,
+            "user_name": user.first_name,
+            "table_id": table_c.id,
+            "row_id": 2,
+            "action_timestamp": datetime(2021, 1, 1, 12, 0, tzinfo=timezone.utc),
+            "action_type": "update_rows",
+            "before_values": {link_c_to_a.db_column: []},
+            "after_values": {link_c_to_a.db_column: [1, 2]},
+            "fields_metadata": {
+                link_c_to_a.db_column: {
+                    "id": link_c_to_a.id,
+                    "type": "link_row",
+                    "linked_rows": {"1": {"value": "a1"}, "2": {"value": "a2"}},
+                }
+            },
+        },
+    ]
+
+    assert list(history_entries) == expected_entries
diff --git a/changelog/entries/unreleased/feature/3264_show_relationship_changes_in_row_change_history.json b/changelog/entries/unreleased/feature/3264_show_relationship_changes_in_row_change_history.json
new file mode 100644
index 000000000..3494db555
--- /dev/null
+++ b/changelog/entries/unreleased/feature/3264_show_relationship_changes_in_row_change_history.json
@@ -0,0 +1,8 @@
+{
+    "type": "feature",
+    "message": "Show relationship changes (added or removed) in the related row's change history.",
+    "domain": "database",
+    "issue_number": 3264,
+    "bullet_points": [],
+    "created_at": "2025-03-18"
+}
\ No newline at end of file