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

Add bulk_update_with_history #650

Merged
merged 8 commits into from
Apr 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
Changes
=======

Unreleased
------------
- Added `bulk_update_with_history` utility function (gh-650)

2.9.0 (2020-04-23)
------------------
- Add simple filtering if provided a minutes argument in `clean_duplicate_history` (gh-606)
Expand Down
28 changes: 24 additions & 4 deletions docs/common_issues.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Bulk Creating and Queryset Updating
-----------------------------------
``django-simple-history`` functions by saving history using a ``post_save`` signal
every time that an object with history is saved. However, for certain bulk
operations, such as bulk_create_ and `queryset updates <https://docs.djangoproject.com/en/2.0/ref/models/querysets/#update>`_,
operations, such as bulk_create_, bulk_update_, and `queryset updates`_,
signals are not sent, and the history is not saved automatically. However,
``django-simple-history`` provides utility functions to work around this.

Expand All @@ -16,6 +16,7 @@ As of ``django-simple-history`` 2.2.0, we can use the utility function
history:

.. _bulk_create: https://docs.djangoproject.com/en/2.0/ref/models/querysets/#bulk-create
.. _bulk_update: https://docs.djangoproject.com/en/3.0/ref/models/querysets/#bulk-update


.. code-block:: pycon
Expand All @@ -42,9 +43,27 @@ can add `changeReason` on each instance:
>>> Poll.history.get(id=data[0].id).history_change_reason
'reason'

Bulk Updating a Model with History (New)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Bulk update was introduced with Django 2.2. We can use the utility function
``bulk_update_with_history`` in order to bulk update objects using Django's ``bulk_update`` function while saving the object history:

QuerySet Updates with History
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. code-block:: pycon

>>> from simple_history.utils import bulk_create_with_history
>>> from simple_history.tests.models import Poll
>>> from django.utils.timezone import now
>>>
>>> data = [Poll(id=x, question='Question ' + str(x), pub_date=now()) for x in range(1000)]
>>> objs = bulk_create_with_history(data, Poll, batch_size=500)
>>> for obj in objs: obj.question = 'Duplicate Questions'
>>> bulk_update_with_history(objs, Poll, ['question'], batch_size=500)
>>> Poll.objects.first().question
'Duplicate Question'

QuerySet Updates with History (Updated in Django 2.2)
jihoon796 marked this conversation as resolved.
Show resolved Hide resolved
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Unlike with ``bulk_create``, `queryset updates`_ perform an SQL update query on
the queryset, and never return the actual updated objects (which would be
necessary for the inserts into the historical table). Thus, we tell you that
Expand All @@ -60,8 +79,9 @@ As the Django documentation says::
e.comments_on = False
e.save()

.. _queryset updates: https://docs.djangoproject.com/en/2.0/ref/models/querysets/#update
.. _queryset updates: https://docs.djangoproject.com/en/2.2/ref/models/querysets/#update

Note: Django 2.2 now allows ``bulk_update``. No ``pre_save`` or ``post_save`` signals are sent still.

Tracking Custom Users
---------------------
Expand Down
14 changes: 11 additions & 3 deletions simple_history/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,24 @@ def _as_of_set(self, date):
continue
yield last_change.instance

def bulk_history_create(self, objs, batch_size=None):
"""Bulk create the history for the objects specified by objs"""
def bulk_history_create(self, objs, batch_size=None, update=False):
jihoon796 marked this conversation as resolved.
Show resolved Hide resolved
"""
Bulk create the history for the objects specified by objs.
If called by bulk_update_with_history, use the update boolean and
save the history_type accordingly.
"""

history_type = "+"
if update:
history_type = "~"

historical_instances = []
for instance in objs:
row = self.model(
history_date=getattr(instance, "_history_date", timezone.now()),
history_user=getattr(instance, "_history_user", None),
history_change_reason=getattr(instance, "changeReason", ""),
history_type="+",
history_type=history_type,
**{
field.attname: getattr(instance, field.attname)
for field in instance._meta.fields
Expand Down
41 changes: 41 additions & 0 deletions simple_history/tests/tests/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,44 @@ def test_set_custom_history_user_on_first_obj(self):
def test_efficiency(self):
with self.assertNumQueries(1):
Poll.history.bulk_history_create(self.data)


class BulkHistoryUpdateTestCase(TestCase):
def setUp(self):
self.data = [
Poll(id=1, question="Question 1", pub_date=datetime.now()),
Poll(id=2, question="Question 2", pub_date=datetime.now()),
Poll(id=3, question="Question 3", pub_date=datetime.now()),
Poll(id=4, question="Question 4", pub_date=datetime.now()),
]

def test_simple_bulk_history_create(self):
created = Poll.history.bulk_history_create(self.data, update=True)
self.assertEqual(len(created), 4)
self.assertQuerysetEqual(
Poll.history.order_by("question"),
["Question 1", "Question 2", "Question 3", "Question 4"],
attrgetter("question"),
)
self.assertTrue(
all([history.history_type == "~" for history in Poll.history.all()])
)

created = Poll.history.bulk_create([])
self.assertEqual(created, [])
self.assertEqual(Poll.history.count(), 4)

def test_bulk_history_create_with_change_reason(self):
for poll in self.data:
poll.changeReason = "reason"

Poll.history.bulk_history_create(self.data)

self.assertTrue(
all(
[
history.history_change_reason == "reason"
for history in Poll.history.all()
]
)
)
21 changes: 21 additions & 0 deletions simple_history/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import django
from django.db import transaction
from django.forms.models import model_to_dict

Expand Down Expand Up @@ -72,3 +73,23 @@ def bulk_create_with_history(objs, model, batch_size=None):
history_manager.bulk_history_create(obj_list, batch_size=batch_size)
objs_with_id = obj_list
return objs_with_id


def bulk_update_with_history(objs, model, fields, batch_size=None):
"""
Bulk update the objects specified by objs while also bulk creating
their history (all in one transaction).
:param objs: List of objs of type model to be updated
:param model: Model class that should be updated
:param fields: The fields that are updated
:param batch_size: Number of objects that should be updated in each batch
"""
if django.VERSION < (2, 2,):
raise NotImplementedError(
"bulk_update_with_history is only available on "
"Django versions 2.2 and later"
)
history_manager = get_history_manager_for_model(model)
with transaction.atomic(savepoint=False):
model.objects.bulk_update(objs, fields, batch_size=batch_size)
history_manager.bulk_history_create(objs, batch_size=batch_size, update=True)