Skip to content

Commit 2b0c474

Browse files
committed
Merge remote-tracking branch 'vkf/exclude_fields'.
2 parents 3f8bc66 + d907949 commit 2b0c474

File tree

3 files changed

+63
-27
lines changed

3 files changed

+63
-27
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Combine bulk create, update, and delete. Make the DB match a set of in-memory ob
7474
- `filters`: Q() filters specifying the subset of the database to work in. Use `None` or `[]` if you want to sync against the entire table.
7575
- `batch_size`: (optional) passes through to Django `bulk_create.batch_size` and `bulk_update.batch_size`, and controls how many objects are created/updated per SQL query.
7676
- `fields`: (optional) List of fields to update. If not set, will sync all fields that are editable and not auto-created.
77+
- `exclude_fields`: (optional) List of fields to exclude from updates.
7778
- `skip_creates`: (optional) If truthy, will not perform any object creations needed to fully sync. Defaults to not skip.
7879
- `skip_updates`: (optional) If truthy, will not perform any object updates needed to fully sync. Defaults to not skip.
7980
- `skip_deletes`: (optional) If truthy, will not perform any object deletions needed to fully sync. Defaults to not skip.

bulk_sync/__init__.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from collections import OrderedDict
22
import logging
3+
34
from django.db import transaction, router
5+
from django.core.exceptions import FieldDoesNotExist
46

57
logger = logging.getLogger(__name__)
68

@@ -11,6 +13,7 @@ def bulk_sync(
1113
filters,
1214
batch_size=None,
1315
fields=None,
16+
exclude_fields=None,
1417
skip_creates=False,
1518
skip_updates=False,
1619
skip_deletes=False,
@@ -20,10 +23,13 @@ def bulk_sync(
2023
`new_models`: Django ORM objects that are the desired state. They may or may not have `id` set.
2124
`key_fields`: Identifying attribute name(s) to match up `new_models` items with database rows. If a foreign key
2225
is being used as a key field, be sure to pass the `fieldname_id` rather than the `fieldname`.
23-
`filters`: Q() filters specifying the subset of the database to work in. Use `None` or `[]` if you want to sync against the entire table.
26+
`filters`: Q() filters specifying the subset of the database to work in. Use `None` or `[]` if you want to sync
27+
against the entire table.
2428
`batch_size`: (optional) passes through to Django `bulk_create.batch_size` and `bulk_update.batch_size`, and controls
2529
how many objects are created/updated per SQL query.
26-
`fields`: (optional) list of fields to update. If not set, will sync all fields that are editable and not auto-created.
30+
`fields`: (optional) list of fields to update. If not set, will sync all fields that are editable and not
31+
auto-created.
32+
`exclude_fields`: (optional) list of fields to exclude from updates.
2733
`skip_creates`: If truthy, will not perform any object creations needed to fully sync. Defaults to not skip.
2834
`skip_updates`: If truthy, will not perform any object updates needed to fully sync. Defaults to not skip.
2935
`skip_deletes`: If truthy, will not perform any object deletions needed to fully sync. Defaults to not skip.
@@ -38,6 +44,17 @@ def bulk_sync(
3844
if not field.primary_key and not field.auto_created and field.editable
3945
]
4046

47+
if exclude_fields is not None:
48+
model_fields = set(field.name for field in db_class._meta.fields)
49+
fields_to_update = set(fields)
50+
fields_to_exclude = set(exclude_fields)
51+
52+
# Check that we're not attempting to exclude non-existent fields
53+
if not fields_to_exclude <= model_fields:
54+
raise FieldDoesNotExist(f'model "{db_class.__name__}" has no field(s) {fields_to_exclude - model_fields}')
55+
56+
fields = list(fields_to_update - fields_to_exclude)
57+
4158
using = router.db_for_write(db_class)
4259
with transaction.atomic(using=using):
4360
objs = db_class.objects.all()

tests/tests.py

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.conf import settings
22
from django.db import IntegrityError
3+
from django.core.exceptions import FieldDoesNotExist
34
from django.db.models import Q
45
from django.test import TestCase
56

@@ -78,18 +79,17 @@ def test_provided_pk_is_retained_but_raises_if_mismatch_with_keyfield(self):
7879
# Crashes because e1.id already exists in database, even though 'name' doesnt match so it tries to INSERT.
7980
ret = bulk_sync(new_models=new_objs, filters=Q(company_id=c1.id), key_fields=("name",))
8081

81-
unique_pk = Employee.objects.values_list('id', flat=True).order_by('-id').first() + 1
82+
unique_pk = Employee.objects.values_list("id", flat=True).order_by("-id").first() + 1
8283
new_objs = [Employee(id=unique_pk, name="Notscott", age=41, company=c1)]
8384
ret = bulk_sync(new_models=new_objs, filters=Q(company_id=c1.id), key_fields=("name",))
8485

8586
self.assertEqual(0, ret["stats"]["updated"])
86-
self.assertEqual(1, ret["stats"]["created"]) # Added 'Notscott'
87-
self.assertEqual(1, ret["stats"]["deleted"]) # Deleted 'Scott'
87+
self.assertEqual(1, ret["stats"]["created"]) # Added 'Notscott'
88+
self.assertEqual(1, ret["stats"]["deleted"]) # Deleted 'Scott'
8889

8990
# Make sure we retained the PK
9091
self.assertEqual(Employee.objects.filter(id=unique_pk).count(), 1)
9192

92-
9393
def test_fields_parameter(self):
9494
c1 = Company.objects.create(name="Foo Products, Ltd.")
9595
c2 = Company.objects.create(name="Bar Microcontrollers, Inc.")
@@ -98,12 +98,39 @@ def test_fields_parameter(self):
9898
e2 = Employee.objects.create(name="Isaac", age=9, company=c2)
9999

100100
# We should update Scott's age, and not touch company.
101-
new_objs = [
102-
Employee(name="Scott", age=41, company=c1),
103-
Employee(name="Isaac", age=9, company=c1),
104-
]
101+
new_objs = [Employee(name="Scott", age=41, company=c1), Employee(name="Isaac", age=9, company=c1)]
105102

106-
ret = bulk_sync(new_models=new_objs, filters=None, key_fields=("name",), fields=['age'])
103+
ret = bulk_sync(new_models=new_objs, filters=None, key_fields=("name",), fields=["age"])
104+
105+
new_e1 = Employee.objects.get(id=e1.id)
106+
self.assertEqual("Scott", new_e1.name)
107+
self.assertEqual(41, new_e1.age)
108+
self.assertEqual(c1, new_e1.company)
109+
110+
new_e2 = Employee.objects.get(id=e2.id)
111+
self.assertEqual("Isaac", new_e2.name)
112+
self.assertEqual(9, new_e2.age)
113+
self.assertEqual(c2, new_e2.company)
114+
115+
self.assertEqual(2, ret["stats"]["updated"])
116+
self.assertEqual(0, ret["stats"]["created"])
117+
self.assertEqual(0, ret["stats"]["deleted"])
118+
119+
def test_exclude_fields_parameter(self):
120+
c1 = Company.objects.create(name="Foo Products, Ltd.")
121+
c2 = Company.objects.create(name="Bar Microcontrollers, Inc.")
122+
123+
e1 = Employee.objects.create(name="Scott", age=40, company=c1)
124+
e2 = Employee.objects.create(name="Isaac", age=9, company=c2)
125+
126+
# We should update Scott's age, and not touch company.
127+
new_objs = [Employee(name="Scott", age=41, company=c1), Employee(name="Isaac", age=9, company=c1)]
128+
129+
with self.assertRaises(FieldDoesNotExist):
130+
# Crashes because we attempted to exclude a field that does not exist
131+
bulk_sync(new_models=new_objs, filters=None, key_fields=("name",), exclude_fields=["missing_field"])
132+
133+
ret = bulk_sync(new_models=new_objs, filters=None, key_fields=("name",), exclude_fields=["company"])
107134

108135
new_e1 = Employee.objects.get(id=e1.id)
109136
self.assertEqual("Scott", new_e1.name)
@@ -126,14 +153,12 @@ def test_skip_deletes(self):
126153
e2 = Employee.objects.create(name="Isaac", age=9, company=c1)
127154

128155
# update Scott - this makes Isaac is the "stale object" that would be deleted if skip_deletes were False
129-
new_objs = [
130-
Employee(name="Scott", age=41, company=c1),
131-
]
156+
new_objs = [Employee(name="Scott", age=41, company=c1)]
132157

133158
# but Isaac should remain when the skip_deletes flag is True
134159
ret = bulk_sync(new_models=new_objs, filters=None, key_fields=("name",), skip_deletes=True)
135160

136-
self.assertEqual(["Scott", "Isaac"], [x.name for x in Employee.objects.all().order_by('id')])
161+
self.assertEqual(["Scott", "Isaac"], [x.name for x in Employee.objects.all().order_by("id")])
137162

138163
new_e1 = Employee.objects.get(id=e1.id)
139164
self.assertEqual(41, new_e1.age)
@@ -151,14 +176,12 @@ def test_skip_creates(self):
151176
e2 = Employee.objects.create(name="Isaac", age=9, company=c1)
152177

153178
# create a new employee that will be ignored
154-
new_objs = [
155-
Employee(name="John", age=52, company=c1)
156-
]
179+
new_objs = [Employee(name="John", age=52, company=c1)]
157180

158181
ret = bulk_sync(new_models=new_objs, filters=None, key_fields=("name",), skip_creates=True, skip_deletes=True)
159182

160183
self.assertEqual(2, Employee.objects.count())
161-
self.assertEqual(["Scott", "Isaac"], [x.name for x in Employee.objects.all().order_by('id')])
184+
self.assertEqual(["Scott", "Isaac"], [x.name for x in Employee.objects.all().order_by("id")])
162185

163186
self.assertEqual(0, ret["stats"]["updated"])
164187
self.assertEqual(0, ret["stats"]["created"])
@@ -171,10 +194,7 @@ def test_skip_updates(self):
171194
e2 = Employee.objects.create(name="Isaac", age=9, company=c1)
172195

173196
# update employee that will be ignored, create a new one
174-
new_objs = [
175-
Employee(name="Scott", age=100, company=c1),
176-
Employee(name="Alice", age=36, company=c1)
177-
]
197+
new_objs = [Employee(name="Scott", age=100, company=c1), Employee(name="Alice", age=36, company=c1)]
178198

179199
ret = bulk_sync(new_models=new_objs, filters=None, key_fields=("name",), skip_updates=True)
180200

@@ -184,15 +204,13 @@ def test_skip_updates(self):
184204

185205
# Isaac is "stale" object - was deleted, Alice was created
186206
self.assertEqual(2, Employee.objects.count())
187-
self.assertEqual(["Scott", "Alice"], [x.name for x in Employee.objects.all().order_by('id')])
188-
207+
self.assertEqual(["Scott", "Alice"], [x.name for x in Employee.objects.all().order_by("id")])
189208

190209
self.assertEqual(0, ret["stats"]["updated"])
191210
self.assertEqual(1, ret["stats"]["created"])
192211
self.assertEqual(1, ret["stats"]["deleted"])
193212

194213

195-
196214
class BulkCompareTests(TestCase):
197215
""" Test `bulk_compare` method """
198216

@@ -261,5 +279,5 @@ def test_bulk_compare_with_ignore_relation_field(self):
261279
self.assertEqual([new_objs[2], new_objs[3]], ret["added"])
262280
self.assertEqual([e3], list(ret["removed"]))
263281
self.assertEqual([new_objs[0]], ret["updated"])
264-
self.assertEqual({new_objs[0]: {'age': (40, 41)}}, ret["updated_details"])
282+
self.assertEqual({new_objs[0]: {"age": (40, 41)}}, ret["updated_details"])
265283
self.assertEqual([new_objs[1]], ret["unchanged"])

0 commit comments

Comments
 (0)