Skip to content

Commit 4325591

Browse files
JordanHyattquadracikdavid-homelendtim-schilling
authored
HistoricOneToOneField (#1394)
* Implemented HistoricOneToOneField with unit tests * Updated AUTHORS.rst * Added HistoricOneToOneFiled to querying_history.rst * removed call to field cache * - created HistoricDescriptorMixin to DRY out get_queryset method - Added suggested doc string to HistoricOneToOneField * Switch to lowest supported python version for pre-commit black --------- Co-authored-by: quadracik <[email protected]> Co-authored-by: David Diamant <[email protected]> Co-authored-by: Tim Schilling <[email protected]>
1 parent 7fdebc8 commit 4325591

File tree

7 files changed

+269
-12
lines changed

7 files changed

+269
-12
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ repos:
1010
rev: 24.8.0
1111
hooks:
1212
- id: black
13-
language_version: python3.8
13+
language_version: python3.9
1414

1515
- repo: https://github.com/pycqa/flake8
1616
rev: 7.1.1

AUTHORS.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ Authors
7979
- Jonathan Loo (`alpha1d3d <https://github.com/alpha1d3d>`_)
8080
- Jonathan Sanchez
8181
- Jonathan Zvesper (`zvesp <https://github.com/zvesp>`_)
82-
- Jordon Wing (`jordonwii <https://github.com/jordonwii`_)
82+
- Jordan Hyatt (`JordanHyatt <https://github.com/JordanHyatt>`_)
83+
- Jordon Wing (`jordonwii <https://github.com/jordonwii>`_)
8384
- Josh Fyne
8485
- Josh Thomas (`joshuadavidthomas <https://github.com/joshuadavidthomas>`_)
8586
- Keith Hackbarth

CHANGES.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Unreleased
77
- Made ``skip_history_when_saving`` work when creating an object - not just when
88
updating an object (gh-1262)
99
- Improved performance of the ``latest_of_each()`` history manager method (gh-1360)
10+
- Added HistoricOneToOneField (gh-1394)
1011

1112
3.7.0 (2024-05-29)
1213
------------------

docs/querying_history.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ historic point in time (even if it is the most recent version).
149149
You can use `to_historic` to return the historical model that was used
150150
to furnish the instance at hand, if it is actually historic.
151151

152+
.. _`HistoricForeignKey`:
152153

153154
HistoricForeignKey
154155
------------------
@@ -162,6 +163,11 @@ reverse relationships.
162163
See the `HistoricForeignKeyTest` code and models for an example.
163164

164165

166+
HistoricOneToOneField
167+
---------------------
168+
169+
Similar to :ref:`HistoricForeignKey`, but for OneToOneFields instead.
170+
165171
most_recent
166172
-----------
167173

simple_history/models.py

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
from django.db.models.fields.related import ForeignKey
1919
from django.db.models.fields.related_descriptors import (
2020
ForwardManyToOneDescriptor,
21+
ForwardOneToOneDescriptor,
2122
ReverseManyToOneDescriptor,
23+
ReverseOneToOneDescriptor,
2224
create_reverse_many_to_one_manager,
2325
)
2426
from django.db.models.query import QuerySet
@@ -833,21 +835,16 @@ def transform_field(field):
833835
field.serialize = True
834836

835837

836-
class HistoricForwardManyToOneDescriptor(ForwardManyToOneDescriptor):
837-
"""
838-
Overrides get_queryset to provide historic query support, should the
839-
instance be historic (and therefore was generated by a timepoint query)
840-
and the other side of the relation also uses a history manager.
841-
"""
838+
class HistoricDescriptorMixin:
842839

843-
def get_queryset(self, **hints) -> QuerySet:
840+
def get_queryset(self, **hints):
844841
instance = hints.get("instance")
845842
if instance:
846843
history = getattr(instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME, None)
847844
histmgr = getattr(
848-
self.field.remote_field.model,
845+
self.get_related_model(),
849846
getattr(
850-
self.field.remote_field.model._meta,
847+
self.get_related_model()._meta,
851848
"simple_history_manager_attribute",
852849
"_notthere",
853850
),
@@ -858,6 +855,19 @@ def get_queryset(self, **hints) -> QuerySet:
858855
return super().get_queryset(**hints)
859856

860857

858+
class HistoricForwardManyToOneDescriptor(
859+
HistoricDescriptorMixin, ForwardManyToOneDescriptor
860+
):
861+
"""
862+
Overrides get_queryset to provide historic query support, should the
863+
instance be historic (and therefore was generated by a timepoint query)
864+
and the other side of the relation also uses a history manager.
865+
"""
866+
867+
def get_related_model(self):
868+
return self.field.remote_field.model
869+
870+
861871
class HistoricReverseManyToOneDescriptor(ReverseManyToOneDescriptor):
862872
"""
863873
Overrides get_queryset to provide historic query support, should the
@@ -922,6 +932,54 @@ class HistoricForeignKey(ForeignKey):
922932
related_accessor_class = HistoricReverseManyToOneDescriptor
923933

924934

935+
class HistoricForwardOneToOneDescriptor(
936+
HistoricDescriptorMixin, ForwardOneToOneDescriptor
937+
):
938+
"""
939+
Overrides get_queryset to provide historic query support, should the
940+
instance be historic (and therefore was generated by a timepoint query)
941+
and the other side of the relation also uses a history manager.
942+
"""
943+
944+
def get_related_model(self):
945+
return self.field.remote_field.model
946+
947+
948+
class HistoricReverseOneToOneDescriptor(
949+
HistoricDescriptorMixin, ReverseOneToOneDescriptor
950+
):
951+
"""
952+
Overrides get_queryset to provide historic query support, should the
953+
instance be historic (and therefore was generated by a timepoint query)
954+
and the other side of the relation also uses a history manager.
955+
"""
956+
957+
def get_related_model(self):
958+
return self.related.related_model
959+
960+
961+
class HistoricOneToOneField(models.OneToOneField):
962+
"""
963+
Allows one to one fields to work properly from a historic instance.
964+
965+
If you use as_of queries to extract historical instances from
966+
a model, and you have other models that are related by one to
967+
one fields and also historic, changing them to a
968+
HistoricOneToOneField field type will allow you to naturally
969+
cross the relationship boundary at the same point in time as
970+
the origin instance.
971+
972+
A historic instance maintains an attribute ("_historic") when
973+
it is historic, holding the historic record instance and the
974+
timepoint used to query it ("_as_of"). HistoricOneToOneField
975+
looks for this and uses an as_of query against the related
976+
object so the relationship is assessed at the same timepoint.
977+
"""
978+
979+
forward_related_accessor_class = HistoricForwardOneToOneDescriptor
980+
related_accessor_class = HistoricReverseOneToOneDescriptor
981+
982+
925983
def is_historic(instance):
926984
"""
927985
Returns True if the instance was acquired with an as_of timepoint.

simple_history/tests/models.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010

1111
from simple_history import register
1212
from simple_history.manager import HistoricalQuerySet, HistoryManager
13-
from simple_history.models import HistoricalRecords, HistoricForeignKey
13+
from simple_history.models import (
14+
HistoricalRecords,
15+
HistoricForeignKey,
16+
HistoricOneToOneField,
17+
)
1418

1519
from .custom_user.models import CustomUser as User
1620
from .external.models import AbstractExternal, AbstractExternal2, AbstractExternal3
@@ -983,3 +987,58 @@ class TestHistoricParticipanToHistoricOrganization(models.Model):
983987
related_name="historic_participants",
984988
)
985989
history = HistoricalRecords()
990+
991+
992+
class TestParticipantToHistoricOrganizationOneToOne(models.Model):
993+
"""
994+
Non-historic table with one to one relationship to historic table.
995+
996+
In this case it should simply behave like ForeignKey because
997+
the origin model (this one) cannot be historic, so foreign key
998+
lookups are always "current".
999+
"""
1000+
1001+
name = models.CharField(max_length=15, unique=True)
1002+
organization = HistoricOneToOneField(
1003+
TestOrganizationWithHistory, on_delete=CASCADE, related_name="participant"
1004+
)
1005+
1006+
1007+
class TestHistoricParticipantToOrganizationOneToOne(models.Model):
1008+
"""
1009+
Historic table with one to one relationship to non-historic table.
1010+
1011+
In this case it should simply behave like OneToOneField because
1012+
the origin model (this one) cannot be historic, so one to one field
1013+
lookups are always "current".
1014+
"""
1015+
1016+
name = models.CharField(max_length=15, unique=True)
1017+
organization = HistoricOneToOneField(
1018+
TestOrganization, on_delete=CASCADE, related_name="participant"
1019+
)
1020+
history = HistoricalRecords()
1021+
1022+
1023+
class TestHistoricParticipanToHistoricOrganizationOneToOne(models.Model):
1024+
"""
1025+
Historic table with one to one relationship to historic table.
1026+
1027+
In this case as_of queries on the origin model (this one)
1028+
or on the target model (the other one) will traverse the
1029+
one to one field relationship honoring the timepoint of the
1030+
original query. This only happens when both tables involved
1031+
are historic.
1032+
1033+
NOTE: related_name has to be different than the one used in
1034+
TestParticipantToHistoricOrganizationOneToOne as they are
1035+
sharing the same target table.
1036+
"""
1037+
1038+
name = models.CharField(max_length=15, unique=True)
1039+
organization = HistoricOneToOneField(
1040+
TestOrganizationWithHistory,
1041+
on_delete=CASCADE,
1042+
related_name="historic_participant",
1043+
)
1044+
history = HistoricalRecords()

simple_history/tests/tests/test_models.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,13 @@
110110
Street,
111111
Temperature,
112112
TestHistoricParticipanToHistoricOrganization,
113+
TestHistoricParticipanToHistoricOrganizationOneToOne,
113114
TestHistoricParticipantToOrganization,
115+
TestHistoricParticipantToOrganizationOneToOne,
114116
TestOrganization,
115117
TestOrganizationWithHistory,
116118
TestParticipantToHistoricOrganization,
119+
TestParticipantToHistoricOrganizationOneToOne,
117120
UnicodeVerboseName,
118121
UnicodeVerboseNamePlural,
119122
UserTextFieldChangeReasonModel,
@@ -2841,3 +2844,132 @@ def test_historic_to_historic(self):
28412844
)[0]
28422845
pt1i = pt1h.instance
28432846
self.assertEqual(pt1i.organization.name, "original")
2847+
2848+
2849+
class HistoricOneToOneFieldTest(TestCase):
2850+
"""
2851+
Tests chasing OneToOne foreign keys across time points naturally with
2852+
HistoricForeignKey.
2853+
"""
2854+
2855+
def test_non_historic_to_historic(self):
2856+
"""
2857+
Non-historic table with one to one relationship to historic table.
2858+
2859+
In this case it should simply behave like OneToOneField because
2860+
the origin model (this one) cannot be historic, so OneToOneField
2861+
lookups are always "current".
2862+
"""
2863+
org = TestOrganizationWithHistory.objects.create(name="original")
2864+
part = TestParticipantToHistoricOrganizationOneToOne.objects.create(
2865+
name="part", organization=org
2866+
)
2867+
before_mod = timezone.now()
2868+
self.assertEqual(part.organization.id, org.id)
2869+
self.assertEqual(org.participant, part)
2870+
2871+
historg = TestOrganizationWithHistory.history.as_of(before_mod).get(
2872+
name="original"
2873+
)
2874+
self.assertEqual(historg.participant, part)
2875+
2876+
self.assertEqual(org.history.count(), 1)
2877+
org.name = "modified"
2878+
org.save()
2879+
self.assertEqual(org.history.count(), 2)
2880+
2881+
# drop internal caches, re-select
2882+
part = TestParticipantToHistoricOrganizationOneToOne.objects.get(name="part")
2883+
self.assertEqual(part.organization.name, "modified")
2884+
2885+
def test_historic_to_non_historic(self):
2886+
"""
2887+
Historic table OneToOneField to non-historic table.
2888+
2889+
In this case it should simply behave like OneToOneField because
2890+
the origin model (this one) can be historic but the target model
2891+
is not, so foreign key lookups are always "current".
2892+
"""
2893+
org = TestOrganization.objects.create(name="org")
2894+
part = TestHistoricParticipantToOrganizationOneToOne.objects.create(
2895+
name="original", organization=org
2896+
)
2897+
self.assertEqual(part.organization.id, org.id)
2898+
self.assertEqual(org.participant, part)
2899+
2900+
histpart = TestHistoricParticipantToOrganizationOneToOne.objects.get(
2901+
name="original"
2902+
)
2903+
self.assertEqual(histpart.organization.id, org.id)
2904+
2905+
def test_historic_to_historic(self):
2906+
"""
2907+
Historic table with one to one relationship to historic table.
2908+
2909+
In this case as_of queries on the origin model (this one)
2910+
or on the target model (the other one) will traverse the
2911+
foreign key relationship honoring the timepoint of the
2912+
original query. This only happens when both tables involved
2913+
are historic.
2914+
2915+
At t1 we have one org, one participant.
2916+
At t2 we have one org, one participant, however the org's name has changed.
2917+
"""
2918+
org = TestOrganizationWithHistory.objects.create(name="original")
2919+
2920+
p1 = TestHistoricParticipanToHistoricOrganizationOneToOne.objects.create(
2921+
name="p1", organization=org
2922+
)
2923+
t1 = timezone.now()
2924+
org.name = "modified"
2925+
org.save()
2926+
p1.name = "p1_modified"
2927+
p1.save()
2928+
t2 = timezone.now()
2929+
2930+
# forward relationships - see how natural chasing timepoint relations is
2931+
p1t1 = TestHistoricParticipanToHistoricOrganizationOneToOne.history.as_of(
2932+
t1
2933+
).get(name="p1")
2934+
self.assertEqual(p1t1.organization, org)
2935+
self.assertEqual(p1t1.organization.name, "original")
2936+
p1t2 = TestHistoricParticipanToHistoricOrganizationOneToOne.history.as_of(
2937+
t2
2938+
).get(name="p1_modified")
2939+
self.assertEqual(p1t2.organization, org)
2940+
self.assertEqual(p1t2.organization.name, "modified")
2941+
2942+
# reverse relationships
2943+
# at t1
2944+
ot1 = TestOrganizationWithHistory.history.as_of(t1).all()[0]
2945+
self.assertEqual(ot1.historic_participant.name, "p1")
2946+
2947+
# at t2
2948+
ot2 = TestOrganizationWithHistory.history.as_of(t2).all()[0]
2949+
self.assertEqual(ot2.historic_participant.name, "p1_modified")
2950+
2951+
# current
2952+
self.assertEqual(org.historic_participant.name, "p1_modified")
2953+
2954+
self.assertTrue(is_historic(ot1))
2955+
self.assertFalse(is_historic(org))
2956+
2957+
self.assertIsInstance(
2958+
to_historic(ot1), TestOrganizationWithHistory.history.model
2959+
)
2960+
self.assertIsNone(to_historic(org))
2961+
2962+
# test querying directly from the history table and converting
2963+
# to an instance, it should chase the foreign key properly
2964+
# in this case if _as_of is not present we use the history_date
2965+
# https://github.com/jazzband/django-simple-history/issues/983
2966+
pt1h = TestHistoricParticipanToHistoricOrganizationOneToOne.history.all()[0]
2967+
pt1i = pt1h.instance
2968+
self.assertEqual(pt1i.organization.name, "modified")
2969+
pt1h = (
2970+
TestHistoricParticipanToHistoricOrganizationOneToOne.history.all().order_by(
2971+
"history_date"
2972+
)[0]
2973+
)
2974+
pt1i = pt1h.instance
2975+
self.assertEqual(pt1i.organization.name, "original")

0 commit comments

Comments
 (0)