winidjango 1.0.4__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.
@@ -0,0 +1,644 @@
1
+ """Bulk utilities for Django models.
2
+
3
+ This module provides utility functions for working with Django models,
4
+ including bulk operations and validation. These utilities help with
5
+ efficiently managing large amounts of data in Django applications.
6
+ """
7
+
8
+ from collections import defaultdict
9
+ from collections.abc import Callable, Generator, Iterable
10
+ from functools import partial
11
+ from itertools import islice
12
+ from typing import TYPE_CHECKING, Any, Literal, cast, get_args, overload
13
+
14
+ from django.db import router, transaction
15
+ from django.db.models import (
16
+ Field,
17
+ Model,
18
+ QuerySet,
19
+ )
20
+ from django.db.models.deletion import Collector
21
+ from winiutils.src.iterating.concurrent.multithreading import multithread_loop
22
+
23
+ from winidjango.src.db.models import (
24
+ hash_model_instance,
25
+ topological_sort_models,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from django.contrib.contenttypes.fields import GenericForeignKey
30
+ from django.db.models.fields.related import ForeignObjectRel
31
+
32
+ import logging
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ MODE_TYPES = Literal["create", "update", "delete"]
37
+ MODES = get_args(MODE_TYPES)
38
+
39
+ MODE_CREATE = MODES[0]
40
+ MODE_UPDATE = MODES[1]
41
+ MODE_DELETE = MODES[2]
42
+
43
+ STANDARD_BULK_SIZE = 1000
44
+
45
+
46
+ def bulk_create_in_steps[TModel: Model](
47
+ model: type[TModel],
48
+ bulk: Iterable[TModel],
49
+ step: int = STANDARD_BULK_SIZE,
50
+ ) -> list[TModel]:
51
+ """Create model instances from bulk and saves them to the database in steps.
52
+
53
+ Takes a list of model instances and creates them in the database in steps.
54
+ This is useful when you want to create a large number of objects
55
+ in the database. It also uses multithreading to speed up the process.
56
+
57
+ Args:
58
+ model (type[Model]): The Django model class to create.
59
+ bulk (Iterable[Model]): a list of model instances to create.
60
+ step (int, optional): The step size of the bulk creation.
61
+ Defaults to STANDARD_BULK_SIZE.
62
+
63
+ Returns:
64
+ list[Model]: a list of created objects.
65
+ """
66
+ return cast(
67
+ "list[TModel]",
68
+ bulk_method_in_steps(model=model, bulk=bulk, step=step, mode=MODE_CREATE),
69
+ )
70
+
71
+
72
+ def bulk_update_in_steps[TModel: Model](
73
+ model: type[TModel],
74
+ bulk: Iterable[TModel],
75
+ update_fields: list[str],
76
+ step: int = STANDARD_BULK_SIZE,
77
+ ) -> int:
78
+ """Update model instances in the database in steps using multithreading.
79
+
80
+ Takes a list of model instances and updates them in the database in chunks.
81
+ This is useful when you want to update a large number of objects efficiently.
82
+ Uses multithreading to speed up the process by processing chunks in parallel.
83
+
84
+ Args:
85
+ model (type[Model]): The Django model class to update.
86
+ bulk (Iterable[Model]): A list of model instances to update.
87
+ update_fields (list[str]): List of field names to update on the models.
88
+ step (int, optional): The step size for bulk updates.
89
+ Defaults to STANDARD_BULK_SIZE.
90
+
91
+ Returns:
92
+ int: Total number of objects updated across all chunks.
93
+ """
94
+ return cast(
95
+ "int",
96
+ bulk_method_in_steps(
97
+ model=model, bulk=bulk, step=step, mode=MODE_UPDATE, fields=update_fields
98
+ ),
99
+ )
100
+
101
+
102
+ def bulk_delete_in_steps[TModel: Model](
103
+ model: type[TModel], bulk: Iterable[TModel], step: int = STANDARD_BULK_SIZE
104
+ ) -> tuple[int, dict[str, int]]:
105
+ """Delete model instances from the database in steps using multithreading.
106
+
107
+ Takes a list of model instances and deletes them from the database in chunks.
108
+ This is useful when you want to delete a large number of objects efficiently.
109
+ Uses multithreading to speed up the process by processing chunks in parallel.
110
+ Also handles cascade deletions according to model relationships.
111
+
112
+ Args:
113
+ model (type[Model]): The Django model class to update.
114
+ bulk (Iterable[Model]): A list of model instances to delete.
115
+ step (int, optional): The step size for bulk deletions.
116
+ Defaults to STANDARD_BULK_SIZE.
117
+
118
+ Returns:
119
+ tuple[int, dict[str, int]]: A tuple containing the
120
+ total count of deleted objects
121
+ and a dictionary mapping model names to their deletion counts.
122
+ """
123
+ return cast(
124
+ "tuple[int, dict[str, int]]",
125
+ bulk_method_in_steps(
126
+ model=model,
127
+ bulk=bulk,
128
+ step=step,
129
+ mode=MODE_DELETE,
130
+ ),
131
+ )
132
+
133
+
134
+ @overload
135
+ def bulk_method_in_steps[TModel: Model](
136
+ model: type[TModel],
137
+ bulk: Iterable[TModel],
138
+ step: int,
139
+ mode: Literal["create"],
140
+ **kwargs: Any,
141
+ ) -> list[TModel]: ...
142
+
143
+
144
+ @overload
145
+ def bulk_method_in_steps[TModel: Model](
146
+ model: type[TModel],
147
+ bulk: Iterable[TModel],
148
+ step: int,
149
+ mode: Literal["update"],
150
+ **kwargs: Any,
151
+ ) -> int: ...
152
+
153
+
154
+ @overload
155
+ def bulk_method_in_steps[TModel: Model](
156
+ model: type[TModel],
157
+ bulk: Iterable[TModel],
158
+ step: int,
159
+ mode: Literal["delete"],
160
+ **kwargs: Any,
161
+ ) -> tuple[int, dict[str, int]]: ...
162
+
163
+
164
+ def bulk_method_in_steps[TModel: Model](
165
+ model: type[TModel],
166
+ bulk: Iterable[TModel],
167
+ step: int,
168
+ mode: MODE_TYPES,
169
+ **kwargs: Any,
170
+ ) -> int | tuple[int, dict[str, int]] | list[TModel]:
171
+ """Execute bulk operations on model instances in steps with transaction handling.
172
+
173
+ This is the core function that handles bulk create, update, or delete operations
174
+ by dividing the work into manageable chunks and processing them with multithreading.
175
+ It includes transaction safety checks and delegates to the atomic version.
176
+
177
+ Args:
178
+ model (type[Model]): The Django model class to perform operations on.
179
+ bulk (Iterable[Model]): A list of model instances to process.
180
+ step (int): The step size for chunking the bulk operations.
181
+ mode (MODE_TYPES): The operation mode - 'create', 'update', or 'delete'.
182
+ **kwargs: Additional keyword arguments passed to the bulk operation methods.
183
+
184
+ Returns:
185
+ None | int | tuple[int, dict[str, int]] | list[Model]:
186
+ The result depends on mode:
187
+ - create: list of created model instances
188
+ - update: integer count of updated objects
189
+ - delete: tuple of (total_count, count_by_model_dict)
190
+ - None if bulk is empty
191
+ """
192
+ # check if we are inside a transaction.atomic block
193
+ _in_atomic_block = transaction.get_connection().in_atomic_block
194
+ if _in_atomic_block:
195
+ logger.info(
196
+ "BE CAREFUL USING BULK OPERATIONS INSIDE A BROADER TRANSACTION BLOCK. "
197
+ "BULKING WITH BULKS THAT DEPEND ON EACH OTHER CAN CAUSE "
198
+ "INTEGRITY ERRORS OR POTENTIAL OTHER ISSUES."
199
+ )
200
+ return bulk_method_in_steps_atomic(
201
+ model=model, bulk=bulk, step=step, mode=mode, **kwargs
202
+ )
203
+
204
+
205
+ # Overloads for bulk_method_in_steps_atomic
206
+ @overload
207
+ @transaction.atomic
208
+ def bulk_method_in_steps_atomic[TModel: Model](
209
+ model: type[TModel],
210
+ bulk: Iterable[TModel],
211
+ step: int,
212
+ mode: Literal["create"],
213
+ **kwargs: Any,
214
+ ) -> list[TModel]: ...
215
+
216
+
217
+ @overload
218
+ @transaction.atomic
219
+ def bulk_method_in_steps_atomic[TModel: Model](
220
+ model: type[TModel],
221
+ bulk: Iterable[TModel],
222
+ step: int,
223
+ mode: Literal["update"],
224
+ **kwargs: Any,
225
+ ) -> int: ...
226
+
227
+
228
+ @overload
229
+ @transaction.atomic
230
+ def bulk_method_in_steps_atomic[TModel: Model](
231
+ model: type[TModel],
232
+ bulk: Iterable[TModel],
233
+ step: int,
234
+ mode: Literal["delete"],
235
+ **kwargs: Any,
236
+ ) -> tuple[int, dict[str, int]]: ...
237
+
238
+
239
+ @transaction.atomic
240
+ def bulk_method_in_steps_atomic[TModel: Model](
241
+ model: type[TModel],
242
+ bulk: Iterable[TModel],
243
+ step: int,
244
+ mode: MODE_TYPES,
245
+ **kwargs: Any,
246
+ ) -> int | tuple[int, dict[str, int]] | list[TModel]:
247
+ """Bulk create, update or delete the given list of objects in steps.
248
+
249
+ WHEN BULK CREATING OR UPDATING A BULK
250
+ AND THEN A SECOND BULK THAT DEPENDS ON THE FIRST BULK,
251
+ YOU WILL RUN INTO A INTEGRITY ERROR IF YOU DO THE
252
+ ENTIRE THING IN AN @transaction.atomic DECORATOR.
253
+ REMOVE THE DECORATORS THAT ARE HIGHER UP THAN THE ONE OF THIS FUNCTION
254
+ TO AVOID THIS ERROR.
255
+
256
+ Args:
257
+ model (type[Model]): The Django model class to perform operations on.
258
+ bulk (Iterable[Model]): A list of model instances to process.
259
+ step (int): number of objects to process in one chunk
260
+ mode (MODE_TYPES): The operation mode - 'create', 'update', or 'delete'.
261
+ **kwargs: Additional keyword arguments passed to the bulk operation methods.
262
+
263
+ Returns:
264
+ None | int | tuple[int, dict[str, int]] | list[Model]:
265
+ The result depends on mode:
266
+ - create: list of created model instances
267
+ - update: integer count of updated objects
268
+ - delete: tuple of (total_count, count_by_model_dict)
269
+ - None if bulk is empty
270
+ """
271
+ bulk_method = get_bulk_method(model=model, mode=mode, **kwargs)
272
+
273
+ chunks = get_step_chunks(bulk=bulk, step=step)
274
+
275
+ # multithreading significantly increases speed
276
+ result = multithread_loop(
277
+ process_function=bulk_method,
278
+ process_args=chunks,
279
+ )
280
+
281
+ return flatten_bulk_in_steps_result(result=result, mode=mode)
282
+
283
+
284
+ def get_step_chunks(
285
+ bulk: Iterable[Model], step: int
286
+ ) -> Generator[tuple[list[Model]], None, None]:
287
+ """Yield chunks of the given size from the bulk.
288
+
289
+ Args:
290
+ bulk (Iterable[Model]): The bulk to chunk.
291
+ step (int): The size of each chunk.
292
+
293
+ Yields:
294
+ Generator[list[Model], None, None]: Chunks of the bulk.
295
+ """
296
+ bulk = iter(bulk)
297
+ while True:
298
+ chunk = list(islice(bulk, step))
299
+ if not chunk:
300
+ break
301
+ yield (chunk,) # bc concurrent_loop expects a tuple of args
302
+
303
+
304
+ # Overloads for get_bulk_method
305
+ @overload
306
+ def get_bulk_method(
307
+ model: type[Model], mode: Literal["create"], **kwargs: Any
308
+ ) -> Callable[[list[Model]], list[Model]]: ...
309
+
310
+
311
+ @overload
312
+ def get_bulk_method(
313
+ model: type[Model], mode: Literal["update"], **kwargs: Any
314
+ ) -> Callable[[list[Model]], int]: ...
315
+
316
+
317
+ @overload
318
+ def get_bulk_method(
319
+ model: type[Model], mode: Literal["delete"], **kwargs: Any
320
+ ) -> Callable[[list[Model]], tuple[int, dict[str, int]]]: ...
321
+
322
+
323
+ def get_bulk_method(
324
+ model: type[Model], mode: MODE_TYPES, **kwargs: Any
325
+ ) -> Callable[[list[Model]], list[Model] | int | tuple[int, dict[str, int]]]:
326
+ """Get the appropriate bulk method function based on the operation mode.
327
+
328
+ Creates and returns a function that performs the specified bulk operation
329
+ (create, update, or delete) on a chunk of model instances. The returned
330
+ function is configured with the provided kwargs.
331
+
332
+ Args:
333
+ model (type[Model]): The Django model class to perform operations on.
334
+ mode (MODE_TYPES): The operation mode - 'create', 'update', or 'delete'.
335
+ **kwargs: Additional keyword arguments to pass to the bulk operation method.
336
+
337
+ Raises:
338
+ ValueError: If the mode is not one of the valid MODE_TYPES.
339
+
340
+ Returns:
341
+ Callable[[list[Model]], Any]: A function that performs the bulk operation
342
+ on a chunk of model instances.
343
+ """
344
+ bulk_method: Callable[[list[Model]], list[Model] | int | tuple[int, dict[str, int]]]
345
+ if mode == MODE_CREATE:
346
+
347
+ def bulk_create_chunk(chunk: list[Model]) -> list[Model]:
348
+ return model.objects.bulk_create(objs=chunk, **kwargs)
349
+
350
+ bulk_method = bulk_create_chunk
351
+ elif mode == MODE_UPDATE:
352
+
353
+ def bulk_update_chunk(chunk: list[Model]) -> int:
354
+ return model.objects.bulk_update(objs=chunk, **kwargs)
355
+
356
+ bulk_method = bulk_update_chunk
357
+ elif mode == MODE_DELETE:
358
+
359
+ def bulk_delete_chunk(chunk: list[Model]) -> tuple[int, dict[str, int]]:
360
+ return bulk_delete(model=model, objs=chunk, **kwargs)
361
+
362
+ bulk_method = bulk_delete_chunk
363
+ else:
364
+ msg = f"Invalid method. Must be one of {MODES}"
365
+ raise ValueError(msg)
366
+
367
+ return bulk_method
368
+
369
+
370
+ # Overloads for flatten_bulk_in_steps_result
371
+ @overload
372
+ def flatten_bulk_in_steps_result[TModel: Model](
373
+ result: list[list[TModel]], mode: Literal["create"]
374
+ ) -> list[TModel]: ...
375
+
376
+
377
+ @overload
378
+ def flatten_bulk_in_steps_result[TModel: Model](
379
+ result: list[int], mode: Literal["update"]
380
+ ) -> int: ...
381
+
382
+
383
+ @overload
384
+ def flatten_bulk_in_steps_result[TModel: Model](
385
+ result: list[tuple[int, dict[str, int]]], mode: Literal["delete"]
386
+ ) -> tuple[int, dict[str, int]]: ...
387
+
388
+
389
+ def flatten_bulk_in_steps_result[TModel: Model](
390
+ result: list[int] | list[tuple[int, dict[str, int]]] | list[list[TModel]], mode: str
391
+ ) -> int | tuple[int, dict[str, int]] | list[TModel]:
392
+ """Flatten and aggregate results from multithreaded bulk operations.
393
+
394
+ Processes the results returned from parallel bulk operations and aggregates
395
+ them into the appropriate format based on the operation mode. Handles
396
+ different return types for create, update, and delete operations.
397
+
398
+ Args:
399
+ result (list[Any]): List of results from each chunk operation.
400
+ mode (str): The operation mode - 'create', 'update', or 'delete'.
401
+
402
+ Raises:
403
+ ValueError: If the mode is not one of the valid operation modes.
404
+
405
+ Returns:
406
+ None | int | tuple[int, dict[str, int]] | list[Model]: Aggregated result:
407
+ - update: sum of updated object counts
408
+ - delete: tuple of (total_count, count_by_model_dict)
409
+ - create: flattened list of all created objects
410
+ """
411
+ if mode == MODE_UPDATE:
412
+ # formated as [1000, 1000, ...]
413
+ # since django 4.2 bulk_update returns the count of updated objects
414
+ result = cast("list[int]", result)
415
+ return int(sum(result))
416
+ if mode == MODE_DELETE:
417
+ # formated as [(count, {model_name: count, model_cascade_name: count}), ...]
418
+ # join the results to get the total count of deleted objects
419
+ result = cast("list[tuple[int, dict[str, int]]]", result)
420
+ total_count = 0
421
+ count_sum_by_model: defaultdict[str, int] = defaultdict(int)
422
+ for count_sum, count_by_model in result:
423
+ total_count += count_sum
424
+ for model_name, count in count_by_model.items():
425
+ count_sum_by_model[model_name] += count
426
+ return (total_count, dict(count_sum_by_model))
427
+ if mode == MODE_CREATE:
428
+ # formated as [[obj1, obj2, ...], [obj1, obj2, ...], ...]
429
+ result = cast("list[list[TModel]]", result)
430
+ return [item for sublist in result for item in sublist]
431
+
432
+ msg = f"Invalid method. Must be one of {MODES}"
433
+ raise ValueError(msg)
434
+
435
+
436
+ def bulk_delete(
437
+ model: type[Model], objs: Iterable[Model], **_: Any
438
+ ) -> tuple[int, dict[str, int]]:
439
+ """Delete model instances using Django's QuerySet delete method.
440
+
441
+ Deletes the provided model instances from the database using Django's
442
+ built-in delete functionality. Handles both individual model instances
443
+ and QuerySets, and returns deletion statistics including cascade counts.
444
+
445
+ Args:
446
+ model (type[Model]): The Django model class to delete from.
447
+ objs (list[Model]): A list of model instances to delete.
448
+
449
+ Returns:
450
+ tuple[int, dict[str, int]]: A tuple containing the total count of deleted
451
+ objects and a dictionary mapping model names to their deletion counts.
452
+ """
453
+ if not isinstance(objs, QuerySet):
454
+ objs = list(objs)
455
+ pks = [obj.pk for obj in objs]
456
+ query_set = model.objects.filter(pk__in=pks)
457
+ else:
458
+ query_set = objs
459
+
460
+ return query_set.delete()
461
+
462
+
463
+ def bulk_create_bulks_in_steps[TModel: Model](
464
+ bulk_by_class: dict[type[TModel], Iterable[TModel]],
465
+ step: int = STANDARD_BULK_SIZE,
466
+ ) -> dict[type[TModel], list[TModel]]:
467
+ """Create multiple bulks of different model types in dependency order.
468
+
469
+ Takes a dictionary mapping model classes to lists of instances and creates
470
+ them in the database in the correct order based on model dependencies.
471
+ Uses topological sorting to ensure foreign key constraints are satisfied.
472
+
473
+ Args:
474
+ bulk_by_class (dict[type[Model], list[Model]]): Dictionary mapping model classes
475
+ to lists of instances to create.
476
+ step (int, optional): The step size for bulk creation. Defaults to 1000.
477
+ validate (bool, optional): Whether to validate instances before creation.
478
+ Defaults to True.
479
+
480
+ Returns:
481
+ dict[type[Model], list[Model]]: Dictionary mapping model classes to lists
482
+ of created instances.
483
+ """
484
+ # order the bulks in order of creation depending how they depend on each other
485
+ models_ = list(bulk_by_class.keys())
486
+ ordered_models = topological_sort_models(models=models_)
487
+
488
+ results: dict[type[TModel], list[TModel]] = {}
489
+ for model_ in ordered_models:
490
+ bulk = bulk_by_class[model_]
491
+ result = bulk_create_in_steps(model=model_, bulk=bulk, step=step)
492
+ results[model_] = result
493
+
494
+ return results
495
+
496
+
497
+ def get_differences_between_bulks(
498
+ bulk1: list[Model],
499
+ bulk2: list[Model],
500
+ fields: "list[Field[Any, Any] | ForeignObjectRel | GenericForeignKey]",
501
+ ) -> tuple[list[Model], list[Model], list[Model], list[Model]]:
502
+ """Compare two bulks and return their differences and intersections.
503
+
504
+ Compares two lists of model instances by computing hashes of their field values
505
+ and returns the differences and intersections between them. Optionally allows
506
+ specifying which fields to compare and the depth of comparison for related objects.
507
+
508
+ Args:
509
+ bulk1 (list[Model]): First list of model instances to compare.
510
+ bulk2 (list[Model]): Second list of model instances to compare.
511
+ fields (list[Field] | None, optional): List of fields to compare.
512
+ Defaults to None, which compares all fields.
513
+ max_depth (int | None, optional): Maximum depth for comparing related objects.
514
+ Defaults to None.
515
+
516
+ Raises:
517
+ ValueError: If the two bulks contain different model types.
518
+
519
+ Returns:
520
+ tuple[list[Model], list[Model], list[Model], list[Model]]: A tuple containing:
521
+ - Objects in bulk1 but not in bulk2
522
+ - Objects in bulk2 but not in bulk1
523
+ - Objects in both bulk1 and bulk2 (from bulk1)
524
+ - Objects in both bulk1 and bulk2 (from bulk2)
525
+ """
526
+ if not bulk1 or not bulk2:
527
+ return bulk1, bulk2, [], []
528
+
529
+ if type(bulk1[0]) is not type(bulk2[0]):
530
+ msg = "Both bulks must be of the same model type."
531
+ raise ValueError(msg)
532
+
533
+ hash_model_instance_with_fields = partial(
534
+ hash_model_instance,
535
+ fields=fields,
536
+ )
537
+ # Precompute hashes and map them directly to models in a single pass for both bulks
538
+ hashes1 = list(map(hash_model_instance_with_fields, bulk1))
539
+ hashes2 = list(map(hash_model_instance_with_fields, bulk2))
540
+
541
+ # Convert keys to sets for difference operations
542
+ set1, set2 = set(hashes1), set(hashes2)
543
+
544
+ # Calculate differences between sets
545
+ # Find differences and intersection with original order preserved
546
+ # Important, we need to return the original objects that are the same in memory,
547
+ # so in_1_not_2 and in_2_not_1
548
+ in_1_not_2 = set1 - set2
549
+ in_1_not_2_list = [
550
+ model
551
+ for model, hash_ in zip(bulk1, hashes1, strict=False)
552
+ if hash_ in in_1_not_2
553
+ ]
554
+
555
+ in_2_not_1 = set2 - set1
556
+ in_2_not_1_list = [
557
+ model
558
+ for model, hash_ in zip(bulk2, hashes2, strict=False)
559
+ if hash_ in in_2_not_1
560
+ ]
561
+
562
+ in_1_and_2 = set1 & set2
563
+ in_1_and_2_from_1 = [
564
+ model
565
+ for model, hash_ in zip(bulk1, hashes1, strict=False)
566
+ if hash_ in in_1_and_2
567
+ ]
568
+ in_1_and_2_from_2 = [
569
+ model
570
+ for model, hash_ in zip(bulk2, hashes2, strict=False)
571
+ if hash_ in in_1_and_2
572
+ ]
573
+
574
+ return in_1_not_2_list, in_2_not_1_list, in_1_and_2_from_1, in_1_and_2_from_2
575
+
576
+
577
+ def simulate_bulk_deletion(
578
+ model_class: type[Model], entries: list[Model]
579
+ ) -> dict[type[Model], set[Model]]:
580
+ """Simulate bulk deletion to preview what objects would be deleted.
581
+
582
+ Uses Django's Collector to simulate the deletion process and determine
583
+ which objects would be deleted due to cascade relationships, without
584
+ actually performing the deletion. Useful for previewing deletion effects.
585
+
586
+ Args:
587
+ model_class (type[Model]): The Django model class of the entries to delete.
588
+ entries (list[Model]): List of model instances to simulate deletion for.
589
+
590
+ Returns:
591
+ dict[type[Model], set[Model]]: Dictionary mapping model classes to sets
592
+ of objects that would be deleted, including cascade deletions.
593
+ """
594
+ if not entries:
595
+ return {}
596
+
597
+ # Initialize the Collector
598
+ using = router.db_for_write(model_class)
599
+ collector = Collector(using)
600
+
601
+ # Collect deletion cascade for all entries
602
+ collector.collect(entries)
603
+
604
+ # Prepare the result dictionary
605
+ deletion_summary: defaultdict[type[Model], set[Model]] = defaultdict(set)
606
+
607
+ # Add normal deletes
608
+ for model, objects in collector.data.items():
609
+ deletion_summary[model].update(objects) # objects is already iterable
610
+
611
+ # Add fast deletes (explicitly expand querysets)
612
+ for queryset in collector.fast_deletes:
613
+ deletion_summary[queryset.model].update(list(queryset))
614
+
615
+ return deletion_summary
616
+
617
+
618
+ def multi_simulate_bulk_deletion(
619
+ entries: dict[type[Model], list[Model]],
620
+ ) -> dict[type[Model], set[Model]]:
621
+ """Simulate bulk deletion for multiple model types and aggregate results.
622
+
623
+ Performs deletion simulation for multiple model types and combines the results
624
+ into a single summary. This is useful when you want to preview the deletion
625
+ effects across multiple related model types.
626
+
627
+ Args:
628
+ entries (dict[type[Model], list[Model]]): Dictionary mapping model classes
629
+ to lists of instances to simulate deletion for.
630
+
631
+ Returns:
632
+ dict[type[Model], set[Model]]: Dictionary mapping model classes to sets
633
+ of all objects that would be deleted across all simulations.
634
+ """
635
+ deletion_summaries = [
636
+ simulate_bulk_deletion(model, entry) for model, entry in entries.items()
637
+ ]
638
+ # join the dicts to get the total count of deleted objects
639
+ joined_deletion_summary = defaultdict(set)
640
+ for deletion_summary in deletion_summaries:
641
+ for model, objects in deletion_summary.items():
642
+ joined_deletion_summary[model].update(objects)
643
+
644
+ return dict(joined_deletion_summary)