django-bulk-hooks 0.2.14__tar.gz → 0.2.15__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.
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/PKG-INFO +1 -1
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/operations/coordinator.py +201 -91
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/pyproject.toml +1 -1
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/LICENSE +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/README.md +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/__init__.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/changeset.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/conditions.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/decorators.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/dispatcher.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/factory.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/handler.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/helpers.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/manager.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/models.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/operations/__init__.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/operations/analyzer.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/operations/bulk_executor.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/operations/mti_handler.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/operations/mti_plans.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/queryset.py +0 -0
- {django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/registry.py +0 -0
{django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/operations/coordinator.py
RENAMED
|
@@ -7,7 +7,8 @@ a clean, simple API for the QuerySet to use.
|
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
9
|
from django.db import transaction
|
|
10
|
-
from django.db.models import QuerySet
|
|
10
|
+
from django.db.models import QuerySet
|
|
11
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
11
12
|
|
|
12
13
|
from django_bulk_hooks.helpers import (
|
|
13
14
|
build_changeset_for_create,
|
|
@@ -29,6 +30,7 @@ class BulkOperationCoordinator:
|
|
|
29
30
|
Services are created lazily and cached.
|
|
30
31
|
"""
|
|
31
32
|
|
|
33
|
+
|
|
32
34
|
def __init__(self, queryset):
|
|
33
35
|
"""
|
|
34
36
|
Initialize coordinator for a queryset.
|
|
@@ -212,40 +214,52 @@ class BulkOperationCoordinator:
|
|
|
212
214
|
self, update_kwargs, bypass_hooks=False, bypass_validation=False
|
|
213
215
|
):
|
|
214
216
|
"""
|
|
215
|
-
Execute queryset
|
|
216
|
-
|
|
217
|
-
ARCHITECTURE
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
1. Fetch old state (
|
|
222
|
-
2. Execute
|
|
223
|
-
3. Fetch new state (
|
|
224
|
-
4. Run
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
217
|
+
Execute queryset.update() with full hook support.
|
|
218
|
+
|
|
219
|
+
ARCHITECTURE & PERFORMANCE TRADE-OFFS
|
|
220
|
+
======================================
|
|
221
|
+
|
|
222
|
+
To support hooks with queryset.update(), we must:
|
|
223
|
+
1. Fetch old state (SELECT all matching rows)
|
|
224
|
+
2. Execute database update (UPDATE in SQL)
|
|
225
|
+
3. Fetch new state (SELECT all rows again)
|
|
226
|
+
4. Run VALIDATE_UPDATE hooks (validation only)
|
|
227
|
+
5. Run BEFORE_UPDATE hooks (CAN modify instances)
|
|
228
|
+
6. Persist BEFORE_UPDATE modifications (bulk_update)
|
|
229
|
+
7. Run AFTER_UPDATE hooks (read-only side effects)
|
|
230
|
+
|
|
231
|
+
Performance Cost:
|
|
232
|
+
- 2 SELECT queries (before/after)
|
|
233
|
+
- 1 UPDATE query (actual update)
|
|
234
|
+
- 1 bulk_update (if hooks modify data)
|
|
235
|
+
|
|
236
|
+
Trade-off: Hooks require loading data into Python. If you need
|
|
237
|
+
maximum performance and don't need hooks, use bypass_hooks=True.
|
|
238
|
+
|
|
239
|
+
Hook Semantics:
|
|
240
|
+
- BEFORE_UPDATE hooks run after the DB update and CAN modify instances
|
|
241
|
+
- Modifications are auto-persisted (framework handles complexity)
|
|
242
|
+
- AFTER_UPDATE hooks run after BEFORE_UPDATE and are read-only
|
|
243
|
+
- This enables cascade logic and computed fields based on DB values
|
|
244
|
+
- User expectation: BEFORE_UPDATE hooks can modify data
|
|
245
|
+
|
|
246
|
+
Why this approach works well:
|
|
247
|
+
- Allows hooks to see Subquery/F() computed values
|
|
248
|
+
- Enables HasChanged conditions on complex expressions
|
|
249
|
+
- Maintains SQL performance (Subquery stays in database)
|
|
250
|
+
- Meets user expectations: BEFORE_UPDATE can modify instances
|
|
251
|
+
- Clean separation: BEFORE for modifications, AFTER for side effects
|
|
252
|
+
|
|
253
|
+
For true "prevent write" semantics, intercept at a higher level
|
|
254
|
+
or use bulk_update() directly (which has true before semantics).
|
|
239
255
|
"""
|
|
240
|
-
# Check bypass early
|
|
241
256
|
from django_bulk_hooks.context import get_bypass_hooks
|
|
242
|
-
should_bypass = bypass_hooks or get_bypass_hooks()
|
|
243
257
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return
|
|
258
|
+
# Fast path: no hooks at all
|
|
259
|
+
if bypass_hooks or get_bypass_hooks():
|
|
260
|
+
return QuerySet.update(self.queryset, **update_kwargs)
|
|
247
261
|
|
|
248
|
-
#
|
|
262
|
+
# Full hook lifecycle path
|
|
249
263
|
return self._execute_queryset_update_with_hooks(
|
|
250
264
|
update_kwargs=update_kwargs,
|
|
251
265
|
bypass_validation=bypass_validation,
|
|
@@ -255,34 +269,38 @@ class BulkOperationCoordinator:
|
|
|
255
269
|
self, update_kwargs, bypass_validation=False
|
|
256
270
|
):
|
|
257
271
|
"""
|
|
258
|
-
Execute queryset update with
|
|
272
|
+
Execute queryset update with full hook lifecycle support.
|
|
259
273
|
|
|
260
|
-
This method
|
|
261
|
-
|
|
274
|
+
This method implements the fetch-update-fetch pattern required
|
|
275
|
+
to support hooks with queryset.update(). BEFORE_UPDATE hooks can
|
|
276
|
+
modify instances and modifications are auto-persisted.
|
|
262
277
|
|
|
263
278
|
Args:
|
|
264
279
|
update_kwargs: Dict of fields to update
|
|
265
280
|
bypass_validation: Skip validation hooks if True
|
|
266
281
|
|
|
267
282
|
Returns:
|
|
268
|
-
Number of
|
|
283
|
+
Number of rows updated
|
|
269
284
|
"""
|
|
270
|
-
# 1
|
|
285
|
+
# Step 1: Fetch old state (before database update)
|
|
271
286
|
old_instances = list(self.queryset)
|
|
272
287
|
if not old_instances:
|
|
273
288
|
return 0
|
|
289
|
+
|
|
274
290
|
old_records_map = {inst.pk: inst for inst in old_instances}
|
|
275
291
|
|
|
276
|
-
# 2
|
|
277
|
-
|
|
292
|
+
# Step 2: Execute native Django update
|
|
293
|
+
# Use stored reference to parent class method - clean and simple
|
|
294
|
+
update_count = QuerySet.update(self.queryset, **update_kwargs)
|
|
278
295
|
|
|
279
|
-
if
|
|
296
|
+
if update_count == 0:
|
|
280
297
|
return 0
|
|
281
298
|
|
|
282
|
-
# 3
|
|
299
|
+
# Step 3: Fetch new state (after database update)
|
|
300
|
+
# This captures any Subquery/F() computed values
|
|
283
301
|
new_instances = list(self.queryset)
|
|
284
302
|
|
|
285
|
-
# 4
|
|
303
|
+
# Step 4: Build changeset
|
|
286
304
|
changeset = build_changeset_for_update(
|
|
287
305
|
self.model_cls,
|
|
288
306
|
new_instances,
|
|
@@ -290,65 +308,157 @@ class BulkOperationCoordinator:
|
|
|
290
308
|
old_records_map=old_records_map,
|
|
291
309
|
)
|
|
292
310
|
|
|
293
|
-
# Mark
|
|
311
|
+
# Mark as queryset update for potential hook inspection
|
|
294
312
|
changeset.operation_meta['is_queryset_update'] = True
|
|
295
|
-
changeset.operation_meta['
|
|
313
|
+
changeset.operation_meta['allows_modifications'] = True
|
|
296
314
|
|
|
297
|
-
# 5
|
|
315
|
+
# Step 5: Get MTI inheritance chain
|
|
298
316
|
models_in_chain = [self.model_cls]
|
|
299
317
|
if self.mti_handler.is_mti_model():
|
|
300
318
|
models_in_chain.extend(self.mti_handler.get_parent_models())
|
|
301
319
|
|
|
302
|
-
# 6
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
320
|
+
# Step 6: Run VALIDATE hooks (if not bypassed)
|
|
321
|
+
if not bypass_validation:
|
|
322
|
+
for model_cls in models_in_chain:
|
|
323
|
+
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
324
|
+
self.dispatcher.dispatch(
|
|
325
|
+
model_changeset,
|
|
326
|
+
"validate_update",
|
|
327
|
+
bypass_hooks=False
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Step 7: Run BEFORE_UPDATE hooks with modification tracking
|
|
331
|
+
modified_fields = self._run_before_update_hooks_with_tracking(
|
|
332
|
+
new_instances,
|
|
333
|
+
models_in_chain,
|
|
334
|
+
changeset
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Step 8: Auto-persist BEFORE_UPDATE modifications
|
|
338
|
+
if modified_fields:
|
|
339
|
+
self._persist_hook_modifications(new_instances, modified_fields)
|
|
340
|
+
|
|
341
|
+
# Step 9: Run AFTER_UPDATE hooks (read-only side effects)
|
|
316
342
|
for model_cls in models_in_chain:
|
|
317
343
|
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
318
|
-
self.dispatcher.dispatch(
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
for instance in new_instances:
|
|
323
|
-
if instance.pk in pre_hook_state:
|
|
324
|
-
for field_name, pre_value in pre_hook_state[instance.pk].items():
|
|
325
|
-
try:
|
|
326
|
-
current_value = getattr(instance, field_name, None)
|
|
327
|
-
except Exception:
|
|
328
|
-
current_value = None
|
|
329
|
-
|
|
330
|
-
if current_value != pre_value:
|
|
331
|
-
hook_modified_fields.add(field_name)
|
|
332
|
-
|
|
333
|
-
# Auto-persist hook modifications
|
|
334
|
-
if hook_modified_fields:
|
|
335
|
-
logger.info(
|
|
336
|
-
f"BEFORE_UPDATE hooks modified {len(hook_modified_fields)} fields: {hook_modified_fields}"
|
|
344
|
+
self.dispatcher.dispatch(
|
|
345
|
+
model_changeset,
|
|
346
|
+
"after_update",
|
|
347
|
+
bypass_hooks=False
|
|
337
348
|
)
|
|
338
|
-
logger.info("Auto-persisting modifications with bulk_update")
|
|
339
|
-
|
|
340
|
-
# Use bulk_update to persist changes
|
|
341
|
-
# This will trigger another hook cycle (Salesforce-style cascading)
|
|
342
|
-
from django.db.models import QuerySet as BaseQuerySet
|
|
343
|
-
base_qs = BaseQuerySet(model=self.model_cls, using=self.queryset.db)
|
|
344
|
-
base_qs.bulk_update(new_instances, list(hook_modified_fields))
|
|
345
349
|
|
|
346
|
-
|
|
350
|
+
return update_count
|
|
351
|
+
|
|
352
|
+
def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
|
|
353
|
+
"""
|
|
354
|
+
Run BEFORE_UPDATE hooks and detect modifications.
|
|
355
|
+
|
|
356
|
+
This is what users expect - BEFORE_UPDATE hooks can modify instances
|
|
357
|
+
and those modifications will be automatically persisted. The framework
|
|
358
|
+
handles the complexity internally.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Set of field names that were modified by hooks
|
|
362
|
+
"""
|
|
363
|
+
# Snapshot current state
|
|
364
|
+
pre_hook_state = self._snapshot_instance_state(instances)
|
|
365
|
+
|
|
366
|
+
# Run BEFORE_UPDATE hooks
|
|
347
367
|
for model_cls in models_in_chain:
|
|
348
368
|
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
349
|
-
self.dispatcher.dispatch(
|
|
369
|
+
self.dispatcher.dispatch(
|
|
370
|
+
model_changeset,
|
|
371
|
+
"before_update",
|
|
372
|
+
bypass_hooks=False
|
|
373
|
+
)
|
|
350
374
|
|
|
351
|
-
|
|
375
|
+
# Detect modifications
|
|
376
|
+
return self._detect_modifications(instances, pre_hook_state)
|
|
377
|
+
|
|
378
|
+
def _snapshot_instance_state(self, instances):
|
|
379
|
+
"""
|
|
380
|
+
Create a snapshot of current instance field values.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
instances: List of model instances
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Dict mapping pk -> {field_name: value}
|
|
387
|
+
"""
|
|
388
|
+
snapshot = {}
|
|
389
|
+
|
|
390
|
+
for instance in instances:
|
|
391
|
+
if instance.pk is None:
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
field_values = {}
|
|
395
|
+
for field in self.model_cls._meta.get_fields():
|
|
396
|
+
# Skip relations that aren't concrete fields
|
|
397
|
+
if field.many_to_many or field.one_to_many:
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
field_name = field.name
|
|
401
|
+
try:
|
|
402
|
+
field_values[field_name] = getattr(instance, field_name)
|
|
403
|
+
except (AttributeError, FieldDoesNotExist):
|
|
404
|
+
# Field not accessible (e.g., deferred field)
|
|
405
|
+
field_values[field_name] = None
|
|
406
|
+
|
|
407
|
+
snapshot[instance.pk] = field_values
|
|
408
|
+
|
|
409
|
+
return snapshot
|
|
410
|
+
|
|
411
|
+
def _detect_modifications(self, instances, pre_hook_state):
|
|
412
|
+
"""
|
|
413
|
+
Detect which fields were modified by comparing to snapshot.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
instances: List of model instances
|
|
417
|
+
pre_hook_state: Previous state snapshot from _snapshot_instance_state
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
Set of field names that were modified
|
|
421
|
+
"""
|
|
422
|
+
modified_fields = set()
|
|
423
|
+
|
|
424
|
+
for instance in instances:
|
|
425
|
+
if instance.pk not in pre_hook_state:
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
old_values = pre_hook_state[instance.pk]
|
|
429
|
+
|
|
430
|
+
for field_name, old_value in old_values.items():
|
|
431
|
+
try:
|
|
432
|
+
current_value = getattr(instance, field_name)
|
|
433
|
+
except (AttributeError, FieldDoesNotExist):
|
|
434
|
+
current_value = None
|
|
435
|
+
|
|
436
|
+
# Compare values
|
|
437
|
+
if current_value != old_value:
|
|
438
|
+
modified_fields.add(field_name)
|
|
439
|
+
|
|
440
|
+
return modified_fields
|
|
441
|
+
|
|
442
|
+
def _persist_hook_modifications(self, instances, modified_fields):
|
|
443
|
+
"""
|
|
444
|
+
Persist modifications made by hooks using bulk_update.
|
|
445
|
+
|
|
446
|
+
This creates a "cascade" effect similar to Salesforce workflows.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
instances: List of modified instances
|
|
450
|
+
modified_fields: Set of field names that were modified
|
|
451
|
+
"""
|
|
452
|
+
logger.info(
|
|
453
|
+
f"Hooks modified {len(modified_fields)} field(s): "
|
|
454
|
+
f"{', '.join(sorted(modified_fields))}"
|
|
455
|
+
)
|
|
456
|
+
logger.info("Auto-persisting modifications via bulk_update")
|
|
457
|
+
|
|
458
|
+
# Use Django's bulk_update directly (not our hook version)
|
|
459
|
+
# Create a fresh QuerySet to avoid recursion
|
|
460
|
+
fresh_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
|
|
461
|
+
QuerySet.bulk_update(fresh_qs, instances, list(modified_fields))
|
|
352
462
|
|
|
353
463
|
@transaction.atomic
|
|
354
464
|
def delete(self, bypass_hooks=False, bypass_validation=False):
|
|
@@ -375,8 +485,8 @@ class BulkOperationCoordinator:
|
|
|
375
485
|
|
|
376
486
|
# Execute with hook lifecycle
|
|
377
487
|
def operation():
|
|
378
|
-
#
|
|
379
|
-
return
|
|
488
|
+
# Use stored reference to parent method - clean and simple
|
|
489
|
+
return QuerySet.delete(self.queryset)
|
|
380
490
|
|
|
381
491
|
return self._execute_with_mti_hooks(
|
|
382
492
|
changeset=changeset,
|
|
@@ -532,8 +642,8 @@ class BulkOperationCoordinator:
|
|
|
532
642
|
# This is a FK field being updated by its attname (e.g., business_id)
|
|
533
643
|
# Add the relationship name (e.g., 'business') to skip list
|
|
534
644
|
fk_relationships.add(field.name)
|
|
535
|
-
except
|
|
645
|
+
except FieldDoesNotExist:
|
|
536
646
|
# If field lookup fails, skip it
|
|
537
647
|
continue
|
|
538
648
|
|
|
539
|
-
return fk_relationships
|
|
649
|
+
return fk_relationships
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/operations/__init__.py
RENAMED
|
File without changes
|
{django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/operations/analyzer.py
RENAMED
|
File without changes
|
{django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/operations/bulk_executor.py
RENAMED
|
File without changes
|
{django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/operations/mti_handler.py
RENAMED
|
File without changes
|
{django_bulk_hooks-0.2.14 → django_bulk_hooks-0.2.15}/django_bulk_hooks/operations/mti_plans.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|