Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HistoricOneToOneField #1394

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
3 changes: 2 additions & 1 deletion AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ Authors
- Jonathan Loo (`alpha1d3d <https://github.com/alpha1d3d>`_)
- Jonathan Sanchez
- Jonathan Zvesper (`zvesp <https://github.com/zvesp>`_)
- Jordon Wing (`jordonwii <https://github.com/jordonwii`_)
- Jordan Hyatt (`JordanHyatt <https://github.com/JordanHyatt>`_)
- Jordon Wing (`jordonwii <https://github.com/jordonwii>`_)
- Josh Fyne
- Josh Thomas (`joshuadavidthomas <https://github.com/joshuadavidthomas>`_)
- Keith Hackbarth
Expand Down
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Unreleased
- Made ``skip_history_when_saving`` work when creating an object - not just when
updating an object (gh-1262)
- Improved performance of the ``latest_of_each()`` history manager method (gh-1360)
- Added HistoricOneToOneField (gh-1394)

3.7.0 (2024-05-29)
------------------
Expand Down
5 changes: 5 additions & 0 deletions docs/querying_history.rst
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ reverse relationships.
See the `HistoricForeignKeyTest` code and models for an example.


HistoricOneToOneField
---------------------

Same concept as HistoricForeignKey but for OneToOneFields instead.

most_recent
-----------

Expand Down
6 changes: 0 additions & 6 deletions docs/signals.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,6 @@ saving a historical record. Arguments passed to the signals include the followin
For Many To Many signals you've got the following :

.. glossary::
instance
The source model instance being saved

history_instance
The corresponding history record

rows (for pre_create)
The elements to be bulk inserted into the m2m table

Expand Down
52 changes: 52 additions & 0 deletions simple_history/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
from django.db.models.fields.related import ForeignKey
from django.db.models.fields.related_descriptors import (
ForwardManyToOneDescriptor,
ForwardOneToOneDescriptor,
ReverseManyToOneDescriptor,
ReverseOneToOneDescriptor,
create_reverse_many_to_one_manager,
)
from django.db.models.query import QuerySet
Expand Down Expand Up @@ -922,6 +924,56 @@ class HistoricForeignKey(ForeignKey):
related_accessor_class = HistoricReverseManyToOneDescriptor


class HistoricForwardOneToOneDescriptor(ForwardOneToOneDescriptor):

def get_queryset(self, **hints) -> QuerySet:
instance = hints.get("instance")
if instance:
history = getattr(instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME, None)
histmgr = getattr(
self.field.remote_field.model,
getattr(
self.field.remote_field.model._meta,
"simple_history_manager_attribute",
"_notthere",
),
None,
)
if history and histmgr:
return histmgr.as_of(getattr(history, "_as_of", history.history_date))
return super().get_queryset(**hints)


class HistoricReverseOneToOneDescriptor(ReverseOneToOneDescriptor):
"""
Overrides get_queryset to provide historic query support, should the
instance be historic (and therefore was generated by a timepoint query)
and the other side of the relation also uses a history manager.
"""

def get_queryset(self, **hints):
instance = hints.get("instance")
if instance:
history = getattr(instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME, None)
histmgr = getattr(
self.related.related_model,
getattr(
self.related.related_model._meta,
"simple_history_manager_attribute",
"_notthere",
),
None,
)
if history and histmgr:
return histmgr.as_of(getattr(history, "_as_of", history.history_date))
return super().get_queryset(**hints)


class HistoricOneToOneField(models.OneToOneField):
forward_related_accessor_class = HistoricForwardOneToOneDescriptor
related_accessor_class = HistoricReverseOneToOneDescriptor


def is_historic(instance):
"""
Returns True if the instance was acquired with an as_of timepoint.
Expand Down
61 changes: 60 additions & 1 deletion simple_history/tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@

from simple_history import register
from simple_history.manager import HistoricalQuerySet, HistoryManager
from simple_history.models import HistoricalRecords, HistoricForeignKey
from simple_history.models import (
HistoricalRecords,
HistoricForeignKey,
HistoricOneToOneField,
)

from .custom_user.models import CustomUser as User
from .external.models import AbstractExternal, AbstractExternal2, AbstractExternal3
Expand Down Expand Up @@ -983,3 +987,58 @@ class TestHistoricParticipanToHistoricOrganization(models.Model):
related_name="historic_participants",
)
history = HistoricalRecords()


class TestParticipantToHistoricOrganizationOneToOne(models.Model):
"""
Non-historic table foreign key to historic table.

In this case it should simply behave like ForeignKey because
the origin model (this one) cannot be historic, so foreign key
lookups are always "current".
"""

name = models.CharField(max_length=15, unique=True)
organization = HistoricOneToOneField(
TestOrganizationWithHistory, on_delete=CASCADE, related_name="participant"
)


class TestHistoricParticipantToOrganizationOneToOne(models.Model):
"""
Historic table foreign key to non-historic table.

In this case it should simply behave like ForeignKey because
the origin model (this one) can be historic but the target model
is not, so foreign key lookups are always "current".
"""

name = models.CharField(max_length=15, unique=True)
organization = HistoricOneToOneField(
TestOrganization, on_delete=CASCADE, related_name="participant"
)
history = HistoricalRecords()


class TestHistoricParticipanToHistoricOrganizationOneToOne(models.Model):
"""
Historic table foreign key to historic table.

In this case as_of queries on the origin model (this one)
or on the target model (the other one) will traverse the
foreign key relationship honoring the timepoint of the
original query. This only happens when both tables involved
are historic.

NOTE: related_name has to be different than the one used in
TestParticipantToHistoricOrganization as they are
sharing the same target table.
"""

name = models.CharField(max_length=15, unique=True)
organization = HistoricOneToOneField(
TestOrganizationWithHistory,
on_delete=CASCADE,
related_name="historic_participant",
)
history = HistoricalRecords()
132 changes: 132 additions & 0 deletions simple_history/tests/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,13 @@
Street,
Temperature,
TestHistoricParticipanToHistoricOrganization,
TestHistoricParticipanToHistoricOrganizationOneToOne,
TestHistoricParticipantToOrganization,
TestHistoricParticipantToOrganizationOneToOne,
TestOrganization,
TestOrganizationWithHistory,
TestParticipantToHistoricOrganization,
TestParticipantToHistoricOrganizationOneToOne,
UnicodeVerboseName,
UnicodeVerboseNamePlural,
UserTextFieldChangeReasonModel,
Expand Down Expand Up @@ -2841,3 +2844,132 @@ def test_historic_to_historic(self):
)[0]
pt1i = pt1h.instance
self.assertEqual(pt1i.organization.name, "original")


class HistoricOneToOneFieldTest(TestCase):
"""
Tests chasing OneToOne foreign keys across time points naturally with
HistoricForeignKey.
"""

def test_non_historic_to_historic(self):
"""
Non-historic table foreign key to historic table.

In this case it should simply behave like OneToOneField because
the origin model (this one) cannot be historic, so OneToOneField
lookups are always "current".
"""
org = TestOrganizationWithHistory.objects.create(name="original")
part = TestParticipantToHistoricOrganizationOneToOne.objects.create(
name="part", organization=org
)
before_mod = timezone.now()
self.assertEqual(part.organization.id, org.id)
self.assertEqual(org.participant, part)

historg = TestOrganizationWithHistory.history.as_of(before_mod).get(
name="original"
)
self.assertEqual(historg.participant, part)

self.assertEqual(org.history.count(), 1)
org.name = "modified"
org.save()
self.assertEqual(org.history.count(), 2)

# drop internal caches, re-select
part = TestParticipantToHistoricOrganizationOneToOne.objects.get(name="part")
self.assertEqual(part.organization.name, "modified")

def test_historic_to_non_historic(self):
"""
Historic table OneToOneField to non-historic table.

In this case it should simply behave like OneToOneField because
the origin model (this one) can be historic but the target model
is not, so foreign key lookups are always "current".
"""
org = TestOrganization.objects.create(name="org")
part = TestHistoricParticipantToOrganizationOneToOne.objects.create(
name="original", organization=org
)
self.assertEqual(part.organization.id, org.id)
self.assertEqual(org.participant, part)

histpart = TestHistoricParticipantToOrganizationOneToOne.objects.get(
name="original"
)
self.assertEqual(histpart.organization.id, org.id)

def test_historic_to_historic(self):
"""
Historic table foreign key to historic table.

In this case as_of queries on the origin model (this one)
or on the target model (the other one) will traverse the
foreign key relationship honoring the timepoint of the
original query. This only happens when both tables involved
are historic.

At t1 we have one org, one participant.
At t2 we have one org, one participant, however the org's name has changed.
"""
org = TestOrganizationWithHistory.objects.create(name="original")

p1 = TestHistoricParticipanToHistoricOrganizationOneToOne.objects.create(
name="p1", organization=org
)
t1 = timezone.now()
org.name = "modified"
org.save()
p1.name = "p1_modified"
p1.save()
t2 = timezone.now()

# forward relationships - see how natural chasing timepoint relations is
p1t1 = TestHistoricParticipanToHistoricOrganizationOneToOne.history.as_of(
t1
).get(name="p1")
self.assertEqual(p1t1.organization, org)
self.assertEqual(p1t1.organization.name, "original")
p1t2 = TestHistoricParticipanToHistoricOrganizationOneToOne.history.as_of(
t2
).get(name="p1_modified")
self.assertEqual(p1t2.organization, org)
self.assertEqual(p1t2.organization.name, "modified")

# reverse relationships
# at t1
ot1 = TestOrganizationWithHistory.history.as_of(t1).all()[0]
self.assertEqual(ot1.historic_participant.name, "p1")

# at t2
ot2 = TestOrganizationWithHistory.history.as_of(t2).all()[0]
self.assertEqual(ot2.historic_participant.name, "p1_modified")

# current
self.assertEqual(org.historic_participant.name, "p1_modified")

self.assertTrue(is_historic(ot1))
self.assertFalse(is_historic(org))

self.assertIsInstance(
to_historic(ot1), TestOrganizationWithHistory.history.model
)
self.assertIsNone(to_historic(org))

# test querying directly from the history table and converting
# to an instance, it should chase the foreign key properly
# in this case if _as_of is not present we use the history_date
# https://github.com/jazzband/django-simple-history/issues/983
pt1h = TestHistoricParticipanToHistoricOrganizationOneToOne.history.all()[0]
pt1i = pt1h.instance
self.assertEqual(pt1i.organization.name, "modified")
pt1h = (
TestHistoricParticipanToHistoricOrganizationOneToOne.history.all().order_by(
"history_date"
)[0]
)
pt1i = pt1h.instance
self.assertEqual(pt1i.organization.name, "original")