Skip to content

Commit 2d26d51

Browse files
author
Ross Mechanic
authored
GH-380: Save history on bulk_create (#412)
* GH-380: Added bulk_create_with_history util * Updated documentation * Fixed linting issues * Added blawson's changes
1 parent 53d32cd commit 2d26d51

File tree

4 files changed

+164
-1
lines changed

4 files changed

+164
-1
lines changed

CHANGES.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ Changes
33

44
Unreleased
55
----------
6-
- Add ability to specify alternative user_model for tracking
6+
- Add ability to specify alternative user_model for tracking (gh-371)
7+
- Add util function ``bulk_create_with_history`` to allow bulk_create with history saved
78

89
2.1.1 (2018-06-15)
910
------------------

docs/usage.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,3 +311,33 @@ And to revert to that ``HistoricalPoll`` instance, we can do:
311311
This will change the ``poll`` instance to have the data from the
312312
``HistoricalPoll`` object and it will create a new row in the
313313
``HistoricalPoll`` table indicating that a new change has been made.
314+
315+
Bulk Creating and Queryset Updating
316+
-----------------------------------
317+
Django Simple History functions by saving history using a ``post_save`` signal
318+
every time that an object with history is saved. However, for certain bulk
319+
operations, such as bulk_create_ and `queryset updates <https://docs.djangoproject.com/en/2.0/ref/models/querysets/#update>`_,
320+
signals are not sent, and the history is not saved automatically. However,
321+
Django Simple History provides utility functions to work around this.
322+
323+
Bulk Creating a Model with History
324+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
325+
As of Django Simple History 2.2.0, we can use the utility function
326+
``bulk_create_with_history`` in order to bulk create objects while saving their
327+
history:
328+
329+
.. _bulk_create: https://docs.djangoproject.com/en/2.0/ref/models/querysets/#bulk-create
330+
331+
332+
.. code-block:: pycon
333+
334+
>>> from simple_history.utils import bulk_create_with_history
335+
>>> from simple_history.tests.models import Poll
336+
>>> from django.utils.timezone import now
337+
>>>
338+
>>> data = [Poll(id=x, question='Question ' + str(x), pub_date=now()) for x in range(1000)]
339+
>>> objs = bulk_create_with_history(data, Poll, batch_size=500)
340+
>>> Poll.objects.count()
341+
1000
342+
>>> Poll.history.count()
343+
1000
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from django.db import IntegrityError
2+
from django.test import TestCase, TransactionTestCase
3+
from django.utils.timezone import now
4+
from mock import Mock, patch
5+
6+
from simple_history.exceptions import NotHistoricalModelError
7+
from simple_history.tests.models import (
8+
Document,
9+
Place,
10+
Poll,
11+
PollWithExcludeFields
12+
)
13+
from simple_history.utils import bulk_create_with_history
14+
15+
16+
class BulkCreateWithHistoryTestCase(TestCase):
17+
def setUp(self):
18+
self.data = [
19+
Poll(id=1, question='Question 1', pub_date=now()),
20+
Poll(id=2, question='Question 2', pub_date=now()),
21+
Poll(id=3, question='Question 3', pub_date=now()),
22+
Poll(id=4, question='Question 4', pub_date=now()),
23+
Poll(id=5, question='Question 5', pub_date=now()),
24+
]
25+
26+
def test_bulk_create_history(self):
27+
bulk_create_with_history(self.data, Poll)
28+
29+
self.assertEqual(Poll.objects.count(), 5)
30+
self.assertEqual(Poll.history.count(), 5)
31+
32+
def test_bulk_create_history_num_queries_is_two(self):
33+
with self.assertNumQueries(2):
34+
bulk_create_with_history(self.data, Poll)
35+
36+
def test_bulk_create_history_on_model_without_history_raises_error(self):
37+
self.data = [
38+
Place(id=1, name='Place 1'),
39+
Place(id=2, name='Place 2'),
40+
Place(id=3, name='Place 3'),
41+
]
42+
with self.assertRaises(NotHistoricalModelError):
43+
bulk_create_with_history(self.data, Place)
44+
45+
def test_num_queries_when_batch_size_is_less_than_total(self):
46+
with self.assertNumQueries(6):
47+
bulk_create_with_history(self.data, Poll, batch_size=2)
48+
49+
def test_bulk_create_history_with_batch_size(self):
50+
bulk_create_with_history(self.data, Poll, batch_size=2)
51+
52+
self.assertEqual(Poll.objects.count(), 5)
53+
self.assertEqual(Poll.history.count(), 5)
54+
55+
def test_bulk_create_works_with_excluded_fields(self):
56+
bulk_create_with_history(self.data, PollWithExcludeFields)
57+
58+
self.assertEqual(Poll.objects.count(), 0)
59+
self.assertEqual(Poll.history.count(), 0)
60+
61+
self.assertEqual(PollWithExcludeFields.objects.count(), 5)
62+
self.assertEqual(PollWithExcludeFields.history.count(), 5)
63+
64+
65+
class BulkCreateWithHistoryTransactionTestCase(TransactionTestCase):
66+
def setUp(self):
67+
self.data = [
68+
Poll(id=1, question='Question 1', pub_date=now()),
69+
Poll(id=2, question='Question 2', pub_date=now()),
70+
Poll(id=3, question='Question 3', pub_date=now()),
71+
Poll(id=4, question='Question 4', pub_date=now()),
72+
Poll(id=5, question='Question 5', pub_date=now()),
73+
]
74+
75+
@patch('simple_history.manager.HistoryManager.bulk_history_create',
76+
Mock(side_effect=Exception))
77+
def test_transaction_rolls_back_if_bulk_history_create_fails(self):
78+
with self.assertRaises(Exception):
79+
bulk_create_with_history(self.data, Poll)
80+
81+
self.assertEqual(Poll.objects.count(), 0)
82+
self.assertEqual(Poll.history.count(), 0)
83+
84+
def test_bulk_create_history_on_objects_that_already_exist(self):
85+
Poll.objects.bulk_create(self.data)
86+
87+
with self.assertRaises(IntegrityError):
88+
bulk_create_with_history(self.data, Poll)
89+
90+
self.assertEqual(Poll.objects.count(), 5)
91+
self.assertEqual(Poll.history.count(), 0)
92+
93+
def test_bulk_create_history_rolls_back_when_last_exists(self):
94+
Poll.objects.create(id=5, question='Question 5', pub_date=now())
95+
96+
self.assertEqual(Poll.objects.count(), 1)
97+
self.assertEqual(Poll.history.count(), 1)
98+
99+
with self.assertRaises(IntegrityError):
100+
bulk_create_with_history(self.data, Poll, batch_size=1)
101+
102+
self.assertEqual(Poll.objects.count(), 1)
103+
self.assertEqual(Poll.history.count(), 1)
104+
105+
def test_bulk_create_fails_with_wrong_model(self):
106+
with self.assertRaises(AttributeError):
107+
bulk_create_with_history(self.data, Document)
108+
109+
self.assertEqual(Poll.objects.count(), 0)
110+
self.assertEqual(Poll.history.count(), 0)

simple_history/utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from django.db import transaction
2+
13
from simple_history.exceptions import NotHistoricalModelError
24

35

@@ -31,3 +33,23 @@ def get_history_manager_for_model(model):
3133
def get_history_model_for_model(model):
3234
"""Return the history model for a given app model."""
3335
return get_history_manager_for_model(model).model
36+
37+
38+
def bulk_create_with_history(objs, model, batch_size=None):
39+
"""
40+
Bulk create the objects specified by objs while also bulk creating
41+
their history (all in one transaction).
42+
:param objs: List of objs (not yet saved to the db) of type model
43+
:param model: Model class that should be created
44+
:param batch_size: Number of objects that should be created in each batch
45+
:return: List of objs with IDs
46+
"""
47+
48+
history_manager = get_history_manager_for_model(model)
49+
50+
with transaction.atomic(savepoint=False):
51+
objs_with_id = model.objects.bulk_create(objs, batch_size=batch_size)
52+
history_manager.bulk_history_create(objs_with_id,
53+
batch_size=batch_size)
54+
55+
return objs_with_id

0 commit comments

Comments
 (0)