django-bulk-hooks 0.2.9__tar.gz → 0.2.11__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 (26) hide show
  1. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/decorators.py +55 -7
  3. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/dispatcher.py +13 -2
  4. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/operations/analyzer.py +1 -63
  5. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/operations/coordinator.py +39 -0
  6. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/pyproject.toml +1 -1
  7. django_bulk_hooks-0.2.9/django_bulk_hooks/debug_utils.py +0 -145
  8. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/LICENSE +0 -0
  9. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/README.md +0 -0
  10. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/__init__.py +0 -0
  11. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/changeset.py +0 -0
  12. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/conditions.py +0 -0
  13. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/constants.py +0 -0
  14. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/context.py +0 -0
  15. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/enums.py +0 -0
  16. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/factory.py +0 -0
  17. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/handler.py +0 -0
  18. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/helpers.py +0 -0
  19. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/manager.py +0 -0
  20. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/models.py +0 -0
  21. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/operations/__init__.py +0 -0
  22. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/operations/bulk_executor.py +0 -0
  23. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/operations/mti_handler.py +0 -0
  24. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/operations/mti_plans.py +0 -0
  25. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/django_bulk_hooks/queryset.py +0 -0
  26. {django_bulk_hooks-0.2.9 → django_bulk_hooks-0.2.11}/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.2.9
3
+ Version: 0.2.11
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
@@ -1,4 +1,5 @@
1
1
  import inspect
2
+ import logging
2
3
  from functools import wraps
3
4
 
4
5
  from django.core.exceptions import FieldDoesNotExist
@@ -6,6 +7,8 @@ from django.core.exceptions import FieldDoesNotExist
6
7
  from django_bulk_hooks.enums import DEFAULT_PRIORITY
7
8
  from django_bulk_hooks.registry import register_hook
8
9
 
10
+ logger = logging.getLogger(__name__)
11
+
9
12
 
10
13
  def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
11
14
  """
@@ -35,7 +38,7 @@ def select_related(*related_fields):
35
38
  def decorator(func):
36
39
  sig = inspect.signature(func)
37
40
 
38
- def preload_related(records, *, model_cls=None):
41
+ def preload_related(records, *, model_cls=None, skip_fields=None):
39
42
  if not isinstance(records, list):
40
43
  raise TypeError(
41
44
  f"@select_related expects a list of model instances, got {type(records)}"
@@ -47,6 +50,9 @@ def select_related(*related_fields):
47
50
  if model_cls is None:
48
51
  model_cls = records[0].__class__
49
52
 
53
+ if skip_fields is None:
54
+ skip_fields = set()
55
+
50
56
  # Validate field notation upfront
51
57
  for field in related_fields:
52
58
  if "." in field:
@@ -161,6 +167,10 @@ def select_related(*related_fields):
161
167
  continue
162
168
 
163
169
  for field in related_fields:
170
+ # Skip preloading if this relationship conflicts with FK field being updated
171
+ if field in skip_fields:
172
+ continue
173
+
164
174
  if fields_cache is not None and field in fields_cache:
165
175
  continue
166
176
 
@@ -179,6 +189,10 @@ def select_related(*related_fields):
179
189
  continue
180
190
 
181
191
  for field_name, relation_field in direct_relation_fields.items():
192
+ # Skip preloading if this relationship conflicts with FK field being updated
193
+ if field_name in skip_fields:
194
+ continue
195
+
182
196
  if fields_cache is not None and field_name in fields_cache:
183
197
  continue
184
198
 
@@ -198,6 +212,12 @@ def select_related(*related_fields):
198
212
  if fields_cache is not None:
199
213
  fields_cache[field_name] = rel_obj
200
214
 
215
+ def preload_with_skip_fields(records, *, model_cls=None, skip_fields=None):
216
+ """Wrapper that applies skip_fields logic to the preload function"""
217
+ if skip_fields is None:
218
+ skip_fields = set()
219
+ return preload_related(records, model_cls=model_cls, skip_fields=skip_fields)
220
+
201
221
  @wraps(func)
202
222
  def wrapper(*args, **kwargs):
203
223
  bound = sig.bind_partial(*args, **kwargs)
@@ -210,13 +230,27 @@ def select_related(*related_fields):
210
230
 
211
231
  new_records = bound.arguments["new_records"]
212
232
 
213
- model_cls_override = bound.arguments.get("model_cls")
233
+ if not isinstance(new_records, list):
234
+ raise TypeError(
235
+ f"@select_related expects a list of model instances, got {type(new_records)}"
236
+ )
237
+
238
+ if not new_records:
239
+ # Empty list, nothing to preload
240
+ return func(*args, **kwargs)
214
241
 
215
- preload_related(new_records, model_cls=model_cls_override)
242
+ # Validate field notation upfront (same as in preload_related)
243
+ for field in related_fields:
244
+ if "." in field:
245
+ raise ValueError(
246
+ f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')"
247
+ )
216
248
 
217
- return func(*bound.args, **bound.kwargs)
249
+ # Don't preload here - let the dispatcher handle it
250
+ # The dispatcher will call the preload function with skip_fields
251
+ return func(*args, **kwargs)
218
252
 
219
- wrapper._select_related_preload = preload_related
253
+ wrapper._select_related_preload = preload_with_skip_fields
220
254
  wrapper._select_related_fields = related_fields
221
255
 
222
256
  return wrapper
@@ -241,8 +275,22 @@ def bulk_hook(model_cls, event, when=None, priority=None):
241
275
  def __init__(self):
242
276
  self.func = func
243
277
 
244
- def handle(self, new_records=None, old_records=None, **kwargs):
245
- return self.func(new_records, old_records, **kwargs)
278
+ def handle(self, changeset=None, new_records=None, old_records=None, **kwargs):
279
+ # Support both old and new hook signatures for backward compatibility
280
+ # Old signature: def hook(self, new_records, old_records, **kwargs)
281
+ # New signature: def hook(self, changeset, new_records, old_records, **kwargs)
282
+
283
+ # Check function signature to determine which format to use
284
+ import inspect
285
+ sig = inspect.signature(func)
286
+ params = list(sig.parameters.keys())
287
+
288
+ if 'changeset' in params:
289
+ # New signature with changeset
290
+ return self.func(changeset, new_records, old_records, **kwargs)
291
+ else:
292
+ # Old signature without changeset
293
+ return self.func(new_records, old_records, **kwargs)
246
294
 
247
295
  # Register the hook using the registry
248
296
  register_hook(
@@ -108,7 +108,9 @@ class HookDispatcher:
108
108
  return
109
109
 
110
110
  # Execute hooks in priority order
111
+ logger.info(f"🔥 HOOKS: Executing {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}")
111
112
  for handler_cls, method_name, condition, priority in hooks:
113
+ logger.info(f" → {handler_cls.__name__}.{method_name} (priority={priority})")
112
114
  self._execute_hook(handler_cls, method_name, condition, changeset)
113
115
 
114
116
  def _execute_hook(self, handler_cls, method_name, condition, changeset):
@@ -159,6 +161,9 @@ class HookDispatcher:
159
161
  try:
160
162
  model_cls_override = getattr(handler, "model_cls", None)
161
163
 
164
+ # Get FK fields being updated to avoid preloading conflicting relationships
165
+ skip_fields = changeset.operation_meta.get('fk_fields_being_updated', set())
166
+
162
167
  # Preload for new_records
163
168
  if filtered_changeset.new_records:
164
169
  logger.debug(
@@ -166,7 +171,9 @@ class HookDispatcher:
166
171
  f"new_records for {handler_cls.__name__}.{method_name}"
167
172
  )
168
173
  preload_func(
169
- filtered_changeset.new_records, model_cls=model_cls_override
174
+ filtered_changeset.new_records,
175
+ model_cls=model_cls_override,
176
+ skip_fields=skip_fields
170
177
  )
171
178
 
172
179
  # Also preload for old_records (for conditions that check previous values)
@@ -176,7 +183,9 @@ class HookDispatcher:
176
183
  f"old_records for {handler_cls.__name__}.{method_name}"
177
184
  )
178
185
  preload_func(
179
- filtered_changeset.old_records, model_cls=model_cls_override
186
+ filtered_changeset.old_records,
187
+ model_cls=model_cls_override,
188
+ skip_fields=skip_fields
180
189
  )
181
190
  except Exception:
182
191
  logger.debug(
@@ -197,12 +206,14 @@ class HookDispatcher:
197
206
  # New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
198
207
  #
199
208
  # This is standard Python framework design (see Django signals, Flask hooks, etc.)
209
+ logger.info(f" 🚀 Executing: {handler_cls.__name__}.{method_name}")
200
210
  try:
201
211
  method(
202
212
  changeset=filtered_changeset,
203
213
  new_records=filtered_changeset.new_records,
204
214
  old_records=filtered_changeset.old_records,
205
215
  )
216
+ logger.info(f" ✅ Completed: {handler_cls.__name__}.{method_name}")
206
217
  except Exception as e:
207
218
  # Fail-fast: re-raise to rollback transaction
208
219
  logger.error(
@@ -257,10 +257,6 @@ class ModelAnalyzer:
257
257
  F() expressions, Subquery, Case, etc.) into concrete values and applies
258
258
  them to the instances.
259
259
 
260
- CRITICAL: When setting FK fields by their attname (e.g., business_id),
261
- we must manually clear the relationship cache (e.g., business) to match
262
- Django's ForeignKey descriptor behavior.
263
-
264
260
  Args:
265
261
  instances: List of model instances to update
266
262
  update_kwargs: Dict of {field_name: value_or_expression}
@@ -274,66 +270,8 @@ class ModelAnalyzer:
274
270
  fields_updated = list(update_kwargs.keys())
275
271
 
276
272
  for field_name, value in update_kwargs.items():
277
- # Determine if this is a FK field being set by its attname
278
- field_info = self._get_fk_field_info(field_name)
279
-
280
273
  for instance in instances:
281
274
  resolved_value = self.resolve_expression(field_name, value, instance)
282
275
  setattr(instance, field_name, resolved_value)
283
-
284
- # Clear relationship cache when FK field is set directly
285
- # This replicates Django's ForeignKey descriptor behavior
286
- if field_info and field_info['is_fk_attname']:
287
- self._clear_fk_cache(instance, field_info['accessor_name'])
288
-
289
- return fields_updated
290
-
291
- def _get_fk_field_info(self, field_name):
292
- """
293
- Get information about a FK field if field_name is a FK attname.
294
-
295
- Args:
296
- field_name: Field name to check
297
-
298
- Returns:
299
- Dict with FK info or None if not a FK field
300
- """
301
- try:
302
- # Check all fields to find if this is a FK attname
303
- for field in self.model_cls._meta.get_fields():
304
- if (field.is_relation and
305
- not field.many_to_many and
306
- not field.one_to_many and
307
- hasattr(field, 'attname') and
308
- field.attname == field_name):
309
- # This is a FK field being set by its attname (e.g., business_id)
310
- return {
311
- 'is_fk_attname': True,
312
- 'accessor_name': field.name, # e.g., 'business'
313
- 'field': field
314
- }
315
- except Exception as e:
316
- logger.debug(f"Error checking FK field info for {field_name}: {e}")
317
-
318
- return None
319
-
320
- def _clear_fk_cache(self, instance, accessor_name):
321
- """
322
- Clear cached relationship when FK field is set directly.
323
276
 
324
- This replicates what Django's ForeignKey descriptor __set__ does:
325
- when you set a FK field, Django clears the cached related object.
326
-
327
- Args:
328
- instance: Model instance
329
- accessor_name: Name of the relationship accessor (e.g., 'business')
330
- """
331
- try:
332
- if hasattr(instance, '_state') and hasattr(instance._state, 'fields_cache'):
333
- instance._state.fields_cache.pop(accessor_name, None)
334
- logger.debug(
335
- f"Cleared FK cache for '{accessor_name}' on {self.model_cls.__name__}"
336
- )
337
- except Exception as e:
338
- # Don't fail the operation, just log
339
- logger.debug(f"Could not clear FK cache for {accessor_name}: {e}")
277
+ return fields_updated
@@ -276,6 +276,9 @@ class BulkOperationCoordinator:
276
276
  # Fetch old records for comparison (single bulk query)
277
277
  old_records_map = self.analyzer.fetch_old_records_map(instances)
278
278
 
279
+ # Detect FK fields being updated to prevent @select_related conflicts
280
+ fk_fields_being_updated = self._get_fk_fields_being_updated(update_kwargs)
281
+
279
282
  # Build changeset for VALIDATE and BEFORE hooks
280
283
  # instances now have the "intended" values from update_kwargs
281
284
  changeset = build_changeset_for_update(
@@ -285,6 +288,10 @@ class BulkOperationCoordinator:
285
288
  old_records_map=old_records_map,
286
289
  )
287
290
 
291
+ # Add FK field info to changeset meta for dispatcher to use
292
+ if fk_fields_being_updated:
293
+ changeset.operation_meta['fk_fields_being_updated'] = fk_fields_being_updated
294
+
288
295
  # Execute VALIDATE and BEFORE hooks
289
296
  # Hooks can now modify the instances and changes will persist
290
297
  if not bypass_validation:
@@ -470,3 +477,35 @@ class BulkOperationCoordinator:
470
477
  self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
471
478
 
472
479
  return result
480
+
481
+ def _get_fk_fields_being_updated(self, update_kwargs):
482
+ """
483
+ Get the relationship names for FK fields being updated.
484
+
485
+ This helps @select_related avoid preloading relationships that are
486
+ being modified, which can cause cache conflicts.
487
+
488
+ Args:
489
+ update_kwargs: Dict of fields being updated
490
+
491
+ Returns:
492
+ Set of relationship names (e.g., {'business'}) for FK fields being updated
493
+ """
494
+ fk_relationships = set()
495
+
496
+ for field_name in update_kwargs.keys():
497
+ try:
498
+ field = self.model_cls._meta.get_field(field_name)
499
+ if (field.is_relation and
500
+ not field.many_to_many and
501
+ not field.one_to_many and
502
+ hasattr(field, 'attname') and
503
+ field.attname == field_name):
504
+ # This is a FK field being updated by its attname (e.g., business_id)
505
+ # Add the relationship name (e.g., 'business') to skip list
506
+ fk_relationships.add(field.name)
507
+ except Exception:
508
+ # If field lookup fails, skip it
509
+ continue
510
+
511
+ return fk_relationships
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.2.9"
3
+ version = "0.2.11"
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,145 +0,0 @@
1
- """
2
- Debug utilities for tracking N+1 queries and database performance.
3
- """
4
-
5
- import logging
6
- import time
7
- from functools import wraps
8
- from django.db import connection
9
- from django.conf import settings
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- def track_queries(func):
15
- """
16
- Decorator to track database queries during function execution.
17
- """
18
-
19
- @wraps(func)
20
- def wrapper(*args, **kwargs):
21
- # Reset query count
22
- initial_queries = len(connection.queries)
23
- initial_time = time.time()
24
-
25
- logger.debug(
26
- f"QUERY DEBUG: Starting {func.__name__} - initial query count: {initial_queries}"
27
- )
28
-
29
- try:
30
- result = func(*args, **kwargs)
31
-
32
- final_queries = len(connection.queries)
33
- final_time = time.time()
34
- query_count = final_queries - initial_queries
35
- duration = final_time - initial_time
36
-
37
- logger.debug(
38
- f"QUERY DEBUG: Completed {func.__name__} - queries executed: {query_count}, duration: {duration:.4f}s"
39
- )
40
-
41
- # Log all queries executed during this function
42
- if query_count > 0:
43
- logger.debug(f"QUERY DEBUG: Queries executed in {func.__name__}:")
44
- for i, query in enumerate(connection.queries[initial_queries:], 1):
45
- logger.debug(
46
- f"QUERY DEBUG: {i}. {query['sql'][:100]}... (time: {query['time']})"
47
- )
48
-
49
- return result
50
-
51
- except Exception as e:
52
- final_queries = len(connection.queries)
53
- query_count = final_queries - initial_queries
54
- logger.debug(
55
- f"QUERY DEBUG: Exception in {func.__name__} - queries executed: {query_count}"
56
- )
57
- raise
58
-
59
- return wrapper
60
-
61
-
62
- def log_query_count(context=""):
63
- """
64
- Log the current query count with optional context.
65
- """
66
- query_count = len(connection.queries)
67
- logger.debug(f"QUERY DEBUG: Query count at {context}: {query_count}")
68
-
69
-
70
- def log_recent_queries(count=5, context=""):
71
- """
72
- Log the most recent database queries.
73
- """
74
- recent_queries = connection.queries[-count:] if connection.queries else []
75
- logger.debug(f"QUERY DEBUG: Recent {len(recent_queries)} queries at {context}:")
76
- for i, query in enumerate(recent_queries, 1):
77
- logger.debug(
78
- f"QUERY DEBUG: {i}. {query['sql'][:100]}... (time: {query['time']})"
79
- )
80
-
81
-
82
- class QueryTracker:
83
- """
84
- Context manager for tracking database queries.
85
- """
86
-
87
- def __init__(self, context_name="QueryTracker"):
88
- self.context_name = context_name
89
- self.initial_queries = 0
90
- self.start_time = 0
91
-
92
- def __enter__(self):
93
- self.initial_queries = len(connection.queries)
94
- self.start_time = time.time()
95
- logger.debug(
96
- f"QUERY DEBUG: Starting {self.context_name} - initial query count: {self.initial_queries}"
97
- )
98
- return self
99
-
100
- def __exit__(self, exc_type, exc_val, exc_tb):
101
- final_queries = len(connection.queries)
102
- final_time = time.time()
103
- query_count = final_queries - self.initial_queries
104
- duration = final_time - self.start_time
105
-
106
- logger.debug(
107
- f"QUERY DEBUG: Completed {self.context_name} - queries executed: {query_count}, duration: {duration:.4f}s"
108
- )
109
-
110
- if query_count > 0:
111
- logger.debug(f"QUERY DEBUG: Queries executed in {self.context_name}:")
112
- for i, query in enumerate(connection.queries[self.initial_queries :], 1):
113
- logger.debug(
114
- f"QUERY DEBUG: {i}. {query['sql'][:100]}... (time: {query['time']})"
115
- )
116
-
117
- return False # Don't suppress exceptions
118
-
119
-
120
- def enable_django_query_logging():
121
- """
122
- Enable Django's built-in query logging.
123
- """
124
- if not settings.DEBUG:
125
- logger.warning("Django query logging can only be enabled in DEBUG mode")
126
- return
127
-
128
- # Enable query logging
129
- settings.LOGGING = {
130
- "version": 1,
131
- "disable_existing_loggers": False,
132
- "handlers": {
133
- "console": {
134
- "class": "logging.StreamHandler",
135
- },
136
- },
137
- "loggers": {
138
- "django.db.backends": {
139
- "level": "DEBUG",
140
- "handlers": ["console"],
141
- },
142
- },
143
- }
144
-
145
- logger.info("Django query logging enabled")