django-bulk-hooks 0.1.122__tar.gz → 0.1.124__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of django-bulk-hooks might be problematic. Click here for more details.

Files changed (18) hide show
  1. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/PKG-INFO +1 -1
  2. django_bulk_hooks-0.1.124/django_bulk_hooks/manager.py +83 -0
  3. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/django_bulk_hooks/queryset.py +35 -24
  4. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/pyproject.toml +1 -1
  5. django_bulk_hooks-0.1.122/django_bulk_hooks/manager.py +0 -201
  6. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/LICENSE +0 -0
  7. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/README.md +0 -0
  8. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/django_bulk_hooks/__init__.py +0 -0
  9. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/django_bulk_hooks/conditions.py +0 -0
  10. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/django_bulk_hooks/constants.py +0 -0
  11. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/django_bulk_hooks/context.py +0 -0
  12. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/django_bulk_hooks/decorators.py +0 -0
  13. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/django_bulk_hooks/engine.py +0 -0
  14. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/django_bulk_hooks/enums.py +0 -0
  15. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/django_bulk_hooks/handler.py +0 -0
  16. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/django_bulk_hooks/models.py +0 -0
  17. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/django_bulk_hooks/priority.py +0 -0
  18. {django_bulk_hooks-0.1.122 → django_bulk_hooks-0.1.124}/django_bulk_hooks/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.122
3
+ Version: 0.1.124
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -0,0 +1,83 @@
1
+ from django.db import models
2
+
3
+ from django_bulk_hooks.queryset import HookQuerySet
4
+
5
+
6
+ class BulkHookManager(models.Manager):
7
+ def get_queryset(self):
8
+ return HookQuerySet(self.model, using=self._db)
9
+
10
+ def bulk_update(
11
+ self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
12
+ ):
13
+ """
14
+ Delegate to QuerySet's bulk_update implementation.
15
+ This follows Django's pattern where Manager methods call QuerySet methods.
16
+ """
17
+ return self.get_queryset().bulk_update(
18
+ objs, fields, bypass_hooks=bypass_hooks, bypass_validation=bypass_validation, **kwargs
19
+ )
20
+
21
+ def bulk_create(
22
+ self,
23
+ objs,
24
+ batch_size=None,
25
+ ignore_conflicts=False,
26
+ update_conflicts=False,
27
+ update_fields=None,
28
+ unique_fields=None,
29
+ bypass_hooks=False,
30
+ bypass_validation=False,
31
+ ):
32
+ """
33
+ Delegate to QuerySet's bulk_create implementation.
34
+ This follows Django's pattern where Manager methods call QuerySet methods.
35
+ """
36
+ return self.get_queryset().bulk_create(
37
+ objs,
38
+ batch_size=batch_size,
39
+ ignore_conflicts=ignore_conflicts,
40
+ update_conflicts=update_conflicts,
41
+ update_fields=update_fields,
42
+ unique_fields=unique_fields,
43
+ bypass_hooks=bypass_hooks,
44
+ bypass_validation=bypass_validation,
45
+ )
46
+
47
+ def bulk_delete(
48
+ self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
49
+ ):
50
+ """
51
+ Delegate to QuerySet's bulk_delete implementation.
52
+ This follows Django's pattern where Manager methods call QuerySet methods.
53
+ """
54
+ return self.get_queryset().bulk_delete(
55
+ objs, batch_size=batch_size, bypass_hooks=bypass_hooks, bypass_validation=bypass_validation
56
+ )
57
+
58
+ def update(self, **kwargs):
59
+ """
60
+ Delegate to QuerySet's update implementation.
61
+ This follows Django's pattern where Manager methods call QuerySet methods.
62
+ """
63
+ return self.get_queryset().update(**kwargs)
64
+
65
+ def delete(self):
66
+ """
67
+ Delegate to QuerySet's delete implementation.
68
+ This follows Django's pattern where Manager methods call QuerySet methods.
69
+ """
70
+ return self.get_queryset().delete()
71
+
72
+ def save(self, obj):
73
+ """
74
+ Save a single object using the appropriate bulk operation.
75
+ """
76
+ if obj.pk:
77
+ self.bulk_update(
78
+ [obj],
79
+ fields=[field.name for field in obj._meta.fields if field.name != "id"],
80
+ )
81
+ else:
82
+ self.bulk_create([obj])
83
+ return obj
@@ -1,10 +1,6 @@
1
- from django.db import models, transaction, connections
2
- from django.db.models import AutoField, Q, Max
3
- from django.db import NotSupportedError
4
- from django.db.models.constants import OnConflict
5
- from django.db.models.expressions import DatabaseDefault
6
- import operator
7
- from functools import reduce
1
+
2
+ from django.db import models, transaction
3
+ from django.db.models import AutoField
8
4
 
9
5
  from django_bulk_hooks import engine
10
6
  from django_bulk_hooks.constants import (
@@ -303,8 +299,11 @@ class HookQuerySet(models.QuerySet):
303
299
  """
304
300
  Process a single batch of objects through the inheritance chain.
305
301
  """
306
- # Step 1: Handle parent tables with individual saves (needed for PKs)
302
+ # Step 1: Handle parent tables with bulk operations where possible
307
303
  parent_objects_map = {}
304
+
305
+ # Group parent objects by model class to enable bulk operations
306
+ parent_objects_by_model = {}
308
307
  for obj in batch:
309
308
  parent_instances = {}
310
309
  current_parent = None
@@ -312,10 +311,24 @@ class HookQuerySet(models.QuerySet):
312
311
  parent_obj = self._create_parent_instance(
313
312
  obj, model_class, current_parent
314
313
  )
315
- parent_obj.save()
316
314
  parent_instances[model_class] = parent_obj
317
315
  current_parent = parent_obj
316
+
317
+ # Group by model class for potential bulk operations
318
+ if model_class not in parent_objects_by_model:
319
+ parent_objects_by_model[model_class] = []
320
+ parent_objects_by_model[model_class].append(parent_obj)
321
+
318
322
  parent_objects_map[id(obj)] = parent_instances
323
+
324
+ # Save parent objects in bulk where possible
325
+ for model_class, parent_objs in parent_objects_by_model.items():
326
+ if len(parent_objs) > 1:
327
+ # Use bulk_create for multiple objects of the same model
328
+ model_class._base_manager.bulk_create(parent_objs)
329
+ else:
330
+ # Individual save for single objects
331
+ super(parent_objs[0].__class__, parent_objs[0]).save()
319
332
  # Step 2: Bulk insert for child objects
320
333
  child_model = inheritance_chain[-1]
321
334
  child_objects = []
@@ -324,22 +337,8 @@ class HookQuerySet(models.QuerySet):
324
337
  obj, child_model, parent_objects_map.get(id(obj), {})
325
338
  )
326
339
  child_objects.append(child_obj)
327
- # If the child model is still MTI, call our own logic recursively
340
+ # If the child model is still MTI, we need to handle it specially
328
341
  if len([p for p in child_model._meta.parents.keys() if not p._meta.proxy]) > 0:
329
- # Build inheritance chain for the child model
330
- child_inheritance_chain = []
331
- current_model = child_model
332
- while current_model:
333
- if not current_model._meta.proxy:
334
- child_inheritance_chain.append(current_model)
335
- parents = [
336
- parent
337
- for parent in current_model._meta.parents.keys()
338
- if not parent._meta.proxy
339
- ]
340
- current_model = parents[0] if parents else None
341
- child_inheritance_chain.reverse()
342
-
343
342
  # For nested MTI, we can't use bulk operations recursively
344
343
  # because it would create infinite recursion. Instead, we save each child individually.
345
344
  # We use super().save() to avoid triggering hooks that would cause recursion.
@@ -388,6 +387,12 @@ class HookQuerySet(models.QuerySet):
388
387
  elif hasattr(field, 'auto_now') and field.auto_now:
389
388
  field.pre_save(parent_obj, add=True)
390
389
 
390
+ # Ensure auto_now_add fields are explicitly set to prevent null constraint violations
391
+ for field in parent_model._meta.local_fields:
392
+ if hasattr(field, 'auto_now_add') and field.auto_now_add:
393
+ if getattr(parent_obj, field.name) is None:
394
+ field.pre_save(parent_obj, add=True)
395
+
391
396
  return parent_obj
392
397
 
393
398
  def _create_child_instance(self, source_obj, child_model, parent_instances):
@@ -411,4 +416,10 @@ class HookQuerySet(models.QuerySet):
411
416
  elif hasattr(field, 'auto_now') and field.auto_now:
412
417
  field.pre_save(child_obj, add=True)
413
418
 
419
+ # Ensure auto_now_add fields are explicitly set to prevent null constraint violations
420
+ for field in child_model._meta.local_fields:
421
+ if hasattr(field, 'auto_now_add') and field.auto_now_add:
422
+ if getattr(child_obj, field.name) is None:
423
+ field.pre_save(child_obj, add=True)
424
+
414
425
  return child_obj
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.1.122"
3
+ version = "0.1.124"
4
4
  description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
5
5
  authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
6
6
  readme = "README.md"
@@ -1,201 +0,0 @@
1
- from django.db import models, transaction
2
-
3
- from django_bulk_hooks import engine
4
- from django_bulk_hooks.constants import (
5
- AFTER_CREATE,
6
- AFTER_DELETE,
7
- AFTER_UPDATE,
8
- BEFORE_CREATE,
9
- BEFORE_DELETE,
10
- BEFORE_UPDATE,
11
- VALIDATE_CREATE,
12
- VALIDATE_DELETE,
13
- VALIDATE_UPDATE,
14
- )
15
- from django_bulk_hooks.context import HookContext
16
- from django_bulk_hooks.queryset import HookQuerySet
17
-
18
-
19
- class BulkHookManager(models.Manager):
20
- CHUNK_SIZE = 200
21
-
22
- def get_queryset(self):
23
- return HookQuerySet(self.model, using=self._db)
24
-
25
- @transaction.atomic
26
- def bulk_update(
27
- self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
28
- ):
29
- if not objs:
30
- return []
31
-
32
- model_cls = self.model
33
-
34
- if any(not isinstance(obj, model_cls) for obj in objs):
35
- raise TypeError(
36
- f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
37
- )
38
-
39
- if not bypass_hooks:
40
- # Load originals for hook comparison and ensure they match the order of new instances
41
- original_map = {
42
- obj.pk: obj
43
- for obj in model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
44
- }
45
- originals = [original_map.get(obj.pk) for obj in objs]
46
-
47
- ctx = HookContext(model_cls)
48
-
49
- # Run validation hooks first
50
- if not bypass_validation:
51
- engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
52
-
53
- # Then run business logic hooks
54
- engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
55
-
56
- # Automatically detect fields that were modified during BEFORE_UPDATE hooks
57
- modified_fields = self._detect_modified_fields(objs, originals)
58
- if modified_fields:
59
- # Convert to set for efficient union operation
60
- fields_set = set(fields)
61
- fields_set.update(modified_fields)
62
- fields = list(fields_set)
63
-
64
- for i in range(0, len(objs), self.CHUNK_SIZE):
65
- chunk = objs[i : i + self.CHUNK_SIZE]
66
- # Call the base implementation to avoid re-triggering this method
67
- super(models.Manager, self).bulk_update(chunk, fields, **kwargs)
68
-
69
- if not bypass_hooks:
70
- engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
71
-
72
- return objs
73
-
74
- @transaction.atomic
75
- def bulk_create(
76
- self,
77
- objs,
78
- batch_size=None,
79
- ignore_conflicts=False,
80
- update_conflicts=False,
81
- update_fields=None,
82
- unique_fields=None,
83
- bypass_hooks=False,
84
- bypass_validation=False,
85
- ):
86
- """
87
- Delegate to QuerySet's bulk_create implementation.
88
- This follows Django's pattern where Manager methods call QuerySet methods.
89
- """
90
- return self.get_queryset().bulk_create(
91
- objs,
92
- batch_size=batch_size,
93
- ignore_conflicts=ignore_conflicts,
94
- update_conflicts=update_conflicts,
95
- update_fields=update_fields,
96
- unique_fields=unique_fields,
97
- bypass_hooks=bypass_hooks,
98
- bypass_validation=bypass_validation,
99
- )
100
-
101
- @transaction.atomic
102
- def bulk_delete(
103
- self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
104
- ):
105
- if not objs:
106
- return []
107
-
108
- model_cls = self.model
109
-
110
- if any(not isinstance(obj, model_cls) for obj in objs):
111
- raise TypeError(
112
- f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
113
- )
114
-
115
- ctx = HookContext(model_cls)
116
-
117
- if not bypass_hooks:
118
- # Run validation hooks first
119
- if not bypass_validation:
120
- engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
121
-
122
- # Then run business logic hooks
123
- engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
124
-
125
- pks = [obj.pk for obj in objs if obj.pk is not None]
126
-
127
- # Use base manager for the actual deletion to prevent recursion
128
- # The hooks have already been fired above, so we don't need them again
129
- model_cls._base_manager.filter(pk__in=pks).delete()
130
-
131
- if not bypass_hooks:
132
- engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
133
-
134
- return objs
135
-
136
- @transaction.atomic
137
- def update(self, **kwargs):
138
- objs = list(self.all())
139
- if not objs:
140
- return 0
141
- for key, value in kwargs.items():
142
- for obj in objs:
143
- setattr(obj, key, value)
144
- self.bulk_update(objs, fields=list(kwargs.keys()))
145
- return len(objs)
146
-
147
- @transaction.atomic
148
- def delete(self):
149
- objs = list(self.all())
150
- if not objs:
151
- return 0
152
- self.bulk_delete(objs)
153
- return len(objs)
154
-
155
- @transaction.atomic
156
- def save(self, obj):
157
- if obj.pk:
158
- self.bulk_update(
159
- [obj],
160
- fields=[field.name for field in obj._meta.fields if field.name != "id"],
161
- )
162
- else:
163
- self.bulk_create([obj])
164
- return obj
165
-
166
- def _detect_modified_fields(self, new_instances, original_instances):
167
- """
168
- Detect fields that were modified during BEFORE_UPDATE hooks by comparing
169
- new instances with their original values.
170
- """
171
- if not original_instances:
172
- return set()
173
-
174
- modified_fields = set()
175
-
176
- # Since original_instances is now ordered to match new_instances, we can zip them directly
177
- for new_instance, original in zip(new_instances, original_instances):
178
- if new_instance.pk is None or original is None:
179
- continue
180
-
181
- # Compare all fields to detect changes
182
- for field in new_instance._meta.fields:
183
- if field.name == "id":
184
- continue
185
-
186
- new_value = getattr(new_instance, field.name)
187
- original_value = getattr(original, field.name)
188
-
189
- # Handle different field types appropriately
190
- if field.is_relation:
191
- # For foreign keys, compare the pk values
192
- new_pk = new_value.pk if new_value else None
193
- original_pk = original_value.pk if original_value else None
194
- if new_pk != original_pk:
195
- modified_fields.add(field.name)
196
- else:
197
- # For regular fields, use direct comparison
198
- if new_value != original_value:
199
- modified_fields.add(field.name)
200
-
201
- return modified_fields