django-bulk-hooks 0.1.280__py3-none-any.whl → 0.2.1__py3-none-any.whl

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.

@@ -1,472 +1,44 @@
1
- import logging
1
+ """
2
+ HookQuerySet - Django QuerySet with hook support.
2
3
 
3
- from django.db import models, transaction
4
- from django.db.models import AutoField, Case, Field, Value, When
4
+ This is a thin coordinator that delegates all complex logic to services.
5
+ It follows the Facade pattern, providing a simple interface over the
6
+ complex coordination required for bulk operations with hooks.
7
+ """
5
8
 
6
- from django_bulk_hooks import engine
9
+ import logging
10
+ from django.db import models, transaction
7
11
 
8
12
  logger = logging.getLogger(__name__)
9
- from django_bulk_hooks.constants import (
10
- AFTER_CREATE,
11
- AFTER_DELETE,
12
- AFTER_UPDATE,
13
- BEFORE_CREATE,
14
- BEFORE_DELETE,
15
- BEFORE_UPDATE,
16
- VALIDATE_CREATE,
17
- VALIDATE_DELETE,
18
- VALIDATE_UPDATE,
19
- )
20
- from django_bulk_hooks.context import (
21
- HookContext,
22
- get_bulk_update_value_map,
23
- set_bulk_update_value_map,
24
- )
25
13
 
26
14
 
27
- class HookQuerySetMixin:
15
+ class HookQuerySet(models.QuerySet):
28
16
  """
29
- A mixin that provides bulk hook functionality to any QuerySet.
30
- This can be dynamically injected into querysets from other managers.
31
- """
32
-
33
- @transaction.atomic
34
- def delete(self):
35
- objs = list(self)
36
- if not objs:
37
- return 0
38
-
39
- model_cls = self.model
40
- ctx = HookContext(model_cls)
41
-
42
- # Run validation hooks first
43
- engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
44
-
45
- # Then run business logic hooks
46
- engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
47
-
48
- # Before deletion, ensure all related fields are properly cached
49
- # to avoid DoesNotExist errors in AFTER_DELETE hooks
50
- for obj in objs:
51
- if obj.pk is not None:
52
- # Cache all foreign key relationships by accessing them
53
- for field in model_cls._meta.fields:
54
- if (
55
- field.is_relation
56
- and not field.many_to_many
57
- and not field.one_to_many
58
- ):
59
- try:
60
- # Access the related field to cache it before deletion
61
- getattr(obj, field.name)
62
- except Exception:
63
- # If we can't access the field (e.g., already deleted, no permission, etc.)
64
- # continue with other fields
65
- pass
66
-
67
- # Use Django's standard delete() method
68
- result = super().delete()
69
-
70
- # Run AFTER_DELETE hooks
71
- engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
72
-
73
- return result
74
-
75
- @transaction.atomic
76
- def update(self, **kwargs):
77
- logger.debug(f"Entering update method with {len(kwargs)} kwargs")
78
- instances = list(self)
79
- if not instances:
80
- return 0
81
-
82
- model_cls = self.model
83
- pks = [obj.pk for obj in instances]
84
-
85
- # Load originals for hook comparison and ensure they match the order of instances
86
- # Use the base manager to avoid recursion
87
- original_map = {
88
- obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
89
- }
90
- originals = [original_map.get(obj.pk) for obj in instances]
91
-
92
- # Check if any of the update values are Subquery objects
93
- try:
94
- from django.db.models import Subquery
95
-
96
- logger.debug("Successfully imported Subquery from django.db.models")
97
- except ImportError as e:
98
- logger.error(f"Failed to import Subquery: {e}")
99
- raise
100
-
101
- logger.debug(f"Checking for Subquery objects in {len(kwargs)} kwargs")
102
-
103
- subquery_detected = []
104
- for key, value in kwargs.items():
105
- is_subquery = isinstance(value, Subquery)
106
- logger.debug(
107
- f"Key '{key}': type={type(value).__name__}, is_subquery={is_subquery}"
108
- )
109
- if is_subquery:
110
- subquery_detected.append(key)
111
-
112
- has_subquery = len(subquery_detected) > 0
113
- logger.debug(
114
- f"Subquery detection result: {has_subquery}, detected keys: {subquery_detected}"
115
- )
116
-
117
- # Debug logging for Subquery detection
118
- logger.debug(f"Update kwargs: {list(kwargs.keys())}")
119
- logger.debug(
120
- f"Update kwargs types: {[(k, type(v).__name__) for k, v in kwargs.items()]}"
121
- )
122
-
123
- if has_subquery:
124
- logger.debug(
125
- f"Detected Subquery in update: {[k for k, v in kwargs.items() if isinstance(v, Subquery)]}"
126
- )
127
- else:
128
- # Check if we missed any Subquery objects
129
- for k, v in kwargs.items():
130
- if hasattr(v, "query") and hasattr(v, "resolve_expression"):
131
- logger.warning(
132
- f"Potential Subquery-like object detected but not recognized: {k}={type(v).__name__}"
133
- )
134
- logger.warning(
135
- f"Object attributes: query={hasattr(v, 'query')}, resolve_expression={hasattr(v, 'resolve_expression')}"
136
- )
137
- logger.warning(
138
- f"Object dir: {[attr for attr in dir(v) if not attr.startswith('_')][:10]}"
139
- )
140
-
141
- # Apply field updates to instances
142
- # If a per-object value map exists (from bulk_update), prefer it over kwargs
143
- # IMPORTANT: Do not assign Django expression objects (e.g., Subquery/Case/F)
144
- # to in-memory instances before running BEFORE_UPDATE hooks. Hooks must not
145
- # receive unresolved expression objects.
146
- per_object_values = get_bulk_update_value_map()
147
-
148
- # For Subquery updates, skip all in-memory field assignments to prevent
149
- # expression objects from reaching hooks
150
- if has_subquery:
151
- logger.debug(
152
- "Skipping in-memory field assignments due to Subquery detection"
153
- )
154
- else:
155
- for obj in instances:
156
- if per_object_values and obj.pk in per_object_values:
157
- for field, value in per_object_values[obj.pk].items():
158
- setattr(obj, field, value)
159
- else:
160
- for field, value in kwargs.items():
161
- # Skip assigning expression-like objects (they will be handled at DB level)
162
- is_expression_like = hasattr(value, "resolve_expression")
163
- if is_expression_like:
164
- # Special-case Value() which can be unwrapped safely
165
- if isinstance(value, Value):
166
- try:
167
- setattr(obj, field, value.value)
168
- except Exception:
169
- # If Value cannot be unwrapped for any reason, skip assignment
170
- continue
171
- else:
172
- # Do not assign unresolved expressions to in-memory objects
173
- logger.debug(
174
- f"Skipping assignment of expression {type(value).__name__} to field {field}"
175
- )
176
- continue
177
- else:
178
- setattr(obj, field, value)
179
-
180
- # Salesforce-style trigger behavior: Always run hooks, rely on Django's stack overflow protection
181
- from django_bulk_hooks.context import get_bypass_hooks
182
-
183
- current_bypass_hooks = get_bypass_hooks()
184
-
185
- # Only skip hooks if explicitly bypassed (not for recursion prevention)
186
- if current_bypass_hooks:
187
- logger.debug("update: hooks explicitly bypassed")
188
- ctx = HookContext(model_cls, bypass_hooks=True)
189
- else:
190
- # Always run hooks - Django will handle stack overflow protection
191
- logger.debug("update: running hooks with Salesforce-style behavior")
192
- ctx = HookContext(model_cls, bypass_hooks=False)
193
-
194
- # Run validation hooks first
195
- engine.run(model_cls, VALIDATE_UPDATE, instances, originals, ctx=ctx)
196
-
197
- # For Subquery updates, skip BEFORE_UPDATE hooks here - they'll run after refresh
198
- if not has_subquery:
199
- # Then run BEFORE_UPDATE hooks for non-Subquery updates
200
- engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
201
-
202
- # Persist any additional field mutations made by BEFORE_UPDATE hooks.
203
- # Build CASE statements per modified field not already present in kwargs.
204
- # Note: For Subquery updates, this will be empty since hooks haven't run yet
205
- # For Subquery updates, hook modifications are handled later via bulk_update
206
- if not has_subquery:
207
- modified_fields = self._detect_modified_fields(instances, originals)
208
- extra_fields = [f for f in modified_fields if f not in kwargs]
209
- else:
210
- extra_fields = [] # Skip for Subquery updates
211
-
212
- if extra_fields:
213
- case_statements = {}
214
- for field_name in extra_fields:
215
- try:
216
- field_obj = model_cls._meta.get_field(field_name)
217
- except Exception:
218
- # Skip unknown fields
219
- continue
220
-
221
- when_statements = []
222
- for obj in instances:
223
- obj_pk = getattr(obj, "pk", None)
224
- if obj_pk is None:
225
- continue
226
-
227
- # Determine value and output field
228
- if getattr(field_obj, "is_relation", False):
229
- # For FK fields, store the raw id and target field output type
230
- value = getattr(obj, field_obj.attname, None)
231
- output_field = field_obj.target_field
232
- target_name = (
233
- field_obj.attname
234
- ) # use column name (e.g., fk_id)
235
- else:
236
- value = getattr(obj, field_name)
237
- output_field = field_obj
238
- target_name = field_name
239
-
240
- # Special handling for Subquery and other expression values in CASE statements
241
- if isinstance(value, Subquery):
242
- logger.debug(
243
- f"Creating When statement with Subquery for {field_name}"
244
- )
245
- # Ensure the Subquery has proper output_field
246
- if (
247
- not hasattr(value, "output_field")
248
- or value.output_field is None
249
- ):
250
- value.output_field = output_field
251
- logger.debug(
252
- f"Set output_field for Subquery in When statement to {output_field}"
253
- )
254
- when_statements.append(When(pk=obj_pk, then=value))
255
- elif hasattr(value, "resolve_expression"):
256
- # Handle other expression objects (Case, F, etc.)
257
- logger.debug(
258
- f"Creating When statement with expression for {field_name}: {type(value).__name__}"
259
- )
260
- when_statements.append(When(pk=obj_pk, then=value))
261
- else:
262
- when_statements.append(
263
- When(
264
- pk=obj_pk,
265
- then=Value(value, output_field=output_field),
266
- )
267
- )
268
-
269
- if when_statements:
270
- case_statements[target_name] = Case(
271
- *when_statements, output_field=output_field
272
- )
273
-
274
- # Merge extra CASE updates into kwargs for DB update
275
- if case_statements:
276
- logger.debug(
277
- f"Adding case statements to kwargs: {list(case_statements.keys())}"
278
- )
279
- for field_name, case_stmt in case_statements.items():
280
- logger.debug(
281
- f"Case statement for {field_name}: {type(case_stmt).__name__}"
282
- )
283
- # Check if the case statement contains Subquery objects
284
- if hasattr(case_stmt, "get_source_expressions"):
285
- source_exprs = case_stmt.get_source_expressions()
286
- for expr in source_exprs:
287
- if isinstance(expr, Subquery):
288
- logger.debug(
289
- f"Case statement for {field_name} contains Subquery"
290
- )
291
- elif hasattr(expr, "get_source_expressions"):
292
- # Check nested expressions (like Value objects)
293
- nested_exprs = expr.get_source_expressions()
294
- for nested_expr in nested_exprs:
295
- if isinstance(nested_expr, Subquery):
296
- logger.debug(
297
- f"Case statement for {field_name} contains nested Subquery"
298
- )
299
-
300
- kwargs = {**kwargs, **case_statements}
301
-
302
- # Use Django's built-in update logic directly
303
- # Call the base QuerySet implementation to avoid recursion
304
-
305
- # Additional safety check: ensure Subquery objects are properly handled
306
- # This prevents the "cannot adapt type 'Subquery'" error
307
- safe_kwargs = {}
308
- logger.debug(f"Processing {len(kwargs)} kwargs for safety check")
309
-
310
- for key, value in kwargs.items():
311
- logger.debug(
312
- f"Processing key '{key}' with value type {type(value).__name__}"
313
- )
314
-
315
- if isinstance(value, Subquery):
316
- logger.debug(f"Found Subquery for field {key}")
317
- # Ensure Subquery has proper output_field
318
- if not hasattr(value, "output_field") or value.output_field is None:
319
- logger.warning(
320
- f"Subquery for field {key} missing output_field, attempting to infer"
321
- )
322
- # Try to infer from the model field
323
- try:
324
- field = model_cls._meta.get_field(key)
325
- logger.debug(f"Inferred field type: {type(field).__name__}")
326
- value = value.resolve_expression(None, None)
327
- value.output_field = field
328
- logger.debug(f"Set output_field to {field}")
329
- except Exception as e:
330
- logger.error(
331
- f"Failed to infer output_field for Subquery on {key}: {e}"
332
- )
333
- raise
334
- else:
335
- logger.debug(
336
- f"Subquery for field {key} already has output_field: {value.output_field}"
337
- )
338
- safe_kwargs[key] = value
339
- elif hasattr(value, "get_source_expressions") and hasattr(
340
- value, "resolve_expression"
341
- ):
342
- # Handle Case statements and other complex expressions
343
- logger.debug(
344
- f"Found complex expression for field {key}: {type(value).__name__}"
345
- )
346
-
347
- # Check if this expression contains any Subquery objects
348
- source_expressions = value.get_source_expressions()
349
- has_nested_subquery = False
17
+ QuerySet with hook support.
350
18
 
351
- for expr in source_expressions:
352
- if isinstance(expr, Subquery):
353
- has_nested_subquery = True
354
- logger.debug(f"Found nested Subquery in {type(value).__name__}")
355
- # Ensure the nested Subquery has proper output_field
356
- if (
357
- not hasattr(expr, "output_field")
358
- or expr.output_field is None
359
- ):
360
- try:
361
- field = model_cls._meta.get_field(key)
362
- expr.output_field = field
363
- logger.debug(
364
- f"Set output_field for nested Subquery to {field}"
365
- )
366
- except Exception as e:
367
- logger.error(
368
- f"Failed to set output_field for nested Subquery: {e}"
369
- )
370
- raise
19
+ This is a thin facade over BulkOperationCoordinator. It provides
20
+ backward-compatible API for Django's QuerySet while integrating
21
+ the full hook lifecycle.
371
22
 
372
- if has_nested_subquery:
373
- logger.debug(
374
- "Expression contains Subquery, ensuring proper output_field"
375
- )
376
- # Try to resolve the expression to ensure it's properly formatted
377
- try:
378
- resolved_value = value.resolve_expression(None, None)
379
- safe_kwargs[key] = resolved_value
380
- logger.debug(f"Successfully resolved expression for {key}")
381
- except Exception as e:
382
- logger.error(f"Failed to resolve expression for {key}: {e}")
383
- raise
384
- else:
385
- safe_kwargs[key] = value
386
- else:
387
- logger.debug(
388
- f"Non-Subquery value for field {key}: {type(value).__name__}"
389
- )
390
- safe_kwargs[key] = value
391
-
392
- logger.debug(f"Safe kwargs keys: {list(safe_kwargs.keys())}")
393
- logger.debug(
394
- f"Safe kwargs types: {[(k, type(v).__name__) for k, v in safe_kwargs.items()]}"
395
- )
396
-
397
- logger.debug(f"Calling super().update() with {len(safe_kwargs)} kwargs")
398
- try:
399
- update_count = super().update(**safe_kwargs)
400
- logger.debug(f"Super update successful, count: {update_count}")
401
- except Exception as e:
402
- logger.error(f"Super update failed: {e}")
403
- logger.error(f"Exception type: {type(e).__name__}")
404
- logger.error(f"Safe kwargs that caused failure: {safe_kwargs}")
405
- raise
406
-
407
- # If we used Subquery objects, refresh the instances to get computed values
408
- # and run BEFORE_UPDATE hooks so HasChanged conditions work correctly
409
- if has_subquery and instances and not current_bypass_hooks:
410
- logger.debug(
411
- "Refreshing instances with Subquery computed values before running hooks"
412
- )
413
- # Simple refresh of model fields without fetching related objects
414
- # Subquery updates only affect the model's own fields, not relationships
415
- refreshed_instances = {
416
- obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
417
- }
418
-
419
- # Bulk update all instances in memory and save pre-hook state
420
- pre_hook_state = {}
421
- for instance in instances:
422
- if instance.pk in refreshed_instances:
423
- refreshed_instance = refreshed_instances[instance.pk]
424
- # Save current state before modifying for hook comparison
425
- pre_hook_values = {}
426
- for field in model_cls._meta.fields:
427
- if field.name != "id":
428
- pre_hook_values[field.name] = getattr(
429
- refreshed_instance, field.name
430
- )
431
- setattr(
432
- instance,
433
- field.name,
434
- getattr(refreshed_instance, field.name),
435
- )
436
- pre_hook_state[instance.pk] = pre_hook_values
437
-
438
- # Now run BEFORE_UPDATE hooks with refreshed instances so conditions work
439
- logger.debug("Running BEFORE_UPDATE hooks after Subquery refresh")
440
- engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
441
-
442
- # Check if hooks modified any fields and persist them with bulk_update
443
- hook_modified_fields = set()
444
- for instance in instances:
445
- if instance.pk in pre_hook_state:
446
- pre_hook_values = pre_hook_state[instance.pk]
447
- for field_name, pre_hook_value in pre_hook_values.items():
448
- current_value = getattr(instance, field_name)
449
- if current_value != pre_hook_value:
450
- hook_modified_fields.add(field_name)
23
+ Key design principles:
24
+ - Minimal logic (< 10 lines per method)
25
+ - No business logic (delegate to coordinator)
26
+ - No conditionals (let services handle it)
27
+ - Transaction boundaries only
28
+ """
451
29
 
452
- hook_modified_fields = list(hook_modified_fields)
453
- if hook_modified_fields:
454
- logger.debug(
455
- f"Running bulk_update for hook-modified fields: {hook_modified_fields}"
456
- )
457
- # Use bulk_update to persist hook modifications, bypassing hooks to avoid recursion
458
- model_cls.objects.bulk_update(
459
- instances, hook_modified_fields, bypass_hooks=True
460
- )
30
+ def __init__(self, *args, **kwargs):
31
+ super().__init__(*args, **kwargs)
32
+ self._coordinator = None
461
33
 
462
- # Salesforce-style: Always run AFTER_UPDATE hooks unless explicitly bypassed
463
- if not current_bypass_hooks:
464
- logger.debug("update: running AFTER_UPDATE")
465
- engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
466
- else:
467
- logger.debug("update: AFTER_UPDATE explicitly bypassed")
34
+ @property
35
+ def coordinator(self):
36
+ """Lazy initialization of coordinator"""
37
+ if self._coordinator is None:
38
+ from django_bulk_hooks.operations import BulkOperationCoordinator
468
39
 
469
- return update_count
40
+ self._coordinator = BulkOperationCoordinator(self)
41
+ return self._coordinator
470
42
 
471
43
  @transaction.atomic
472
44
  def bulk_create(
@@ -481,1725 +53,137 @@ class HookQuerySetMixin:
481
53
  bypass_validation=False,
482
54
  ):
483
55
  """
484
- Insert each of the instances into the database. Behaves like Django's bulk_create,
485
- but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
486
- passed through to the correct logic. For MTI, only a subset of options may be supported.
56
+ Create multiple objects with hook support.
57
+
58
+ This is the public API - delegates to coordinator.
487
59
  """
488
- model_cls, ctx, originals = self._setup_bulk_operation(
489
- objs,
490
- "bulk_create",
491
- require_pks=False,
492
- bypass_hooks=bypass_hooks,
493
- bypass_validation=bypass_validation,
60
+ return self.coordinator.create(
61
+ objs=objs,
62
+ batch_size=batch_size,
63
+ ignore_conflicts=ignore_conflicts,
494
64
  update_conflicts=update_conflicts,
495
- unique_fields=unique_fields,
496
65
  update_fields=update_fields,
66
+ unique_fields=unique_fields,
67
+ bypass_hooks=bypass_hooks,
68
+ bypass_validation=bypass_validation,
497
69
  )
498
70
 
499
- # When you bulk insert you don't get the primary keys back (if it's an
500
- # autoincrement, except if can_return_rows_from_bulk_insert=True), so
501
- # you can't insert into the child tables which references this. There
502
- # are two workarounds:
503
- # 1) This could be implemented if you didn't have an autoincrement pk
504
- # 2) You could do it by doing O(n) normal inserts into the parent
505
- # tables to get the primary keys back and then doing a single bulk
506
- # insert into the childmost table.
507
- # We currently set the primary keys on the objects when using
508
- # PostgreSQL via the RETURNING ID clause. It should be possible for
509
- # Oracle as well, but the semantics for extracting the primary keys is
510
- # trickier so it's not done yet.
511
- if batch_size is not None and batch_size <= 0:
512
- raise ValueError("Batch size must be a positive integer.")
513
-
514
- if not objs:
515
- return objs
516
-
517
- self._validate_objects(objs, require_pks=False, operation_name="bulk_create")
518
-
519
- # Check for MTI - if we detect multi-table inheritance, we need special handling
520
- is_mti = self._is_multi_table_inheritance()
521
-
522
- # Fire hooks before DB ops
523
- if not bypass_hooks:
524
- if update_conflicts and unique_fields:
525
- # For upsert operations, we need to determine which records will be created vs updated
526
- # Check which records already exist in the database based on unique fields
527
- existing_records = []
528
- new_records = []
529
-
530
- # We'll store the records for AFTER hooks after classification is complete
531
-
532
- # Build a filter to check which records already exist
533
- unique_values = []
534
- for obj in objs:
535
- unique_value = {}
536
- query_fields = {} # Track which database field to use for each unique field
537
- for field_name in unique_fields:
538
- # First check for _id field (more reliable for ForeignKeys)
539
- if hasattr(obj, field_name + "_id"):
540
- # Handle ForeignKey fields where _id suffix is used
541
- unique_value[field_name] = getattr(obj, field_name + "_id")
542
- query_fields[field_name] = (
543
- field_name + "_id"
544
- ) # Use _id field for query
545
- elif hasattr(obj, field_name):
546
- unique_value[field_name] = getattr(obj, field_name)
547
- query_fields[field_name] = field_name
548
- if unique_value:
549
- unique_values.append((unique_value, query_fields))
550
-
551
- if unique_values:
552
- # Query the database to see which records already exist - SINGLE BULK QUERY
553
- from django.db.models import Q
554
-
555
- existing_filters = Q()
556
- for unique_value, query_fields in unique_values:
557
- filter_kwargs = {}
558
- for field_name, value in unique_value.items():
559
- # Use the correct database field name (may include _id suffix)
560
- db_field_name = query_fields[field_name]
561
- filter_kwargs[db_field_name] = value
562
- existing_filters |= Q(**filter_kwargs)
563
-
564
- logger.debug(
565
- f"DEBUG: Existence check query filters: {existing_filters}"
566
- )
567
- logger.debug(
568
- f"DEBUG: Unique fields for values_list: {unique_fields}"
569
- )
570
-
571
- # Get all existing records in one query and create a lookup set
572
- # We need to use the original unique_fields for values_list to maintain consistency
573
- existing_records_lookup = set()
574
- existing_query = model_cls.objects.filter(existing_filters)
575
- logger.debug(f"DEBUG: Existence check SQL: {existing_query.query}")
576
-
577
- # Also get the raw database values for debugging
578
- raw_existing = list(existing_query.values_list(*unique_fields))
579
- logger.debug(f"DEBUG: Raw existing records from DB: {raw_existing}")
580
-
581
- # Convert database values to match object types for comparison
582
- # This handles cases where object values are strings but DB values are integers
583
- existing_records_lookup = set()
584
- for existing_record in raw_existing:
585
- # Convert each value in the tuple to match the type from object extraction
586
- converted_record = []
587
- for i, field_name in enumerate(unique_fields):
588
- db_value = existing_record[i]
589
- # Convert all values to strings for consistent comparison
590
- # This ensures all database values are strings like object values
591
- converted_record.append(str(db_value))
592
- converted_tuple = tuple(converted_record)
593
- existing_records_lookup.add(converted_tuple)
594
-
595
- logger.debug(
596
- f"DEBUG: Found {len(raw_existing)} existing records from DB"
597
- )
598
- logger.debug(
599
- f"DEBUG: Existing records lookup set: {existing_records_lookup}"
600
- )
601
-
602
- # Separate records based on whether they already exist
603
- for obj in objs:
604
- obj_unique_value = {}
605
- for field_name in unique_fields:
606
- # First check for _id field (more reliable for ForeignKeys)
607
- if hasattr(obj, field_name + "_id"):
608
- # Handle ForeignKey fields where _id suffix is used
609
- obj_unique_value[field_name] = getattr(
610
- obj, field_name + "_id"
611
- )
612
- elif hasattr(obj, field_name):
613
- obj_unique_value[field_name] = getattr(obj, field_name)
614
-
615
- # Check if this record already exists using our bulk lookup
616
- if obj_unique_value:
617
- # Convert object values to tuple for comparison with existing records
618
- # Apply the same type conversion as we did for database values
619
- obj_unique_tuple = []
620
- for field_name in unique_fields:
621
- value = obj_unique_value[field_name]
622
- # Check if this field uses _id suffix in the query
623
- query_field_name = query_fields[field_name]
624
- if query_field_name.endswith("_id"):
625
- # Convert to string to match how we convert DB values
626
- obj_unique_tuple.append(str(value))
627
- else:
628
- # For non-_id fields, also convert to string for consistency
629
- # This ensures all values are strings like in the database lookup
630
- obj_unique_tuple.append(str(value))
631
- obj_unique_tuple = tuple(obj_unique_tuple)
632
-
633
- logger.debug(
634
- f"DEBUG: Object unique tuple: {obj_unique_tuple}"
635
- )
636
- logger.debug(
637
- f"DEBUG: Object unique value: {obj_unique_value}"
638
- )
639
- if obj_unique_tuple in existing_records_lookup:
640
- existing_records.append(obj)
641
- logger.debug(
642
- f"DEBUG: Found existing record for tuple: {obj_unique_tuple}"
643
- )
644
- else:
645
- new_records.append(obj)
646
- logger.debug(
647
- f"DEBUG: No existing record found for tuple: {obj_unique_tuple}"
648
- )
649
- else:
650
- # If we can't determine uniqueness, treat as new
651
- new_records.append(obj)
652
- else:
653
- # If no unique fields, treat all as new
654
- new_records = objs
655
-
656
- # Store the classified records for AFTER hooks to avoid duplicate queries
657
- ctx.upsert_existing_records = existing_records
658
- ctx.upsert_new_records = new_records
659
-
660
- # Handle auto_now fields intelligently for upsert operations
661
- # Only set auto_now fields on records that will actually be created
662
- self._handle_auto_now_fields(new_records, add=True)
663
-
664
- # For existing records, preserve their original auto_now values
665
- # We'll need to fetch them from the database to preserve the timestamps
666
- if existing_records:
667
- # Get the unique field values for existing records
668
- existing_unique_values = []
669
- for obj in existing_records:
670
- unique_value = {}
671
- for field_name in unique_fields:
672
- if hasattr(obj, field_name):
673
- unique_value[field_name] = getattr(obj, field_name)
674
- if unique_value:
675
- existing_unique_values.append(unique_value)
676
-
677
- if existing_unique_values:
678
- # Build filter to fetch existing records
679
- existing_filters = Q()
680
- for unique_value in existing_unique_values:
681
- filter_kwargs = {}
682
- for field_name, value in unique_value.items():
683
- filter_kwargs[field_name] = value
684
- existing_filters |= Q(**filter_kwargs)
685
-
686
- # Fetch existing records to preserve their auto_now values
687
- existing_db_records = model_cls.objects.filter(existing_filters)
688
- existing_db_map = {}
689
- for db_record in existing_db_records:
690
- key = tuple(
691
- getattr(db_record, field) for field in unique_fields
692
- )
693
- existing_db_map[key] = db_record
694
-
695
- # For existing records, populate all fields from database and set auto_now fields
696
- for obj in existing_records:
697
- key = tuple(getattr(obj, field) for field in unique_fields)
698
- if key in existing_db_map:
699
- db_record = existing_db_map[key]
700
- # Copy all fields from the database record to ensure completeness
701
- # but exclude auto_now_add fields which should never be updated
702
- populated_fields = []
703
- for field in model_cls._meta.local_fields:
704
- if field.name != "id": # Don't overwrite the ID
705
- # Skip auto_now_add fields for existing records
706
- if (
707
- hasattr(field, "auto_now_add")
708
- and field.auto_now_add
709
- ):
710
- continue
711
- db_value = getattr(db_record, field.name)
712
- if (
713
- db_value is not None
714
- ): # Only set non-None values
715
- setattr(obj, field.name, db_value)
716
- populated_fields.append(field.name)
717
- print(
718
- f"DEBUG: Populated {len(populated_fields)} fields for existing record: {populated_fields}"
719
- )
720
- logger.debug(
721
- f"Populated {len(populated_fields)} fields for existing record: {populated_fields}"
722
- )
723
-
724
- # Now set auto_now fields using Django's pre_save method
725
- for field in model_cls._meta.local_fields:
726
- if hasattr(field, "auto_now") and field.auto_now:
727
- field.pre_save(
728
- obj, add=False
729
- ) # add=False for updates
730
- print(
731
- f"DEBUG: Set {field.name} using pre_save for existing record {obj.pk}"
732
- )
733
- logger.debug(
734
- f"Set {field.name} using pre_save for existing record {obj.pk}"
735
- )
736
-
737
- # Remove duplicate code since we're now handling this above
738
-
739
- # CRITICAL: Handle auto_now fields intelligently for existing records
740
- # We need to exclude them from Django's ON CONFLICT DO UPDATE clause to prevent
741
- # Django's default behavior, but still ensure they get updated via pre_save
742
- if existing_records and update_fields:
743
- logger.debug(
744
- f"Processing {len(existing_records)} existing records with update_fields: {update_fields}"
745
- )
746
-
747
- # Identify auto_now fields
748
- auto_now_fields = set()
749
- for field in model_cls._meta.local_fields:
750
- if hasattr(field, "auto_now") and field.auto_now:
751
- auto_now_fields.add(field.name)
752
-
753
- logger.debug(f"Found auto_now fields: {auto_now_fields}")
754
-
755
- if auto_now_fields:
756
- # Store original update_fields and auto_now fields for later restoration
757
- ctx.original_update_fields = update_fields
758
- ctx.auto_now_fields = auto_now_fields
759
-
760
- # Filter out auto_now fields from update_fields for the database operation
761
- # This prevents Django from including them in ON CONFLICT DO UPDATE
762
- filtered_update_fields = [
763
- f for f in update_fields if f not in auto_now_fields
764
- ]
765
-
766
- logger.debug(
767
- f"Filtered update_fields: {filtered_update_fields}"
768
- )
769
- logger.debug(f"Excluded auto_now fields: {auto_now_fields}")
770
-
771
- # Use filtered update_fields for Django's bulk_create operation
772
- update_fields = filtered_update_fields
773
-
774
- logger.debug(
775
- f"Final update_fields for DB operation: {update_fields}"
776
- )
777
- else:
778
- logger.debug("No auto_now fields found to handle")
779
- else:
780
- logger.debug(
781
- f"No existing records or update_fields to process. existing_records: {len(existing_records) if existing_records else 0}, update_fields: {update_fields}"
782
- )
783
-
784
- # Run validation hooks on all records
785
- if not bypass_validation:
786
- engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
787
-
788
- # Run appropriate BEFORE hooks based on what will happen
789
- if new_records:
790
- engine.run(model_cls, BEFORE_CREATE, new_records, ctx=ctx)
791
- if existing_records:
792
- engine.run(model_cls, BEFORE_UPDATE, existing_records, ctx=ctx)
793
- else:
794
- # For regular create operations, run create hooks before DB ops
795
- # Handle auto_now fields normally for new records
796
- self._handle_auto_now_fields(objs, add=True)
797
-
798
- if not bypass_validation:
799
- engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
800
- engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
801
- else:
802
- logger.debug("bulk_create bypassed hooks")
803
-
804
- # For MTI models, we need to handle them specially
805
- if is_mti:
806
- # Use our MTI-specific logic
807
- # Filter out custom parameters that Django's bulk_create doesn't accept
808
- mti_kwargs = {
809
- "batch_size": batch_size,
810
- "ignore_conflicts": ignore_conflicts,
811
- "update_conflicts": update_conflicts,
812
- "update_fields": update_fields,
813
- "unique_fields": unique_fields,
814
- }
815
-
816
- # If we have classified records from upsert logic, pass them to MTI method
817
- if (
818
- update_conflicts
819
- and unique_fields
820
- and hasattr(ctx, "upsert_existing_records")
821
- ):
822
- mti_kwargs["existing_records"] = ctx.upsert_existing_records
823
- mti_kwargs["new_records"] = ctx.upsert_new_records
824
-
825
- # Remove custom hook kwargs if present in self.bulk_create signature
826
- result = self._mti_bulk_create(
827
- objs,
828
- **mti_kwargs,
829
- )
830
- else:
831
- # For single-table models, use Django's built-in bulk_create
832
- # but we need to call it on the base manager to avoid recursion
833
- # Filter out custom parameters that Django's bulk_create doesn't accept
834
-
835
- logger.debug(
836
- f"Calling Django bulk_create with update_fields: {update_fields}"
837
- )
838
- logger.debug(
839
- f"Calling Django bulk_create with update_conflicts: {update_conflicts}"
840
- )
841
- logger.debug(
842
- f"Calling Django bulk_create with unique_fields: {unique_fields}"
843
- )
844
-
845
- result = super().bulk_create(
846
- objs,
847
- batch_size=batch_size,
848
- ignore_conflicts=ignore_conflicts,
849
- update_conflicts=update_conflicts,
850
- update_fields=update_fields,
851
- unique_fields=unique_fields,
852
- )
853
-
854
- logger.debug(f"Django bulk_create completed with result: {result}")
855
-
856
- # Fire AFTER hooks
857
- if not bypass_hooks:
858
- if update_conflicts and unique_fields:
859
- # Handle auto_now fields that were excluded from the main update
860
- if hasattr(ctx, "auto_now_fields") and existing_records:
861
- logger.debug(
862
- f"Performing separate update for auto_now fields: {ctx.auto_now_fields}"
863
- )
864
-
865
- # Perform a separate bulk_update for the auto_now fields that were set via pre_save
866
- # This ensures they get saved to the database even though they were excluded from the main upsert
867
- try:
868
- # Use Django's base manager to bypass hooks and ensure the update happens
869
- base_manager = model_cls._base_manager
870
- auto_now_update_result = base_manager.bulk_update(
871
- existing_records, list(ctx.auto_now_fields)
872
- )
873
- logger.debug(
874
- f"Auto_now fields update completed with result: {auto_now_update_result}"
875
- )
876
- except Exception as e:
877
- logger.error(f"Failed to update auto_now fields: {e}")
878
- # Don't raise the exception - the main operation succeeded
879
-
880
- # Restore original update_fields if we modified them
881
- if hasattr(ctx, "original_update_fields"):
882
- logger.debug(
883
- f"Restoring original update_fields: {ctx.original_update_fields}"
884
- )
885
- update_fields = ctx.original_update_fields
886
- delattr(ctx, "original_update_fields")
887
- if hasattr(ctx, "auto_now_fields"):
888
- delattr(ctx, "auto_now_fields")
889
- logger.debug(f"Restored update_fields: {update_fields}")
890
-
891
- # For upsert operations, reuse the existing/new records determination from BEFORE hooks
892
- # This avoids duplicate queries and improves performance
893
- if hasattr(ctx, "upsert_existing_records") and hasattr(
894
- ctx, "upsert_new_records"
895
- ):
896
- existing_records = ctx.upsert_existing_records
897
- new_records = ctx.upsert_new_records
898
- logger.debug(
899
- f"Reusing upsert record classification from BEFORE hooks: {len(existing_records)} existing, {len(new_records)} new"
900
- )
901
- else:
902
- # Fallback: determine records that actually exist after bulk operation
903
- logger.warning(
904
- "Upsert record classification not found in context, performing fallback query"
905
- )
906
- existing_records = []
907
- new_records = []
908
-
909
- # Build a filter to check which records now exist
910
- unique_values = []
911
- for obj in objs:
912
- unique_value = {}
913
- for field_name in unique_fields:
914
- if hasattr(obj, field_name):
915
- unique_value[field_name] = getattr(obj, field_name)
916
- if unique_value:
917
- unique_values.append(unique_value)
918
-
919
- if unique_values:
920
- # Query the database to see which records exist after bulk operation
921
- from django.db.models import Q
922
-
923
- existing_filters = Q()
924
- for unique_value in unique_values:
925
- filter_kwargs = {}
926
- for field_name, value in unique_value.items():
927
- filter_kwargs[field_name] = value
928
- existing_filters |= Q(**filter_kwargs)
929
-
930
- # Get all existing records in one query and create a lookup set
931
- existing_records_lookup = set()
932
- for existing_record in model_cls.objects.filter(
933
- existing_filters
934
- ).values_list(*unique_fields):
935
- # Convert tuple to a hashable key for lookup
936
- existing_records_lookup.add(existing_record)
937
-
938
- # Separate records based on whether they now exist
939
- for obj in objs:
940
- obj_unique_value = {}
941
- for field_name in unique_fields:
942
- if hasattr(obj, field_name):
943
- obj_unique_value[field_name] = getattr(
944
- obj, field_name
945
- )
946
-
947
- # Check if this record exists using our bulk lookup
948
- if obj_unique_value:
949
- # Convert object values to tuple for comparison with existing records
950
- obj_unique_tuple = tuple(
951
- obj_unique_value[field_name]
952
- for field_name in unique_fields
953
- )
954
- if obj_unique_tuple in existing_records_lookup:
955
- existing_records.append(obj)
956
- else:
957
- new_records.append(obj)
958
- else:
959
- # If we can't determine uniqueness, treat as new
960
- new_records.append(obj)
961
- else:
962
- # If no unique fields, treat all as new
963
- new_records = objs
964
-
965
- # Run appropriate AFTER hooks based on what actually happened
966
- if new_records:
967
- engine.run(model_cls, AFTER_CREATE, new_records, ctx=ctx)
968
- if existing_records:
969
- engine.run(model_cls, AFTER_UPDATE, existing_records, ctx=ctx)
970
- else:
971
- # For regular create operations, run create hooks after DB ops
972
- engine.run(model_cls, AFTER_CREATE, objs, ctx=ctx)
973
-
974
- return result
975
-
976
- def _detect_changed_fields(self, objs):
977
- """
978
- Auto-detect which fields have changed by comparing objects with database values.
979
- Returns a set of field names that have changed across all objects.
980
- """
981
- if not objs:
982
- return set()
983
-
984
- model_cls = self.model
985
- changed_fields = set()
986
-
987
- # Get primary key field names
988
- pk_fields = [f.name for f in model_cls._meta.pk_fields]
989
- if not pk_fields:
990
- pk_fields = ["pk"]
991
-
992
- # Get all object PKs
993
- obj_pks = []
994
- for obj in objs:
995
- if hasattr(obj, "pk") and obj.pk is not None:
996
- obj_pks.append(obj.pk)
997
- else:
998
- # Skip objects without PKs
999
- continue
1000
-
1001
- if not obj_pks:
1002
- return set()
1003
-
1004
- # Fetch current database values for all objects
1005
- existing_objs = {
1006
- obj.pk: obj for obj in model_cls.objects.filter(pk__in=obj_pks)
1007
- }
1008
-
1009
- # Compare each object's current values with database values
1010
- for obj in objs:
1011
- if obj.pk not in existing_objs:
1012
- continue
1013
-
1014
- db_obj = existing_objs[obj.pk]
1015
-
1016
- # Check all concrete fields for changes
1017
- for field in model_cls._meta.concrete_fields:
1018
- field_name = field.name
1019
-
1020
- # Skip primary key fields
1021
- if field_name in pk_fields:
1022
- continue
1023
-
1024
- # Get current value from object
1025
- current_value = getattr(obj, field_name, None)
1026
- # Get database value
1027
- db_value = getattr(db_obj, field_name, None)
1028
-
1029
- # Compare values (handle None cases)
1030
- if current_value != db_value:
1031
- changed_fields.add(field_name)
1032
-
1033
- return changed_fields
1034
-
1035
71
  @transaction.atomic
1036
- def bulk_update(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
1037
- if not objs:
1038
- return []
1039
-
1040
- self._validate_objects(objs, require_pks=True, operation_name="bulk_update")
1041
-
1042
- changed_fields = self._detect_changed_fields(objs)
1043
- is_mti = self._is_multi_table_inheritance()
1044
- hook_context, originals = self._init_hook_context(
1045
- bypass_hooks, objs, "bulk_update"
1046
- )
1047
-
1048
- fields_set, auto_now_fields, custom_update_fields = self._prepare_update_fields(
1049
- changed_fields
1050
- )
1051
-
1052
- self._apply_auto_now_fields(objs, auto_now_fields)
1053
- self._apply_custom_update_fields(objs, custom_update_fields, fields_set)
1054
-
1055
- if is_mti:
1056
- return self._mti_bulk_update(objs, list(fields_set), **kwargs)
1057
- else:
1058
- return self._single_table_bulk_update(
1059
- objs, fields_set, auto_now_fields, **kwargs
1060
- )
1061
-
1062
- def _apply_custom_update_fields(self, objs, custom_update_fields, fields_set):
1063
- """
1064
- Call pre_save() for custom fields that require update handling
1065
- (e.g., CurrentUserField) and update both the objects and the field set.
1066
-
1067
- Args:
1068
- objs (list[Model]): The model instances being updated.
1069
- custom_update_fields (list[Field]): Fields that define a pre_save() hook.
1070
- fields_set (set[str]): The overall set of fields to update, mutated in place.
1071
- """
1072
- if not custom_update_fields:
1073
- return
1074
-
1075
- model_cls = self.model
1076
- pk_field_names = [f.name for f in model_cls._meta.pk_fields]
1077
-
1078
- logger.debug(
1079
- "Applying pre_save() on custom update fields: %s",
1080
- [f.name for f in custom_update_fields],
1081
- )
1082
-
1083
- for obj in objs:
1084
- for field in custom_update_fields:
1085
- try:
1086
- # Call pre_save with add=False (since this is an update)
1087
- new_value = field.pre_save(obj, add=False)
1088
-
1089
- # Only assign if pre_save returned something
1090
- if new_value is not None:
1091
- setattr(obj, field.name, new_value)
1092
-
1093
- # Ensure this field is included in the update set
1094
- if (
1095
- field.name not in fields_set
1096
- and field.name not in pk_field_names
1097
- ):
1098
- fields_set.add(field.name)
1099
-
1100
- logger.debug(
1101
- "Custom field %s updated via pre_save() for object %s",
1102
- field.name,
1103
- obj.pk,
1104
- )
1105
-
1106
- except Exception as e:
1107
- logger.warning(
1108
- "Failed to call pre_save() on custom field %s for object %s: %s",
1109
- field.name,
1110
- getattr(obj, "pk", None),
1111
- e,
1112
- )
1113
-
1114
- def _single_table_bulk_update(self, objs, fields_set, auto_now_fields, **kwargs):
1115
- """
1116
- Perform bulk_update for single-table models, handling Django semantics
1117
- for kwargs and setting a value map for hook execution.
1118
-
1119
- Args:
1120
- objs (list[Model]): The model instances being updated.
1121
- fields_set (set[str]): The names of fields to update.
1122
- auto_now_fields (list[str]): Names of auto_now fields included in update.
1123
- **kwargs: Extra arguments (only Django-supported ones are passed through).
1124
-
1125
- Returns:
1126
- list[Model]: The updated model instances.
1127
- """
1128
- # Strip out unsupported bulk_update kwargs
1129
- django_kwargs = self._filter_django_kwargs(kwargs)
1130
-
1131
- # Build a value map: {pk -> {field: raw_value}} for later hook use
1132
- value_map = self._build_value_map(objs, fields_set, auto_now_fields)
1133
-
1134
- if value_map:
1135
- set_bulk_update_value_map(value_map)
1136
-
1137
- try:
1138
- logger.debug(
1139
- "Calling Django bulk_update for %d objects on fields %s",
1140
- len(objs),
1141
- list(fields_set),
1142
- )
1143
- return super().bulk_update(objs, list(fields_set), **django_kwargs)
1144
- finally:
1145
- # Always clear thread-local state
1146
- set_bulk_update_value_map(None)
1147
-
1148
- def _filter_django_kwargs(self, kwargs):
1149
- """
1150
- Remove unsupported arguments before passing to Django's bulk_update.
1151
- """
1152
- unsupported = {
1153
- "unique_fields",
1154
- "update_conflicts",
1155
- "update_fields",
1156
- "ignore_conflicts",
1157
- }
1158
- passthrough = {}
1159
- for k, v in kwargs.items():
1160
- if k in unsupported:
1161
- logger.warning(
1162
- "Parameter '%s' is not supported by bulk_update. "
1163
- "It is only available for bulk_create UPSERT operations.",
1164
- k,
1165
- )
1166
- elif k not in {"bypass_hooks", "bypass_validation"}:
1167
- passthrough[k] = v
1168
- return passthrough
1169
-
1170
- def _build_value_map(self, objs, fields_set, auto_now_fields):
1171
- """
1172
- Build a mapping of {pk -> {field_name: raw_value}} for hook processing.
1173
-
1174
- Expressions are not included; only concrete values assigned on the object.
1175
- """
1176
- value_map = {}
1177
- for obj in objs:
1178
- if obj.pk is None:
1179
- continue # skip unsaved objects
1180
- field_values = {}
1181
- for field_name in fields_set:
1182
- value = getattr(obj, field_name)
1183
- field_values[field_name] = value
1184
- if field_name in auto_now_fields:
1185
- logger.debug("Object %s %s=%s", obj.pk, field_name, value)
1186
- if field_values:
1187
- value_map[obj.pk] = field_values
1188
-
1189
- logger.debug("Built value_map for %d objects", len(value_map))
1190
- return value_map
1191
-
1192
- def _validate_objects(self, objs, require_pks=False, operation_name="bulk_update"):
1193
- """
1194
- Validate that all objects are instances of this queryset's model.
1195
-
1196
- Args:
1197
- objs (list): Objects to validate
1198
- require_pks (bool): Whether to validate that objects have primary keys
1199
- operation_name (str): Name of the operation for error messages
1200
- """
1201
- model_cls = self.model
1202
-
1203
- # Type check
1204
- invalid_types = {
1205
- type(obj).__name__ for obj in objs if not isinstance(obj, model_cls)
1206
- }
1207
- if invalid_types:
1208
- raise TypeError(
1209
- f"{operation_name} expected instances of {model_cls.__name__}, "
1210
- f"but got {invalid_types}"
1211
- )
1212
-
1213
- # Primary key check (optional, for operations that require saved objects)
1214
- if require_pks:
1215
- missing_pks = [obj for obj in objs if obj.pk is None]
1216
- if missing_pks:
1217
- raise ValueError(
1218
- f"{operation_name} cannot operate on unsaved {model_cls.__name__} instances. "
1219
- f"{len(missing_pks)} object(s) have no primary key."
1220
- )
1221
-
1222
- logger.debug(
1223
- "Validated %d %s objects for %s",
1224
- len(objs),
1225
- model_cls.__name__,
1226
- operation_name,
1227
- )
1228
-
1229
- def _init_hook_context(
1230
- self, bypass_hooks: bool, objs, operation_name="bulk_update"
1231
- ):
1232
- """
1233
- Initialize the hook context for bulk operations.
1234
-
1235
- Args:
1236
- bypass_hooks (bool): Whether to bypass hooks
1237
- objs (list): List of objects being operated on
1238
- operation_name (str): Name of the operation for logging
1239
-
1240
- Returns:
1241
- (HookContext, list): The hook context and a placeholder list
1242
- for 'originals', which can be populated later if needed for
1243
- after_update hooks.
1244
- """
1245
- model_cls = self.model
1246
-
1247
- if bypass_hooks:
1248
- logger.debug(
1249
- "%s: hooks bypassed for %s", operation_name, model_cls.__name__
1250
- )
1251
- ctx = HookContext(model_cls, bypass_hooks=True)
1252
- else:
1253
- logger.debug("%s: hooks enabled for %s", operation_name, model_cls.__name__)
1254
- ctx = HookContext(model_cls, bypass_hooks=False)
1255
-
1256
- # Keep `originals` aligned with objs to support later hook execution.
1257
- originals = [None] * len(objs)
1258
-
1259
- return ctx, originals
1260
-
1261
- def _prepare_update_fields(self, changed_fields):
1262
- """
1263
- Determine the final set of fields to update, including auto_now
1264
- fields and custom fields that require pre_save() on updates.
1265
-
1266
- Args:
1267
- changed_fields (Iterable[str]): Fields detected as changed.
1268
-
1269
- Returns:
1270
- tuple:
1271
- fields_set (set): All fields that should be updated.
1272
- auto_now_fields (list[str]): Fields that require auto_now behavior.
1273
- custom_update_fields (list[Field]): Fields with pre_save hooks to call.
1274
- """
1275
- model_cls = self.model
1276
- fields_set = set(changed_fields)
1277
- pk_field_names = [f.name for f in model_cls._meta.pk_fields]
1278
-
1279
- auto_now_fields = []
1280
- custom_update_fields = []
1281
-
1282
- for field in model_cls._meta.local_concrete_fields:
1283
- # Handle auto_now fields
1284
- if getattr(field, "auto_now", False):
1285
- if field.name not in fields_set and field.name not in pk_field_names:
1286
- fields_set.add(field.name)
1287
- if field.name != field.attname: # handle attname vs name
1288
- fields_set.add(field.attname)
1289
- auto_now_fields.append(field.name)
1290
- logger.debug("Added auto_now field %s to update set", field.name)
1291
-
1292
- # Skip auto_now_add (only applies at creation time)
1293
- elif getattr(field, "auto_now_add", False):
1294
- continue
1295
-
1296
- # Handle custom pre_save fields
1297
- elif hasattr(field, "pre_save"):
1298
- if field.name not in fields_set and field.name not in pk_field_names:
1299
- custom_update_fields.append(field)
1300
- logger.debug(
1301
- "Marked custom field %s for pre_save update", field.name
1302
- )
1303
-
1304
- logger.debug(
1305
- "Prepared update fields: fields_set=%s, auto_now_fields=%s, custom_update_fields=%s",
1306
- fields_set,
1307
- auto_now_fields,
1308
- [f.name for f in custom_update_fields],
1309
- )
1310
-
1311
- return fields_set, auto_now_fields, custom_update_fields
1312
-
1313
- def _apply_auto_now_fields(self, objs, auto_now_fields, add=False):
1314
- """
1315
- Apply the current timestamp to all auto_now fields on each object.
1316
-
1317
- Args:
1318
- objs (list[Model]): The model instances being processed.
1319
- auto_now_fields (list[str]): Field names that require auto_now behavior.
1320
- add (bool): Whether this is for creation (add=True) or update (add=False).
1321
- """
1322
- if not auto_now_fields:
1323
- return
1324
-
1325
- from django.utils import timezone
1326
-
1327
- current_time = timezone.now()
1328
-
1329
- logger.debug(
1330
- "Setting auto_now fields %s to %s for %d objects (add=%s)",
1331
- auto_now_fields,
1332
- current_time,
1333
- len(objs),
1334
- add,
1335
- )
1336
-
1337
- for obj in objs:
1338
- for field_name in auto_now_fields:
1339
- setattr(obj, field_name, current_time)
1340
-
1341
- def _handle_auto_now_fields(self, objs, add=False):
1342
- """
1343
- Handle auto_now and auto_now_add fields for objects.
1344
-
1345
- Args:
1346
- objs (list[Model]): The model instances being processed.
1347
- add (bool): Whether this is for creation (add=True) or update (add=False).
1348
-
1349
- Returns:
1350
- list[str]: Names of auto_now fields that were handled.
1351
- """
1352
- model_cls = self.model
1353
- handled_fields = []
1354
-
1355
- for obj in objs:
1356
- for field in model_cls._meta.local_fields:
1357
- # Handle auto_now_add only during creation
1358
- if add and hasattr(field, "auto_now_add") and field.auto_now_add:
1359
- if getattr(obj, field.name) is None:
1360
- field.pre_save(obj, add=True)
1361
- handled_fields.append(field.name)
1362
- # Handle auto_now during creation or update
1363
- elif hasattr(field, "auto_now") and field.auto_now:
1364
- field.pre_save(obj, add=add)
1365
- handled_fields.append(field.name)
1366
-
1367
- return list(set(handled_fields)) # Remove duplicates
1368
-
1369
- def _execute_hooks_with_operation(
72
+ def bulk_update(
1370
73
  self,
1371
- operation_func,
1372
- validate_hook,
1373
- before_hook,
1374
- after_hook,
1375
74
  objs,
1376
- originals=None,
1377
- ctx=None,
75
+ fields=None,
76
+ batch_size=None,
1378
77
  bypass_hooks=False,
1379
78
  bypass_validation=False,
79
+ **kwargs,
1380
80
  ):
1381
81
  """
1382
- Execute the complete hook lifecycle around a database operation.
82
+ Update multiple objects with hook support.
83
+
84
+ This is the public API - delegates to coordinator.
1383
85
 
1384
86
  Args:
1385
- operation_func (callable): The database operation to execute
1386
- validate_hook: Hook constant for validation
1387
- before_hook: Hook constant for before operation
1388
- after_hook: Hook constant for after operation
1389
- objs (list): Objects being operated on
1390
- originals (list, optional): Original objects for comparison hooks
1391
- ctx: Hook context
1392
- bypass_hooks (bool): Whether to skip hooks
1393
- bypass_validation (bool): Whether to skip validation hooks
87
+ objs: List of model instances to update
88
+ fields: List of field names to update (optional, will auto-detect if None)
89
+ batch_size: Number of objects per batch
90
+ bypass_hooks: Skip all hooks if True
91
+ bypass_validation: Skip validation hooks if True
1394
92
 
1395
93
  Returns:
1396
- The result of the database operation
94
+ Number of objects updated
1397
95
  """
1398
- model_cls = self.model
1399
-
1400
- # Run validation hooks first (if not bypassed)
1401
- if not bypass_validation and validate_hook:
1402
- engine.run(model_cls, validate_hook, objs, ctx=ctx)
1403
-
1404
- # Run before hooks (if not bypassed)
1405
- if not bypass_hooks and before_hook:
1406
- engine.run(model_cls, before_hook, objs, originals, ctx=ctx)
1407
-
1408
- # Execute the database operation
1409
- result = operation_func()
1410
-
1411
- # Run after hooks (if not bypassed)
1412
- if not bypass_hooks and after_hook:
1413
- engine.run(model_cls, after_hook, objs, originals, ctx=ctx)
1414
-
1415
- return result
1416
-
1417
- def _log_bulk_operation_start(self, operation_name, objs, **kwargs):
1418
- """
1419
- Log the start of a bulk operation with consistent formatting.
1420
-
1421
- Args:
1422
- operation_name (str): Name of the operation (e.g., "bulk_create")
1423
- objs (list): Objects being operated on
1424
- **kwargs: Additional parameters to log
1425
- """
1426
- model_cls = self.model
1427
-
1428
- # Build parameter string for additional kwargs
1429
- param_str = ""
1430
- if kwargs:
1431
- param_parts = []
1432
- for key, value in kwargs.items():
1433
- if isinstance(value, (list, tuple)):
1434
- param_parts.append(f"{key}={value}")
1435
- else:
1436
- param_parts.append(f"{key}={value}")
1437
- param_str = f", {', '.join(param_parts)}"
96
+ # If fields is None, auto-detect changed fields using analyzer
97
+ if fields is None:
98
+ fields = self.coordinator.analyzer.detect_changed_fields(objs)
99
+ if not fields:
100
+ logger.debug(
101
+ f"bulk_update: No fields changed for {len(objs)} {self.model.__name__} objects"
102
+ )
103
+ return 0
1438
104
 
1439
- # Use both print and logger for consistency with existing patterns
1440
- print(
1441
- f"DEBUG: {operation_name} called for {model_cls.__name__} with {len(objs)} objects{param_str}"
1442
- )
1443
- logger.debug(
1444
- f"{operation_name} called for {model_cls.__name__} with {len(objs)} objects{param_str}"
105
+ return self.coordinator.update(
106
+ objs=objs,
107
+ fields=fields,
108
+ batch_size=batch_size,
109
+ bypass_hooks=bypass_hooks,
110
+ bypass_validation=bypass_validation,
1445
111
  )
1446
112
 
1447
- def _execute_delete_hooks_with_operation(
1448
- self,
1449
- operation_func,
1450
- objs,
1451
- ctx=None,
1452
- bypass_hooks=False,
1453
- bypass_validation=False,
1454
- ):
1455
- """
1456
- Execute hooks for delete operations with special field caching logic.
1457
-
1458
- Args:
1459
- operation_func (callable): The delete operation to execute
1460
- objs (list): Objects being deleted
1461
- ctx: Hook context
1462
- bypass_hooks (bool): Whether to skip hooks
1463
- bypass_validation (bool): Whether to skip validation hooks
1464
-
1465
- Returns:
1466
- The result of the delete operation
113
+ @transaction.atomic
114
+ def update(self, bypass_hooks=False, bypass_validation=False, **kwargs):
1467
115
  """
1468
- model_cls = self.model
1469
-
1470
- # Run validation hooks first (if not bypassed)
1471
- if not bypass_validation:
1472
- engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
1473
-
1474
- # Run before hooks (if not bypassed)
1475
- if not bypass_hooks:
1476
- engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
1477
-
1478
- # Before deletion, ensure all related fields are properly cached
1479
- # to avoid DoesNotExist errors in AFTER_DELETE hooks
1480
- for obj in objs:
1481
- if obj.pk is not None:
1482
- # Cache all foreign key relationships by accessing them
1483
- for field in model_cls._meta.fields:
1484
- if (
1485
- field.is_relation
1486
- and not field.many_to_many
1487
- and not field.one_to_many
1488
- ):
1489
- try:
1490
- # Access the related field to cache it before deletion
1491
- getattr(obj, field.name)
1492
- except Exception:
1493
- # If we can't access the field (e.g., already deleted, no permission, etc.)
1494
- # continue with other fields
1495
- pass
1496
-
1497
- # Execute the database operation
1498
- result = operation_func()
1499
-
1500
- # Run after hooks (if not bypassed)
1501
- if not bypass_hooks:
1502
- engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
116
+ Update QuerySet with hook support.
1503
117
 
1504
- return result
1505
-
1506
- def _setup_bulk_operation(
1507
- self,
1508
- objs,
1509
- operation_name,
1510
- require_pks=False,
1511
- bypass_hooks=False,
1512
- bypass_validation=False,
1513
- **log_kwargs,
1514
- ):
1515
- """
1516
- Common setup logic for bulk operations.
118
+ This is the public API - delegates to coordinator.
1517
119
 
1518
120
  Args:
1519
- objs (list): Objects to operate on
1520
- operation_name (str): Name of the operation for logging and validation
1521
- require_pks (bool): Whether objects must have primary keys
1522
- bypass_hooks (bool): Whether to bypass hooks
1523
- bypass_validation (bool): Whether to bypass validation
1524
- **log_kwargs: Additional parameters to log
121
+ bypass_hooks: Skip all hooks if True
122
+ bypass_validation: Skip validation hooks if True
123
+ **kwargs: Fields to update
1525
124
 
1526
125
  Returns:
1527
- tuple: (model_cls, ctx, originals)
126
+ Number of objects updated
1528
127
  """
1529
- # Log operation start
1530
- self._log_bulk_operation_start(operation_name, objs, **log_kwargs)
1531
-
1532
- # Validate objects
1533
- self._validate_objects(
1534
- objs, require_pks=require_pks, operation_name=operation_name
128
+ return self.coordinator.update_queryset(
129
+ update_kwargs=kwargs,
130
+ bypass_hooks=bypass_hooks,
131
+ bypass_validation=bypass_validation,
1535
132
  )
1536
133
 
1537
- # Initialize hook context
1538
- ctx, originals = self._init_hook_context(bypass_hooks, objs, operation_name)
1539
-
1540
- return self.model, ctx, originals
1541
-
1542
- def _is_multi_table_inheritance(self) -> bool:
1543
- """
1544
- Determine whether this model uses multi-table inheritance (MTI).
1545
- Returns True if the model has any concrete parent models other than itself.
1546
- """
1547
- model_cls = self.model
1548
- for parent in model_cls._meta.all_parents:
1549
- if parent._meta.concrete_model is not model_cls._meta.concrete_model:
1550
- logger.debug(
1551
- "%s detected as MTI model (parent: %s)",
1552
- model_cls.__name__,
1553
- getattr(parent, "__name__", str(parent)),
1554
- )
1555
- return True
1556
-
1557
- logger.debug("%s is not an MTI model", model_cls.__name__)
1558
- return False
1559
-
1560
- def _detect_modified_fields(self, new_instances, original_instances):
1561
- """
1562
- Detect fields that were modified during BEFORE_UPDATE hooks by comparing
1563
- new instances with their original values.
1564
-
1565
- IMPORTANT: Skip fields that contain Django expression objects (Subquery, Case, etc.)
1566
- as these should not be treated as in-memory modifications.
1567
- """
1568
- if not original_instances:
1569
- return set()
1570
-
1571
- modified_fields = set()
1572
-
1573
- # Since original_instances is now ordered to match new_instances, we can zip them directly
1574
- for new_instance, original in zip(new_instances, original_instances):
1575
- if new_instance.pk is None or original is None:
1576
- continue
1577
-
1578
- # Compare all fields to detect changes
1579
- for field in new_instance._meta.fields:
1580
- if field.name == "id":
1581
- continue
1582
-
1583
- # Get the new value to check if it's an expression object
1584
- new_value = getattr(new_instance, field.name)
1585
-
1586
- # Skip fields that contain expression objects - these are not in-memory modifications
1587
- # but rather database-level expressions that should not be applied to instances
1588
- from django.db.models import Subquery
1589
-
1590
- if isinstance(new_value, Subquery) or hasattr(
1591
- new_value, "resolve_expression"
1592
- ):
1593
- logger.debug(
1594
- f"Skipping field {field.name} with expression value: {type(new_value).__name__}"
1595
- )
1596
- continue
1597
-
1598
- # Handle different field types appropriately
1599
- if field.is_relation:
1600
- # Compare by raw id values to catch cases where only <fk>_id was set
1601
- original_pk = getattr(original, field.attname, None)
1602
- if new_value != original_pk:
1603
- modified_fields.add(field.name)
1604
- else:
1605
- original_value = getattr(original, field.name)
1606
- if new_value != original_value:
1607
- modified_fields.add(field.name)
1608
-
1609
- return modified_fields
1610
-
1611
- def _get_inheritance_chain(self):
1612
- """
1613
- Get the complete inheritance chain from root parent to current model.
1614
- Returns list of model classes in order: [RootParent, Parent, Child]
1615
- """
1616
- chain = []
1617
- current_model = self.model
1618
- while current_model:
1619
- if not current_model._meta.proxy:
1620
- chain.append(current_model)
1621
-
1622
- parents = [
1623
- parent
1624
- for parent in current_model._meta.parents.keys()
1625
- if not parent._meta.proxy
1626
- ]
1627
- current_model = parents[0] if parents else None
1628
-
1629
- chain.reverse()
1630
- return chain
1631
-
1632
- def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
1633
- """
1634
- Implements Django's suggested workaround #2 for MTI bulk_create:
1635
- O(n) normal inserts into parent tables to get primary keys back,
1636
- then single bulk insert into childmost table.
1637
- Sets auto_now_add/auto_now fields for each model in the chain.
1638
- """
1639
- # Extract classified records if available (for upsert operations)
1640
- existing_records = kwargs.pop("existing_records", [])
1641
- new_records = kwargs.pop("new_records", [])
1642
-
1643
- # Remove custom hook kwargs before passing to Django internals
1644
- django_kwargs = {
1645
- k: v
1646
- for k, v in kwargs.items()
1647
- if k not in ["bypass_hooks", "bypass_validation"]
1648
- }
1649
- if inheritance_chain is None:
1650
- inheritance_chain = self._get_inheritance_chain()
1651
-
1652
- # Safety check to prevent infinite recursion
1653
- if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
1654
- raise ValueError(
1655
- "Inheritance chain too deep - possible infinite recursion detected"
1656
- )
1657
-
1658
- batch_size = django_kwargs.get("batch_size") or len(objs)
1659
- created_objects = []
1660
- with transaction.atomic(using=self.db, savepoint=False):
1661
- for i in range(0, len(objs), batch_size):
1662
- batch = objs[i : i + batch_size]
1663
- batch_result = self._process_mti_bulk_create_batch(
1664
- batch,
1665
- inheritance_chain,
1666
- existing_records,
1667
- new_records,
1668
- **django_kwargs,
1669
- )
1670
- created_objects.extend(batch_result)
1671
- return created_objects
1672
-
1673
- def _process_mti_bulk_create_batch(
1674
- self,
1675
- batch,
1676
- inheritance_chain,
1677
- existing_records=None,
1678
- new_records=None,
1679
- **kwargs,
1680
- ):
1681
- """
1682
- Process a single batch of objects through the inheritance chain.
1683
- Implements Django's suggested workaround #2: O(n) normal inserts into parent
1684
- tables to get primary keys back, then single bulk insert into childmost table.
1685
- """
1686
- # For MTI, we need to save parent objects first to get PKs
1687
- # Then we can use Django's bulk_create for the child objects
1688
- parent_objects_map = {}
1689
-
1690
- # Step 1: Do O(n) normal inserts into parent tables to get primary keys back
1691
- # Get bypass_hooks from kwargs
1692
- bypass_hooks = kwargs.get("bypass_hooks", False)
1693
- bypass_validation = kwargs.get("bypass_validation", False)
1694
-
1695
- # Create a list for lookup (since model instances without PKs are not hashable)
1696
- existing_records_list = existing_records if existing_records else []
1697
-
1698
- for obj in batch:
1699
- parent_instances = {}
1700
- current_parent = None
1701
- is_existing_record = obj in existing_records_list
1702
-
1703
- for model_class in inheritance_chain[:-1]:
1704
- parent_obj = self._create_parent_instance(
1705
- obj, model_class, current_parent
1706
- )
1707
-
1708
- if is_existing_record:
1709
- # For existing records, we need to update the parent object instead of creating
1710
- # The parent_obj should already have the correct PK from the database lookup
1711
- # Fire parent hooks for updates
1712
- if not bypass_hooks:
1713
- ctx = HookContext(model_class)
1714
- if not bypass_validation:
1715
- engine.run(
1716
- model_class, VALIDATE_UPDATE, [parent_obj], ctx=ctx
1717
- )
1718
- engine.run(model_class, BEFORE_UPDATE, [parent_obj], ctx=ctx)
1719
-
1720
- # Update the existing parent object
1721
- # Filter update_fields to only include fields that exist in the parent model
1722
- parent_update_fields = kwargs.get("update_fields")
1723
- if parent_update_fields:
1724
- # Only include fields that exist in the parent model
1725
- parent_model_fields = {
1726
- field.name for field in model_class._meta.local_fields
1727
- }
1728
- filtered_update_fields = [
1729
- field
1730
- for field in parent_update_fields
1731
- if field in parent_model_fields
1732
- ]
1733
- parent_obj.save(update_fields=filtered_update_fields)
1734
- else:
1735
- parent_obj.save()
1736
-
1737
- # Fire AFTER_UPDATE hooks for parent
1738
- if not bypass_hooks:
1739
- engine.run(model_class, AFTER_UPDATE, [parent_obj], ctx=ctx)
1740
- else:
1741
- # For new records, create the parent object as before
1742
- # Fire parent hooks if not bypassed
1743
- if not bypass_hooks:
1744
- ctx = HookContext(model_class)
1745
- if not bypass_validation:
1746
- engine.run(
1747
- model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx
1748
- )
1749
- engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
1750
-
1751
- # Use Django's base manager to create the object and get PKs back
1752
- # This bypasses hooks and the MTI exception
1753
- field_values = {
1754
- field.name: getattr(parent_obj, field.name)
1755
- for field in model_class._meta.local_fields
1756
- if hasattr(parent_obj, field.name)
1757
- and getattr(parent_obj, field.name) is not None
1758
- }
1759
- created_obj = model_class._base_manager.using(self.db).create(
1760
- **field_values
1761
- )
1762
-
1763
- # Update the parent_obj with the created object's PK
1764
- parent_obj.pk = created_obj.pk
1765
- parent_obj._state.adding = False
1766
- parent_obj._state.db = self.db
1767
-
1768
- # Fire AFTER_CREATE hooks for parent
1769
- if not bypass_hooks:
1770
- engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
1771
-
1772
- parent_instances[model_class] = parent_obj
1773
- current_parent = parent_obj
1774
- parent_objects_map[id(obj)] = parent_instances
1775
-
1776
- # Step 2: Handle child objects - create new ones and update existing ones
1777
- child_model = inheritance_chain[-1]
1778
- all_child_objects = []
1779
- existing_child_objects = []
1780
-
1781
- for obj in batch:
1782
- is_existing_record = obj in existing_records_list
1783
-
1784
- if is_existing_record:
1785
- # For existing records, update the child object
1786
- child_obj = self._create_child_instance(
1787
- obj, child_model, parent_objects_map.get(id(obj), {})
1788
- )
1789
- existing_child_objects.append(child_obj)
1790
- else:
1791
- # For new records, create the child object
1792
- child_obj = self._create_child_instance(
1793
- obj, child_model, parent_objects_map.get(id(obj), {})
1794
- )
1795
- all_child_objects.append(child_obj)
1796
-
1797
- # Step 2.5: Update existing child objects
1798
- if existing_child_objects:
1799
- for child_obj in existing_child_objects:
1800
- # Filter update_fields to only include fields that exist in the child model
1801
- child_update_fields = kwargs.get("update_fields")
1802
- if child_update_fields:
1803
- # Only include fields that exist in the child model
1804
- child_model_fields = {
1805
- field.name for field in child_model._meta.local_fields
1806
- }
1807
- filtered_child_update_fields = [
1808
- field
1809
- for field in child_update_fields
1810
- if field in child_model_fields
1811
- ]
1812
- child_obj.save(update_fields=filtered_child_update_fields)
1813
- else:
1814
- child_obj.save()
1815
-
1816
- # Step 2.6: Use Django's internal bulk_create infrastructure for new child objects
1817
- if all_child_objects:
1818
- # Get the base manager's queryset
1819
- base_qs = child_model._base_manager.using(self.db)
1820
-
1821
- # Use Django's exact approach: call _prepare_for_bulk_create then partition
1822
- base_qs._prepare_for_bulk_create(all_child_objects)
1823
-
1824
- # Implement our own partition since itertools.partition might not be available
1825
- objs_without_pk, objs_with_pk = [], []
1826
- for obj in all_child_objects:
1827
- if obj._is_pk_set():
1828
- objs_with_pk.append(obj)
1829
- else:
1830
- objs_without_pk.append(obj)
1831
-
1832
- # Use Django's internal _batched_insert method
1833
- opts = child_model._meta
1834
- # For child models in MTI, we need to include the foreign key to the parent
1835
- # but exclude the primary key since it's inherited
1836
-
1837
- # Include all local fields except generated ones
1838
- # We need to include the foreign key to the parent (business_ptr)
1839
- fields = [f for f in opts.local_fields if not f.generated]
1840
-
1841
- with transaction.atomic(using=self.db, savepoint=False):
1842
- if objs_with_pk:
1843
- returned_columns = base_qs._batched_insert(
1844
- objs_with_pk,
1845
- fields,
1846
- batch_size=len(objs_with_pk), # Use actual batch size
1847
- )
1848
- for obj_with_pk, results in zip(objs_with_pk, returned_columns):
1849
- for result, field in zip(results, opts.db_returning_fields):
1850
- if field != opts.pk:
1851
- setattr(obj_with_pk, field.attname, result)
1852
- for obj_with_pk in objs_with_pk:
1853
- obj_with_pk._state.adding = False
1854
- obj_with_pk._state.db = self.db
1855
-
1856
- if objs_without_pk:
1857
- # For objects without PK, we still need to exclude primary key fields
1858
- fields = [
1859
- f
1860
- for f in fields
1861
- if not isinstance(f, AutoField) and not f.primary_key
1862
- ]
1863
- returned_columns = base_qs._batched_insert(
1864
- objs_without_pk,
1865
- fields,
1866
- batch_size=len(objs_without_pk), # Use actual batch size
1867
- )
1868
- for obj_without_pk, results in zip(
1869
- objs_without_pk, returned_columns
1870
- ):
1871
- for result, field in zip(results, opts.db_returning_fields):
1872
- setattr(obj_without_pk, field.attname, result)
1873
- obj_without_pk._state.adding = False
1874
- obj_without_pk._state.db = self.db
1875
-
1876
- # Step 3: Update original objects with generated PKs and state
1877
- pk_field_name = child_model._meta.pk.name
1878
-
1879
- # Handle new objects
1880
- for orig_obj, child_obj in zip(batch, all_child_objects):
1881
- child_pk = getattr(child_obj, pk_field_name)
1882
- setattr(orig_obj, pk_field_name, child_pk)
1883
- orig_obj._state.adding = False
1884
- orig_obj._state.db = self.db
1885
-
1886
- # Handle existing objects (they already have PKs, just update state)
1887
- for orig_obj in batch:
1888
- is_existing_record = orig_obj in existing_records_list
1889
- if is_existing_record:
1890
- orig_obj._state.adding = False
1891
- orig_obj._state.db = self.db
1892
-
1893
- return batch
1894
-
1895
- def _create_parent_instance(self, source_obj, parent_model, current_parent):
1896
- parent_obj = parent_model()
1897
- for field in parent_model._meta.local_fields:
1898
- # Only copy if the field exists on the source and is not None
1899
- if hasattr(source_obj, field.name):
1900
- value = getattr(source_obj, field.name, None)
1901
- if value is not None:
1902
- setattr(parent_obj, field.name, value)
1903
- if current_parent is not None:
1904
- for field in parent_model._meta.local_fields:
1905
- if (
1906
- hasattr(field, "remote_field")
1907
- and field.remote_field
1908
- and field.remote_field.model == current_parent.__class__
1909
- ):
1910
- setattr(parent_obj, field.name, current_parent)
1911
- break
1912
-
1913
- # Handle auto_now_add and auto_now fields like Django does
1914
- for field in parent_model._meta.local_fields:
1915
- if hasattr(field, "auto_now_add") and field.auto_now_add:
1916
- # Ensure auto_now_add fields are properly set
1917
- if getattr(parent_obj, field.name) is None:
1918
- field.pre_save(parent_obj, add=True)
1919
- # Explicitly set the value to ensure it's not None
1920
- setattr(parent_obj, field.name, field.value_from_object(parent_obj))
1921
- elif hasattr(field, "auto_now") and field.auto_now:
1922
- field.pre_save(parent_obj, add=True)
1923
-
1924
- return parent_obj
1925
-
1926
- def _create_child_instance(self, source_obj, child_model, parent_instances):
1927
- child_obj = child_model()
1928
- # Only copy fields that exist in the child model's local fields
1929
- for field in child_model._meta.local_fields:
1930
- if isinstance(field, AutoField):
1931
- continue
1932
- if hasattr(source_obj, field.name):
1933
- value = getattr(source_obj, field.name, None)
1934
- if value is not None:
1935
- setattr(child_obj, field.name, value)
1936
-
1937
- # Set parent links for MTI
1938
- for parent_model, parent_instance in parent_instances.items():
1939
- parent_link = child_model._meta.get_ancestor_link(parent_model)
1940
- if parent_link:
1941
- # Set both the foreign key value (the ID) and the object reference
1942
- # This follows Django's pattern in _set_pk_val
1943
- setattr(
1944
- child_obj, parent_link.attname, parent_instance.pk
1945
- ) # Set the foreign key value
1946
- setattr(
1947
- child_obj, parent_link.name, parent_instance
1948
- ) # Set the object reference
1949
-
1950
- # Handle auto_now_add and auto_now fields like Django does
1951
- for field in child_model._meta.local_fields:
1952
- if hasattr(field, "auto_now_add") and field.auto_now_add:
1953
- # Ensure auto_now_add fields are properly set
1954
- if getattr(child_obj, field.name) is None:
1955
- field.pre_save(child_obj, add=True)
1956
- # Explicitly set the value to ensure it's not None
1957
- setattr(child_obj, field.name, field.value_from_object(child_obj))
1958
- elif hasattr(field, "auto_now") and field.auto_now:
1959
- field.pre_save(child_obj, add=True)
1960
-
1961
- return child_obj
1962
-
1963
- def _mti_bulk_update(
1964
- self, objs, fields, field_groups=None, inheritance_chain=None, **kwargs
134
+ @transaction.atomic
135
+ def bulk_delete(
136
+ self, objs, bypass_hooks=False, bypass_validation=False, **kwargs
1965
137
  ):
1966
138
  """
1967
- Custom bulk update implementation for MTI models.
1968
- Updates each table in the inheritance chain efficiently using Django's batch_size.
1969
- """
1970
- model_cls = self.model
1971
- if inheritance_chain is None:
1972
- inheritance_chain = self._get_inheritance_chain()
1973
-
1974
- # Remove custom hook kwargs and unsupported parameters before passing to Django internals
1975
- unsupported_params = [
1976
- "unique_fields",
1977
- "update_conflicts",
1978
- "update_fields",
1979
- "ignore_conflicts",
1980
- ]
1981
- django_kwargs = {}
1982
- for k, v in kwargs.items():
1983
- if k in unsupported_params:
1984
- logger.warning(
1985
- f"Parameter '{k}' is not supported by bulk_update. "
1986
- f"This parameter is only available in bulk_create for UPSERT operations."
1987
- )
1988
- print(f"WARNING: Parameter '{k}' is not supported by bulk_update")
1989
- elif k not in ["bypass_hooks", "bypass_validation"]:
1990
- django_kwargs[k] = v
139
+ Delete multiple objects with hook support.
1991
140
 
1992
- # Safety check to prevent infinite recursion
1993
- if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
1994
- raise ValueError(
1995
- "Inheritance chain too deep - possible infinite recursion detected"
1996
- )
141
+ This is the public API - delegates to coordinator.
1997
142
 
1998
- # Handle auto_now fields and custom fields by calling pre_save on objects
1999
- # Check all models in the inheritance chain for auto_now and custom fields
2000
- custom_update_fields = []
2001
- for obj in objs:
2002
- for model in inheritance_chain:
2003
- for field in model._meta.local_fields:
2004
- if hasattr(field, "auto_now") and field.auto_now:
2005
- field.pre_save(obj, add=False)
2006
- # Check for custom fields that might need pre_save() on update (like CurrentUserField)
2007
- elif hasattr(field, "pre_save") and field.name not in fields:
2008
- try:
2009
- new_value = field.pre_save(obj, add=False)
2010
- if new_value is not None:
2011
- setattr(obj, field.name, new_value)
2012
- custom_update_fields.append(field.name)
2013
- logger.debug(
2014
- f"Custom field {field.name} updated via pre_save() for MTI object {obj.pk}"
2015
- )
2016
- except Exception as e:
2017
- logger.warning(
2018
- f"Failed to call pre_save() on custom field {field.name} in MTI: {e}"
2019
- )
2020
-
2021
- # Add auto_now fields to the fields list so they get updated in the database
2022
- auto_now_fields = set()
2023
- for model in inheritance_chain:
2024
- for field in model._meta.local_fields:
2025
- if hasattr(field, "auto_now") and field.auto_now:
2026
- auto_now_fields.add(field.name)
2027
-
2028
- # Add custom fields that were updated to the fields list
2029
- all_fields = list(fields) + list(auto_now_fields) + custom_update_fields
2030
-
2031
- # Group fields by model in the inheritance chain (if not provided)
2032
- if field_groups is None:
2033
- field_groups = {}
2034
- for field_name in all_fields:
2035
- field = model_cls._meta.get_field(field_name)
2036
- # Find which model in the inheritance chain this field belongs to
2037
- for model in inheritance_chain:
2038
- if field in model._meta.local_fields:
2039
- if model not in field_groups:
2040
- field_groups[model] = []
2041
- field_groups[model].append(field_name)
2042
- break
2043
-
2044
- # Process in batches
2045
- batch_size = django_kwargs.get("batch_size") or len(objs)
2046
- total_updated = 0
2047
-
2048
- with transaction.atomic(using=self.db, savepoint=False):
2049
- for i in range(0, len(objs), batch_size):
2050
- batch = objs[i : i + batch_size]
2051
- batch_result = self._process_mti_bulk_update_batch(
2052
- batch, field_groups, inheritance_chain, **django_kwargs
2053
- )
2054
- total_updated += batch_result
2055
-
2056
- return total_updated
143
+ Args:
144
+ objs: List of objects to delete
145
+ bypass_hooks: Skip all hooks if True
146
+ bypass_validation: Skip validation hooks if True
2057
147
 
2058
- def _process_mti_bulk_update_batch(
2059
- self, batch, field_groups, inheritance_chain, **kwargs
2060
- ):
2061
- """
2062
- Process a single batch of objects for MTI bulk update.
2063
- Updates each table in the inheritance chain for the batch.
148
+ Returns:
149
+ Tuple of (count, details dict)
2064
150
  """
2065
- total_updated = 0
2066
-
2067
- # For MTI, we need to handle parent links correctly
2068
- # The root model (first in chain) has its own PK
2069
- # Child models use the parent link to reference the root PK
2070
- root_model = inheritance_chain[0]
2071
-
2072
- # Get the primary keys from the objects
2073
- # If objects have pk set but are not loaded from DB, use those PKs
2074
- root_pks = []
2075
- for obj in batch:
2076
- # Check both pk and id attributes
2077
- pk_value = getattr(obj, "pk", None)
2078
- if pk_value is None:
2079
- pk_value = getattr(obj, "id", None)
2080
-
2081
- if pk_value is not None:
2082
- root_pks.append(pk_value)
2083
- else:
2084
- continue
2085
-
2086
- if not root_pks:
151
+ # Filter queryset to only these objects
152
+ pks = [obj.pk for obj in objs if obj.pk is not None]
153
+ if not pks:
2087
154
  return 0
2088
155
 
2089
- # Update each table in the inheritance chain
2090
- for model, model_fields in field_groups.items():
2091
- if not model_fields:
2092
- continue
2093
-
2094
- if model == inheritance_chain[0]:
2095
- # Root model - use primary keys directly
2096
- pks = root_pks
2097
- filter_field = "pk"
2098
- else:
2099
- # Child model - use parent link field
2100
- parent_link = None
2101
- for parent_model in inheritance_chain:
2102
- if parent_model in model._meta.parents:
2103
- parent_link = model._meta.parents[parent_model]
2104
- break
2105
-
2106
- if parent_link is None:
2107
- continue
2108
-
2109
- # For child models, the parent link values should be the same as root PKs
2110
- pks = root_pks
2111
- filter_field = parent_link.attname
156
+ # Create a filtered queryset
157
+ filtered_qs = self.filter(pk__in=pks)
2112
158
 
2113
- if pks:
2114
- base_qs = model._base_manager.using(self.db)
159
+ # Use coordinator with the filtered queryset
160
+ from django_bulk_hooks.operations import BulkOperationCoordinator
2115
161
 
2116
- # Check if records exist
2117
- existing_count = base_qs.filter(**{f"{filter_field}__in": pks}).count()
162
+ coordinator = BulkOperationCoordinator(filtered_qs)
2118
163
 
2119
- if existing_count == 0:
2120
- continue
2121
-
2122
- # Build CASE statements for each field to perform a single bulk update
2123
- case_statements = {}
2124
- for field_name in model_fields:
2125
- field = model._meta.get_field(field_name)
2126
- when_statements = []
2127
-
2128
- for pk, obj in zip(pks, batch):
2129
- # Check both pk and id attributes for the object
2130
- obj_pk = getattr(obj, "pk", None)
2131
- if obj_pk is None:
2132
- obj_pk = getattr(obj, "id", None)
2133
-
2134
- if obj_pk is None:
2135
- continue
2136
- value = getattr(obj, field_name)
2137
- when_statements.append(
2138
- When(
2139
- **{filter_field: pk},
2140
- then=Value(value, output_field=field),
2141
- )
2142
- )
2143
-
2144
- case_statements[field_name] = Case(
2145
- *when_statements, output_field=field
2146
- )
2147
-
2148
- # Execute a single bulk update for all objects in this model
2149
- try:
2150
- updated_count = base_qs.filter(
2151
- **{f"{filter_field}__in": pks}
2152
- ).update(**case_statements)
2153
- total_updated += updated_count
2154
- except Exception as e:
2155
- import traceback
2156
-
2157
- traceback.print_exc()
164
+ count, details = coordinator.delete(
165
+ bypass_hooks=bypass_hooks,
166
+ bypass_validation=bypass_validation,
167
+ )
2158
168
 
2159
- return total_updated
169
+ # For bulk_delete, return just the count to match Django's behavior
170
+ return count
2160
171
 
2161
172
  @transaction.atomic
2162
- def bulk_delete(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
2163
- """
2164
- Bulk delete objects in the database.
173
+ def delete(self, bypass_hooks=False, bypass_validation=False):
2165
174
  """
2166
- model_cls = self.model
175
+ Delete QuerySet with hook support.
2167
176
 
2168
- if not objs:
2169
- return 0
177
+ This is the public API - delegates to coordinator.
2170
178
 
2171
- model_cls, ctx, _ = self._setup_bulk_operation(
2172
- objs,
2173
- "bulk_delete",
2174
- require_pks=True,
2175
- bypass_hooks=bypass_hooks,
2176
- bypass_validation=bypass_validation,
2177
- )
2178
-
2179
- # Execute the database operation with hooks
2180
- def delete_operation():
2181
- pks = [obj.pk for obj in objs if obj.pk is not None]
2182
- if pks:
2183
- # Use the base manager to avoid recursion
2184
- return self.model._base_manager.filter(pk__in=pks).delete()[0]
2185
- else:
2186
- return 0
179
+ Args:
180
+ bypass_hooks: Skip all hooks if True
181
+ bypass_validation: Skip validation hooks if True
2187
182
 
2188
- result = self._execute_delete_hooks_with_operation(
2189
- delete_operation,
2190
- objs,
2191
- ctx=ctx,
183
+ Returns:
184
+ Tuple of (count, details dict)
185
+ """
186
+ return self.coordinator.delete(
2192
187
  bypass_hooks=bypass_hooks,
2193
188
  bypass_validation=bypass_validation,
2194
189
  )
2195
-
2196
- return result
2197
-
2198
-
2199
- class HookQuerySet(HookQuerySetMixin, models.QuerySet):
2200
- """
2201
- A QuerySet that provides bulk hook functionality.
2202
- This is the traditional approach for backward compatibility.
2203
- """
2204
-
2205
- pass