Skip to content

Commit 3f73db3

Browse files
authored
Add bulk_update_with_history (#650)
* Add bulk_update_with_history feature Using Django 2.2's bulk_update feature. This is significantly simpler than bulk_create_with_history because we don't have to worry about the primary key issue. * Add support for bulk_update_with_history * Added to the docstring. * initial tests for bulk_update_with_history * Update documentation for bulk_update_with_history * run make format * fix trailing whitespace * update this PR so that it's still compatible with django versions 1.11, 2.0 and 2.1
1 parent 67e8240 commit 3f73db3

File tree

5 files changed

+101
-7
lines changed

5 files changed

+101
-7
lines changed

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
Changes
22
=======
33

4+
Unreleased
5+
------------
6+
- Added `bulk_update_with_history` utility function (gh-650)
7+
48
2.9.0 (2020-04-23)
59
------------------
610
- Add simple filtering if provided a minutes argument in `clean_duplicate_history` (gh-606)

docs/common_issues.rst

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Bulk Creating and Queryset Updating
55
-----------------------------------
66
``django-simple-history`` functions by saving history using a ``post_save`` signal
77
every time that an object with history is saved. However, for certain bulk
8-
operations, such as bulk_create_ and `queryset updates <https://docs.djangoproject.com/en/2.0/ref/models/querysets/#update>`_,
8+
operations, such as bulk_create_, bulk_update_, and `queryset updates`_,
99
signals are not sent, and the history is not saved automatically. However,
1010
``django-simple-history`` provides utility functions to work around this.
1111

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

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

2021

2122
.. code-block:: pycon
@@ -42,9 +43,27 @@ can add `changeReason` on each instance:
4243
>>> Poll.history.get(id=data[0].id).history_change_reason
4344
'reason'
4445
46+
Bulk Updating a Model with History (New)
47+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
48+
Bulk update was introduced with Django 2.2. We can use the utility function
49+
``bulk_update_with_history`` in order to bulk update objects using Django's ``bulk_update`` function while saving the object history:
4550

46-
QuerySet Updates with History
47-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
51+
52+
.. code-block:: pycon
53+
54+
>>> from simple_history.utils import bulk_create_with_history
55+
>>> from simple_history.tests.models import Poll
56+
>>> from django.utils.timezone import now
57+
>>>
58+
>>> data = [Poll(id=x, question='Question ' + str(x), pub_date=now()) for x in range(1000)]
59+
>>> objs = bulk_create_with_history(data, Poll, batch_size=500)
60+
>>> for obj in objs: obj.question = 'Duplicate Questions'
61+
>>> bulk_update_with_history(objs, Poll, ['question'], batch_size=500)
62+
>>> Poll.objects.first().question
63+
'Duplicate Question'
64+
65+
QuerySet Updates with History (Updated in Django 2.2)
66+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4867
Unlike with ``bulk_create``, `queryset updates`_ perform an SQL update query on
4968
the queryset, and never return the actual updated objects (which would be
5069
necessary for the inserts into the historical table). Thus, we tell you that
@@ -60,8 +79,9 @@ As the Django documentation says::
6079
e.comments_on = False
6180
e.save()
6281
63-
.. _queryset updates: https://docs.djangoproject.com/en/2.0/ref/models/querysets/#update
82+
.. _queryset updates: https://docs.djangoproject.com/en/2.2/ref/models/querysets/#update
6483

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

6686
Tracking Custom Users
6787
---------------------

simple_history/manager.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,24 @@ def _as_of_set(self, date):
9898
continue
9999
yield last_change.instance
100100

101-
def bulk_history_create(self, objs, batch_size=None):
102-
"""Bulk create the history for the objects specified by objs"""
101+
def bulk_history_create(self, objs, batch_size=None, update=False):
102+
"""
103+
Bulk create the history for the objects specified by objs.
104+
If called by bulk_update_with_history, use the update boolean and
105+
save the history_type accordingly.
106+
"""
107+
108+
history_type = "+"
109+
if update:
110+
history_type = "~"
103111

104112
historical_instances = []
105113
for instance in objs:
106114
row = self.model(
107115
history_date=getattr(instance, "_history_date", timezone.now()),
108116
history_user=getattr(instance, "_history_user", None),
109117
history_change_reason=getattr(instance, "changeReason", ""),
110-
history_type="+",
118+
history_type=history_type,
111119
**{
112120
field.attname: getattr(instance, field.attname)
113121
for field in instance._meta.fields

simple_history/tests/tests/test_manager.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,44 @@ def test_set_custom_history_user_on_first_obj(self):
158158
def test_efficiency(self):
159159
with self.assertNumQueries(1):
160160
Poll.history.bulk_history_create(self.data)
161+
162+
163+
class BulkHistoryUpdateTestCase(TestCase):
164+
def setUp(self):
165+
self.data = [
166+
Poll(id=1, question="Question 1", pub_date=datetime.now()),
167+
Poll(id=2, question="Question 2", pub_date=datetime.now()),
168+
Poll(id=3, question="Question 3", pub_date=datetime.now()),
169+
Poll(id=4, question="Question 4", pub_date=datetime.now()),
170+
]
171+
172+
def test_simple_bulk_history_create(self):
173+
created = Poll.history.bulk_history_create(self.data, update=True)
174+
self.assertEqual(len(created), 4)
175+
self.assertQuerysetEqual(
176+
Poll.history.order_by("question"),
177+
["Question 1", "Question 2", "Question 3", "Question 4"],
178+
attrgetter("question"),
179+
)
180+
self.assertTrue(
181+
all([history.history_type == "~" for history in Poll.history.all()])
182+
)
183+
184+
created = Poll.history.bulk_create([])
185+
self.assertEqual(created, [])
186+
self.assertEqual(Poll.history.count(), 4)
187+
188+
def test_bulk_history_create_with_change_reason(self):
189+
for poll in self.data:
190+
poll.changeReason = "reason"
191+
192+
Poll.history.bulk_history_create(self.data)
193+
194+
self.assertTrue(
195+
all(
196+
[
197+
history.history_change_reason == "reason"
198+
for history in Poll.history.all()
199+
]
200+
)
201+
)

simple_history/utils.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import django
12
from django.db import transaction
23
from django.forms.models import model_to_dict
34

@@ -72,3 +73,23 @@ def bulk_create_with_history(objs, model, batch_size=None):
7273
history_manager.bulk_history_create(obj_list, batch_size=batch_size)
7374
objs_with_id = obj_list
7475
return objs_with_id
76+
77+
78+
def bulk_update_with_history(objs, model, fields, batch_size=None):
79+
"""
80+
Bulk update the objects specified by objs while also bulk creating
81+
their history (all in one transaction).
82+
:param objs: List of objs of type model to be updated
83+
:param model: Model class that should be updated
84+
:param fields: The fields that are updated
85+
:param batch_size: Number of objects that should be updated in each batch
86+
"""
87+
if django.VERSION < (2, 2,):
88+
raise NotImplementedError(
89+
"bulk_update_with_history is only available on "
90+
"Django versions 2.2 and later"
91+
)
92+
history_manager = get_history_manager_for_model(model)
93+
with transaction.atomic(savepoint=False):
94+
model.objects.bulk_update(objs, fields, batch_size=batch_size)
95+
history_manager.bulk_history_create(objs, batch_size=batch_size, update=True)

0 commit comments

Comments
 (0)