Skip to content

Commit a842b98

Browse files
committed
Added foreign_keys_are_objs arg to diff_against()
This makes it easier to get the related objects from the diff objects, and facilitates prefetching the related objects when listing the diffs - which is needed for the upcoming changes to the admin history page adding a "Changes" column. The returned `ModelDelta` fields are now alphabetically ordered, to prevent flakiness of one of the added tests. The `old` and `new` M2M lists of `ModelChange` are now also sorted, which will prevent inconsistent ordering of the `Place` objects in `AdminSiteTest.test_history_list_contains_diff_changes_for_m2m_fields()` (will be added as part of the previously mentioned upcoming admin history changes). Lastly, cleaned up some `utils` imports in `models.py`.
1 parent a5439d2 commit a842b98

File tree

5 files changed

+296
-28
lines changed

5 files changed

+296
-28
lines changed

CHANGES.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ Unreleased
1717
``history_list_display`` by default, and made the latter into an actual field (gh-1128)
1818
- ``ModelDelta`` and ``ModelChange`` (in ``simple_history.models``) are now immutable
1919
dataclasses; their signatures remain unchanged (gh-1128)
20+
- ``ModelDelta``'s ``changes`` and ``changed_fields`` are now sorted alphabetically by
21+
field name. Also, if ``ModelChange`` is for an M2M field, its ``old`` and ``new``
22+
lists are sorted by the related object. This should help prevent flaky tests. (gh-1128)
23+
- ``diff_against()`` has a new keyword argument, ``foreign_keys_are_objs``;
24+
see usage in the docs under "History Diffing" (gh-1128)
2025

2126
3.5.0 (2024-02-19)
2227
------------------

docs/historical_model.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,6 @@ You will see the many to many changes when diffing between two historical record
554554
informal = Category.objects.create(name="informal questions")
555555
official = Category.objects.create(name="official questions")
556556
p = Poll.objects.create(question="what's up?")
557-
p.save()
558557
p.categories.add(informal, official)
559558
p.categories.remove(informal)
560559

docs/history_diffing.rst

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,103 @@
11
History Diffing
22
===============
33

4-
When you have two instances of the same ``HistoricalRecord`` (such as the ``HistoricalPoll`` example above),
5-
you can perform diffs to see what changed. This will result in a ``ModelDelta`` containing the following properties:
4+
When you have two instances of the same historical model
5+
(such as the ``HistoricalPoll`` example above),
6+
you can perform a diff using the ``diff_against()`` method to see what changed.
7+
This will return a ``ModelDelta`` object with the following attributes:
68

7-
1. A list with each field changed between the two historical records
8-
2. A list with the names of all fields that incurred changes from one record to the other
9-
3. the old and new records.
9+
- ``old_record`` and ``new_record``: The old and new history records
10+
- ``changed_fields``: A list of the names of all fields that were changed between
11+
``old_record`` and ``new_record``, in alphabetical order
12+
- ``changes``: A list of ``ModelChange`` objects - one for each field in
13+
``changed_fields``, in the same order.
14+
These objects have the following attributes:
1015

11-
This may be useful when you want to construct timelines and need to get only the model modifications.
16+
- ``field``: The name of the changed field
17+
(this name is equal to the corresponding field in ``changed_fields``)
18+
- ``old`` and ``new``: The old and new values of the changed field
19+
20+
- For many-to-many fields, these values will be lists of dicts from the through
21+
model field names to the primary keys of the through model's related objects.
22+
The lists are sorted by the value of the many-to-many related object.
23+
24+
This may be useful when you want to construct timelines and need to get only
25+
the model modifications.
1226

1327
.. code-block:: python
1428
15-
p = Poll.objects.create(question="what's up?")
16-
p.question = "what's up, man?"
17-
p.save()
29+
poll = Poll.objects.create(question="what's up?")
30+
poll.question = "what's up, man?"
31+
poll.save()
1832
19-
new_record, old_record = p.history.all()
33+
new_record, old_record = poll.history.all()
2034
delta = new_record.diff_against(old_record)
2135
for change in delta.changes:
22-
print("{} changed from {} to {}".format(change.field, change.old, change.new))
36+
print(f"'{change.field}' changed from '{change.old}' to '{change.new}'")
37+
38+
# Output:
39+
# 'question' changed from 'what's up?' to 'what's up, man?'
40+
41+
``diff_against()`` also accepts the following additional arguments:
42+
43+
- ``excluded_fields`` and ``included_fields``: These can be used to either explicitly
44+
exclude or include fields from being diffed, respectively.
45+
- ``foreign_keys_are_objs``:
46+
47+
- If ``False`` (default): The diff will only contain the raw primary keys of any
48+
``ForeignKey`` fields.
49+
- If ``True``: The diff will contain the actual related model objects instead of just
50+
the primary keys.
51+
Note that this will add extra database queries for each related field that's been
52+
changed - as long as the related objects have not been prefetched
53+
(using e.g. ``select_related()``).
54+
55+
A couple examples showing the difference:
56+
57+
.. code-block:: python
58+
59+
# --- Effect on foreign key fields ---
60+
61+
whats_up = Poll.objects.create(pk=15, name="what's up?")
62+
still_around = Poll.objects.create(pk=31, name="still around?")
63+
64+
choice = Choice.objects.create(poll=whats_up)
65+
choice.poll = still_around
66+
choice.save()
67+
68+
new, old = choice.history.all()
69+
70+
default_delta = new.diff_against(old)
71+
# Printing the changes of `default_delta` will output:
72+
# 'poll' changed from '15' to '31'
73+
74+
delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True)
75+
# Printing the changes of `delta_with_objs` will output:
76+
# 'poll' changed from 'what's up?' to 'still around?'
77+
78+
# Deleting all the polls:
79+
Poll.objects.all().delete()
80+
delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True) # Will raise `Place.DoesNotExist`
81+
82+
83+
# --- Effect on many-to-many fields ---
84+
85+
informal = Category.objects.create(pk=63, name="informal questions")
86+
whats_up.categories.add(informal)
87+
88+
new = whats_up.history.latest()
89+
old = new.prev_record
90+
91+
default_delta = new.diff_against(old)
92+
# Printing the changes of `default_delta` will output:
93+
# 'categories' changed from [] to [{'poll': 15, 'category': 63}]
94+
95+
delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True)
96+
# Printing the changes of `delta_with_objs` will output:
97+
# 'categories' changed from [] to [{'poll': <Poll: what's up?>, 'category': <Category: informal questions>}]
2398
24-
``diff_against`` also accepts 2 arguments ``excluded_fields`` and ``included_fields`` to either explicitly include or exclude fields from being diffed.
99+
# Deleting all the categories:
100+
Category.objects.all().delete()
101+
delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True)
102+
# Printing the changes of `delta_with_objs` will now output:
103+
# 'categories' changed from [] to [{'poll': <Poll: what's up?>, 'category': None}]

simple_history/models.py

Lines changed: 78 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import warnings
55
from dataclasses import dataclass
66
from functools import partial
7-
from typing import Any, Dict, Iterable, List, Sequence, Union
7+
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence, Union
88

99
import django
1010
from django.apps import apps
@@ -31,9 +31,7 @@
3131
from django.utils.text import format_lazy
3232
from django.utils.translation import gettext_lazy as _
3333

34-
from simple_history import utils
35-
36-
from . import exceptions
34+
from . import exceptions, utils
3735
from .manager import (
3836
SIMPLE_HISTORY_REVERSE_ATTR_NAME,
3937
HistoricalQuerySet,
@@ -46,13 +44,17 @@
4644
pre_create_historical_m2m_records,
4745
pre_create_historical_record,
4846
)
49-
from .utils import get_change_reason_from_object
5047

5148
try:
5249
from asgiref.local import Local as LocalContext
5350
except ImportError:
5451
from threading import local as LocalContext
5552

53+
if TYPE_CHECKING:
54+
ModelTypeHint = models.Model
55+
else:
56+
ModelTypeHint = object
57+
5658
registered_models = {}
5759

5860

@@ -668,7 +670,7 @@ def get_change_reason_for_object(self, instance, history_type, using):
668670
Get change reason for object.
669671
Customize this method to automatically fill change reason from context.
670672
"""
671-
return get_change_reason_from_object(instance)
673+
return utils.get_change_reason_from_object(instance)
672674

673675
def m2m_changed(self, instance, action, attr, pk_set, reverse, **_):
674676
if hasattr(instance, "skip_history_when_saving"):
@@ -941,8 +943,28 @@ def __get__(self, instance, owner):
941943
return self.model(**values)
942944

943945

944-
class HistoricalChanges:
945-
def diff_against(self, old_history, excluded_fields=None, included_fields=None):
946+
class HistoricalChanges(ModelTypeHint):
947+
def diff_against(
948+
self,
949+
old_history: "HistoricalChanges",
950+
excluded_fields: Iterable[str] = None,
951+
included_fields: Iterable[str] = None,
952+
*,
953+
foreign_keys_are_objs=False,
954+
) -> "ModelDelta":
955+
"""
956+
:param old_history:
957+
:param excluded_fields: The names of fields to exclude from diffing.
958+
This takes precedence over ``included_fields``.
959+
:param included_fields: The names of the only fields to include when diffing.
960+
If not provided, all history-tracked fields will be included.
961+
:param foreign_keys_are_objs: If ``False``, the returned diff will only contain
962+
the raw PKs of any ``ForeignKey`` fields.
963+
If ``True``, the diff will contain the actual related model objects
964+
instead of just the PKs.
965+
The latter case will necessarily query the database if the related
966+
objects have not been prefetched (using e.g. ``select_related()``).
967+
"""
946968
if not isinstance(old_history, type(self)):
947969
raise TypeError(
948970
"unsupported type(s) for diffing:"
@@ -965,16 +987,23 @@ def diff_against(self, old_history, excluded_fields=None, included_fields=None):
965987
m2m_fields = set(included_m2m_fields).difference(excluded_fields)
966988

967989
changes = [
968-
*self._get_field_changes_for_diff(old_history, fields),
969-
*self._get_m2m_field_changes_for_diff(old_history, m2m_fields),
990+
*self._get_field_changes_for_diff(
991+
old_history, fields, foreign_keys_are_objs
992+
),
993+
*self._get_m2m_field_changes_for_diff(
994+
old_history, m2m_fields, foreign_keys_are_objs
995+
),
970996
]
997+
# Sort by field (attribute) name, to ensure a consistent order
998+
changes.sort(key=lambda change: change.field)
971999
changed_fields = [change.field for change in changes]
9721000
return ModelDelta(changes, changed_fields, old_history, self)
9731001

9741002
def _get_field_changes_for_diff(
9751003
self,
9761004
old_history: "HistoricalChanges",
9771005
fields: Iterable[str],
1006+
foreign_keys_are_objs: bool,
9781007
) -> List["ModelChange"]:
9791008
"""Helper method for ``diff_against()``."""
9801009
changes = []
@@ -987,6 +1016,14 @@ def _get_field_changes_for_diff(
9871016
new_value = new_values[field]
9881017

9891018
if old_value != new_value:
1019+
if foreign_keys_are_objs and isinstance(
1020+
self._meta.get_field(field), ForeignKey
1021+
):
1022+
# Set the fields to their related model objects instead of
1023+
# the raw PKs from `model_to_dict()`
1024+
old_value = getattr(old_history, field)
1025+
new_value = getattr(self, field)
1026+
9901027
change = ModelChange(field, old_value, new_value)
9911028
changes.append(change)
9921029

@@ -996,14 +1033,18 @@ def _get_m2m_field_changes_for_diff(
9961033
self,
9971034
old_history: "HistoricalChanges",
9981035
m2m_fields: Iterable[str],
1036+
foreign_keys_are_objs: bool,
9991037
) -> List["ModelChange"]:
10001038
"""Helper method for ``diff_against()``."""
10011039
changes = []
10021040

10031041
for field in m2m_fields:
1004-
old_m2m_manager = getattr(old_history, field)
1005-
new_m2m_manager = getattr(self, field)
1006-
m2m_through_model_opts = new_m2m_manager.model._meta
1042+
original_field_meta = self.instance_type._meta.get_field(field)
1043+
reverse_field_name = utils.get_m2m_reverse_field_name(original_field_meta)
1044+
# Sort the M2M rows by the related object, to ensure a consistent order
1045+
old_m2m_qs = getattr(old_history, field).order_by(reverse_field_name)
1046+
new_m2m_qs = getattr(self, field).order_by(reverse_field_name)
1047+
m2m_through_model_opts = new_m2m_qs.model._meta
10071048

10081049
# Create a list of field names to compare against.
10091050
# The list is generated without the PK of the intermediate (through)
@@ -1014,10 +1055,32 @@ def _get_m2m_field_changes_for_diff(
10141055
for f in m2m_through_model_opts.fields
10151056
if f.editable and f.name not in ["id", "m2m_history_id", "history"]
10161057
]
1017-
old_rows = list(old_m2m_manager.values(*through_model_fields))
1018-
new_rows = list(new_m2m_manager.values(*through_model_fields))
1058+
old_rows = list(old_m2m_qs.values(*through_model_fields))
1059+
new_rows = list(new_m2m_qs.values(*through_model_fields))
10191060

10201061
if old_rows != new_rows:
1062+
if foreign_keys_are_objs:
1063+
fk_fields = [
1064+
f
1065+
for f in through_model_fields
1066+
if isinstance(m2m_through_model_opts.get_field(f), ForeignKey)
1067+
]
1068+
1069+
# Set the through fields to their related model objects instead of
1070+
# the raw PKs from `values()`
1071+
def rows_with_foreign_key_objs(m2m_qs):
1072+
# Replicate the format of the return value of QuerySet.values()
1073+
return [
1074+
{
1075+
through_field: getattr(through_obj, through_field)
1076+
for through_field in through_model_fields
1077+
}
1078+
for through_obj in m2m_qs.select_related(*fk_fields)
1079+
]
1080+
1081+
old_rows = rows_with_foreign_key_objs(old_m2m_qs)
1082+
new_rows = rows_with_foreign_key_objs(new_m2m_qs)
1083+
10211084
change = ModelChange(field, old_rows, new_rows)
10221085
changes.append(change)
10231086

0 commit comments

Comments
 (0)