Skip to content

Commit d8dc4d2

Browse files
committed
Add HistoricForeignKey for chasing relationships through a timepoint.
Add is_historic, to_historic helpers.
1 parent fc9b55e commit d8dc4d2

File tree

7 files changed

+405
-11
lines changed

7 files changed

+405
-11
lines changed

CHANGES.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ Full list of changes:
2525
- Added pre-commit for better commit quality (gh-896)
2626
- Added ability to break into debugger on unit test failure (gh-890)
2727
- Russian translations update (gh-897)
28+
- Fix bug with ``history.diff_against`` with non-editable fields (gh-923)
29+
- Added HistoricForeignKey (gh-940)
2830
- Add Python 3.10 to test matrix (gh-899)
2931
- Added support for Django 4.0 (gh-898)
30-
- Dropped support for Python 3.6, which reached end-of-life on 2021-12-23 (gh-946).
31-
- Fix bug with ``history.diff_against`` with non-editable fields (gh-923)
3232
- Dropped support for Django 3.1 (gh-952)
33+
- Dropped support for Python 3.6, which reached end-of-life on 2021-12-23 (gh-946)
3334
- RecordModels now support a ``no_db_index`` setting, to drop indices in historical models, default stays the same (gh-720)
3435

3536
3.0.0 (2021-04-16)

docs/querying_history.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,31 @@ When the queryset is returning historical records, `pk` refers to the
137137
`history_id` primary key.
138138

139139

140+
is_historic and to_historic
141+
---------------------------
142+
143+
If you use `as_of` to query history, the resulting instance will have an
144+
attribute named `_history` added to it. This property will contain the
145+
historical model record that the instance was derived from. Calling
146+
is_historic is an easy way to check if an instance was derived from a
147+
historic timepoint (even if it is the most recent version).
148+
149+
You can use `to_historic` to return the historical model that was used
150+
to furnish the instance at hand, if it is actually historic.
151+
152+
153+
HistoricForeignKey
154+
------------------
155+
156+
If you have two historic tables linked by foreign key, you can change it
157+
to use a HistoricForeignKey so that chasing relations from an `as_of`
158+
acquired instance (at a specific timepoint) will honor that timepoint
159+
when accessing the related object(s). This works for both forward and
160+
reverse relationships.
161+
162+
See the `HistoricForeignKeyTest` code and models for an example.
163+
164+
140165
most_recent
141166
-----------
142167

simple_history/manager.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
get_change_reason_from_object,
99
)
1010

11+
# when converting a historical record to an instance, this attribute is added
12+
# to the instance so that code can reverse the instance to its historical record
13+
SIMPLE_HISTORY_REVERSE_ATTR_NAME = "_history"
14+
1115

1216
class HistoricalQuerySet(QuerySet):
1317
"""
@@ -22,6 +26,7 @@ class HistoricalQuerySet(QuerySet):
2226
def __init__(self, *args, **kwargs):
2327
super().__init__(*args, **kwargs)
2428
self._as_instances = False
29+
self._as_of = None
2530
self._pk_attr = self.model.instance_type._meta.pk.attname
2631

2732
def as_instances(self):
@@ -89,6 +94,7 @@ def latest_of_each(self):
8994
def _clone(self):
9095
c = super()._clone()
9196
c._as_instances = self._as_instances
97+
c._as_of = self._as_of
9298
c._pk_attr = self._pk_attr
9399
return c
94100

@@ -108,6 +114,9 @@ def _instanceize(self):
108114
and isinstance(self._result_cache[0], self.model)
109115
):
110116
self._result_cache = [item.instance for item in self._result_cache]
117+
for item in self._result_cache:
118+
historic = getattr(item, SIMPLE_HISTORY_REVERSE_ATTR_NAME)
119+
setattr(historic, "_as_of", self._as_of)
111120

112121

113122
class HistoryDescriptor:
@@ -195,6 +204,8 @@ def as_of(self, date):
195204
"""
196205
queryset = self.get_queryset().filter(history_date__lte=date)
197206
if not self.instance:
207+
if isinstance(queryset, HistoricalQuerySet):
208+
queryset._as_of = date
198209
queryset = queryset.latest_of_each().as_instances()
199210
return queryset
200211

@@ -209,7 +220,10 @@ def as_of(self, date):
209220
raise self.instance.DoesNotExist(
210221
"%s had already been deleted." % self.instance._meta.object_name
211222
)
212-
return history_obj.instance
223+
result = history_obj.instance
224+
historic = getattr(result, SIMPLE_HISTORY_REVERSE_ATTR_NAME)
225+
setattr(historic, "_as_of", date)
226+
return result
213227

214228
def bulk_history_create(
215229
self,

simple_history/models.py

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,25 @@
1111
from django.db import models
1212
from django.db.models import ManyToManyField
1313
from django.db.models.fields.proxy import OrderWrt
14+
from django.db.models.fields.related import ForeignKey
15+
from django.db.models.fields.related_descriptors import (
16+
ForwardManyToOneDescriptor,
17+
ReverseManyToOneDescriptor,
18+
create_reverse_many_to_one_manager,
19+
)
20+
from django.db.models.query import QuerySet
1421
from django.forms.models import model_to_dict
1522
from django.urls import reverse
1623
from django.utils import timezone
1724
from django.utils.encoding import smart_str
25+
from django.utils.functional import cached_property
1826
from django.utils.text import format_lazy
1927
from django.utils.translation import gettext_lazy as _
2028

2129
from simple_history import utils
2230

2331
from . import exceptions
24-
from .manager import HistoryDescriptor
32+
from .manager import SIMPLE_HISTORY_REVERSE_ATTR_NAME, HistoryDescriptor
2533
from .signals import post_create_historical_record, pre_create_historical_record
2634
from .utils import get_change_reason_from_object
2735

@@ -422,7 +430,10 @@ def get_instance(self):
422430
pass
423431
else:
424432
attrs.update(values)
425-
return model(**attrs)
433+
result = model(**attrs)
434+
# this is the only way external code could know an instance is historical
435+
setattr(result, SIMPLE_HISTORY_REVERSE_ATTR_NAME, self)
436+
return result
426437

427438
def get_next_record(self):
428439
"""
@@ -632,6 +643,108 @@ def transform_field(field):
632643
field.serialize = True
633644

634645

646+
class HistoricForwardManyToOneDescriptor(ForwardManyToOneDescriptor):
647+
"""
648+
Overrides get_queryset to provide historic query support, should the
649+
instance be historic (and therefore was generated by a timepoint query)
650+
and the other side of the relation also uses a history manager.
651+
"""
652+
653+
def get_queryset(self, **hints) -> QuerySet:
654+
instance = hints.get("instance")
655+
if instance:
656+
history = getattr(instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME, None)
657+
histmgr = getattr(
658+
self.field.remote_field.model,
659+
getattr(
660+
self.field.remote_field.model._meta,
661+
"simple_history_manager_attribute",
662+
"_notthere",
663+
),
664+
None,
665+
)
666+
if history and histmgr:
667+
return histmgr.as_of(history._as_of)
668+
return super().get_queryset(**hints)
669+
670+
671+
class HistoricReverseManyToOneDescriptor(ReverseManyToOneDescriptor):
672+
"""
673+
Overrides get_queryset to provide historic query support, should the
674+
instance be historic (and therefore was generated by a timepoint query)
675+
and the other side of the relation also uses a history manager.
676+
"""
677+
678+
@cached_property
679+
def related_manager_cls(self):
680+
related_model = self.rel.related_model
681+
682+
class HistoricRelationModelManager(related_model._default_manager.__class__):
683+
def get_queryset(self):
684+
try:
685+
return self.instance._prefetched_objects_cache[
686+
self.field.remote_field.get_cache_name()
687+
]
688+
except (AttributeError, KeyError):
689+
history = getattr(
690+
self.instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME, None
691+
)
692+
histmgr = getattr(
693+
self.model,
694+
getattr(
695+
self.model._meta,
696+
"simple_history_manager_attribute",
697+
"_notthere",
698+
),
699+
None,
700+
)
701+
if history and histmgr:
702+
queryset = histmgr.as_of(history._as_of)
703+
else:
704+
queryset = super().get_queryset()
705+
return self._apply_rel_filters(queryset)
706+
707+
return create_reverse_many_to_one_manager(
708+
HistoricRelationModelManager, self.rel
709+
)
710+
711+
712+
class HistoricForeignKey(ForeignKey):
713+
"""
714+
Allows foreign keys to work properly from a historic instance.
715+
716+
If you use as_of queries to extract historical instances from
717+
a model, and you have other models that are related by foreign
718+
key and also historic, changing them to a HistoricForeignKey
719+
field type will allow you to naturally cross the relationship
720+
boundary at the same point in time as the origin instance.
721+
722+
A historic instance maintains an attribute ("_historic") when
723+
it is historic, holding the historic record instance and the
724+
timepoint used to query it ("_as_of"). HistoricForeignKey
725+
looks for this and uses an as_of query against the related
726+
object so the relationship is assessed at the same timepoint.
727+
"""
728+
729+
forward_related_accessor_class = HistoricForwardManyToOneDescriptor
730+
related_accessor_class = HistoricReverseManyToOneDescriptor
731+
732+
733+
def is_historic(instance):
734+
"""
735+
Returns True if the instance was acquired with an as_of timepoint.
736+
"""
737+
return to_historic(instance) is not None
738+
739+
740+
def to_historic(instance):
741+
"""
742+
Returns a historic model instance if the instance was acquired with
743+
an as_of timepoint, or None.
744+
"""
745+
return getattr(instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME, None)
746+
747+
635748
class HistoricalObjectDescriptor:
636749
def __init__(self, model, fields_included):
637750
self.model = model

simple_history/tests/models.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
from django.apps import apps
55
from django.conf import settings
66
from django.db import models
7+
from django.db.models.deletion import CASCADE
8+
from django.db.models.fields.related import ForeignKey
79
from django.urls import reverse
810

911
from simple_history import register
10-
from simple_history.models import HistoricalRecords
12+
from simple_history.models import HistoricalRecords, HistoricForeignKey
1113

1214
from .custom_user.models import CustomUser as User
1315
from .external.models import AbstractExternal, AbstractExternal2, AbstractExternal3
@@ -788,3 +790,67 @@ class ModelWithMultipleNoDBIndex(models.Model):
788790
"Library", on_delete=models.CASCADE, null=True, related_name="+"
789791
)
790792
history = HistoricalRecords(no_db_index=["name", "fk", "other"])
793+
794+
795+
class TestOrganization(models.Model):
796+
name = models.CharField(max_length=15, unique=True)
797+
798+
799+
class TestOrganizationWithHistory(models.Model):
800+
name = models.CharField(max_length=15, unique=True)
801+
history = HistoricalRecords()
802+
803+
804+
class TestParticipantToHistoricOrganization(models.Model):
805+
"""
806+
Non-historic table foreign key to historic table.
807+
808+
In this case it should simply behave like ForeignKey because
809+
the origin model (this one) cannot be historic, so foreign key
810+
lookups are always "current".
811+
"""
812+
813+
name = models.CharField(max_length=15, unique=True)
814+
organization = HistoricForeignKey(
815+
TestOrganizationWithHistory, on_delete=CASCADE, related_name="participants"
816+
)
817+
818+
819+
class TestHistoricParticipantToOrganization(models.Model):
820+
"""
821+
Historic table foreign key to non-historic table.
822+
823+
In this case it should simply behave like ForeignKey because
824+
the origin model (this one) can be historic but the target model
825+
is not, so foreign key lookups are always "current".
826+
"""
827+
828+
name = models.CharField(max_length=15, unique=True)
829+
organization = HistoricForeignKey(
830+
TestOrganization, on_delete=CASCADE, related_name="participants"
831+
)
832+
history = HistoricalRecords()
833+
834+
835+
class TestHistoricParticipanToHistoricOrganization(models.Model):
836+
"""
837+
Historic table foreign key to historic table.
838+
839+
In this case as_of queries on the origin model (this one)
840+
or on the target model (the other one) will traverse the
841+
foreign key relationship honoring the timepoint of the
842+
original query. This only happens when both tables involved
843+
are historic.
844+
845+
NOTE: related_name has to be different than the one used in
846+
TestParticipantToHistoricOrganization as they are
847+
sharing the same target table.
848+
"""
849+
850+
name = models.CharField(max_length=15, unique=True)
851+
organization = HistoricForeignKey(
852+
TestOrganizationWithHistory,
853+
on_delete=CASCADE,
854+
related_name="historic_participants",
855+
)
856+
history = HistoricalRecords()

simple_history/tests/tests/test_manager.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from django.db import IntegrityError
66
from django.test import TestCase, override_settings, skipUnlessDBFeature
77

8+
from simple_history.manager import SIMPLE_HISTORY_REVERSE_ATTR_NAME
9+
810
from ..models import Document, Poll, RankedDocument
911

1012
User = get_user_model()
@@ -69,12 +71,23 @@ def test_modified(self):
6971

7072
class AsOfAdditionalTestCase(TestCase):
7173
def test_create_and_delete(self):
72-
now = datetime.now()
7374
document = Document.objects.create()
75+
now = datetime.now()
7476
document.delete()
75-
for doc_change in Document.history.all():
76-
doc_change.history_date = now
77-
doc_change.save()
77+
78+
docs_as_of_now = Document.history.as_of(now)
79+
doc = docs_as_of_now[0]
80+
# as_of queries inject a property allowing callers
81+
# to go from instance to historical instance
82+
historic = getattr(doc, SIMPLE_HISTORY_REVERSE_ATTR_NAME)
83+
self.assertIsNotNone(historic)
84+
# as_of queries inject the time point of the original
85+
# query into the historic record so callers can do magical
86+
# things like chase historic foreign key relationships
87+
# by patching forward and reverse one-to-one relationship
88+
# processing (see issue 880)
89+
self.assertEqual(historic._as_of, now)
90+
7891
docs_as_of_tmw = Document.history.as_of(now + timedelta(days=1))
7992
with self.assertNumQueries(1):
8093
self.assertFalse(list(docs_as_of_tmw))
@@ -122,6 +135,12 @@ def test_as_of(self):
122135
ids = {item["id"] for item in queryset.values("id")}
123136
self.assertEqual(ids, {document1.id, document2.id})
124137

138+
# these records are historic
139+
record = queryset[0]
140+
historic = getattr(record, SIMPLE_HISTORY_REVERSE_ATTR_NAME)
141+
self.assertIsInstance(historic, RankedDocument.history.model)
142+
self.assertEqual(historic._as_of, t1)
143+
125144
# at t2 we have one record left
126145
queryset = RankedDocument.history.as_of(t2)
127146
self.assertEqual(queryset.count(), 1)

0 commit comments

Comments
 (0)