Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain-models/plain/models/deletion.py: 15%
244 statements
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-23 11:16 -0600
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-23 11:16 -0600
1from collections import Counter, defaultdict
2from functools import partial, reduce
3from itertools import chain
4from operator import attrgetter, or_
6from plain.models import (
7 query_utils,
8 signals,
9 sql,
10 transaction,
11)
12from plain.models.db import IntegrityError, connections
13from plain.models.query import QuerySet
16class ProtectedError(IntegrityError):
17 def __init__(self, msg, protected_objects):
18 self.protected_objects = protected_objects
19 super().__init__(msg, protected_objects)
22class RestrictedError(IntegrityError):
23 def __init__(self, msg, restricted_objects):
24 self.restricted_objects = restricted_objects
25 super().__init__(msg, restricted_objects)
28def CASCADE(collector, field, sub_objs, using):
29 collector.collect(
30 sub_objs,
31 source=field.remote_field.model,
32 source_attr=field.name,
33 nullable=field.null,
34 fail_on_restricted=False,
35 )
36 if field.null and not connections[using].features.can_defer_constraint_checks:
37 collector.add_field_update(field, None, sub_objs)
40def PROTECT(collector, field, sub_objs, using):
41 raise ProtectedError(
42 f"Cannot delete some instances of model '{field.remote_field.model.__name__}' because they are "
43 f"referenced through a protected foreign key: '{sub_objs[0].__class__.__name__}.{field.name}'",
44 sub_objs,
45 )
48def RESTRICT(collector, field, sub_objs, using):
49 collector.add_restricted_objects(field, sub_objs)
50 collector.add_dependency(field.remote_field.model, field.model)
53def SET(value):
54 if callable(value):
56 def set_on_delete(collector, field, sub_objs, using):
57 collector.add_field_update(field, value(), sub_objs)
59 else:
61 def set_on_delete(collector, field, sub_objs, using):
62 collector.add_field_update(field, value, sub_objs)
64 set_on_delete.deconstruct = lambda: ("plain.models.SET", (value,), {})
65 set_on_delete.lazy_sub_objs = True
66 return set_on_delete
69def SET_NULL(collector, field, sub_objs, using):
70 collector.add_field_update(field, None, sub_objs)
73SET_NULL.lazy_sub_objs = True
76def SET_DEFAULT(collector, field, sub_objs, using):
77 collector.add_field_update(field, field.get_default(), sub_objs)
80SET_DEFAULT.lazy_sub_objs = True
83def DO_NOTHING(collector, field, sub_objs, using):
84 pass
87def get_candidate_relations_to_delete(opts):
88 # The candidate relations are the ones that come from N-1 and 1-1 relations.
89 # N-N (i.e., many-to-many) relations aren't candidates for deletion.
90 return (
91 f
92 for f in opts.get_fields(include_hidden=True)
93 if f.auto_created and not f.concrete and (f.one_to_one or f.one_to_many)
94 )
97class Collector:
98 def __init__(self, using, origin=None):
99 self.using = using
100 # A Model or QuerySet object.
101 self.origin = origin
102 # Initially, {model: {instances}}, later values become lists.
103 self.data = defaultdict(set)
104 # {(field, value): [instances, …]}
105 self.field_updates = defaultdict(list)
106 # {model: {field: {instances}}}
107 self.restricted_objects = defaultdict(partial(defaultdict, set))
108 # fast_deletes is a list of queryset-likes that can be deleted without
109 # fetching the objects into memory.
110 self.fast_deletes = []
112 # Tracks deletion-order dependency for databases without transactions
113 # or ability to defer constraint checks. Only concrete model classes
114 # should be included, as the dependencies exist only between actual
115 # database tables.
116 self.dependencies = defaultdict(set) # {model: {models}}
118 def add(self, objs, source=None, nullable=False, reverse_dependency=False):
119 """
120 Add 'objs' to the collection of objects to be deleted. If the call is
121 the result of a cascade, 'source' should be the model that caused it,
122 and 'nullable' should be set to True if the relation can be null.
124 Return a list of all objects that were not already collected.
125 """
126 if not objs:
127 return []
128 new_objs = []
129 model = objs[0].__class__
130 instances = self.data[model]
131 for obj in objs:
132 if obj not in instances:
133 new_objs.append(obj)
134 instances.update(new_objs)
135 # Nullable relationships can be ignored -- they are nulled out before
136 # deleting, and therefore do not affect the order in which objects have
137 # to be deleted.
138 if source is not None and not nullable:
139 self.add_dependency(source, model, reverse_dependency=reverse_dependency)
140 return new_objs
142 def add_dependency(self, model, dependency, reverse_dependency=False):
143 if reverse_dependency:
144 model, dependency = dependency, model
145 self.dependencies[model._meta.concrete_model].add(
146 dependency._meta.concrete_model
147 )
148 self.data.setdefault(dependency, self.data.default_factory())
150 def add_field_update(self, field, value, objs):
151 """
152 Schedule a field update. 'objs' must be a homogeneous iterable
153 collection of model instances (e.g. a QuerySet).
154 """
155 self.field_updates[field, value].append(objs)
157 def add_restricted_objects(self, field, objs):
158 if objs:
159 model = objs[0].__class__
160 self.restricted_objects[model][field].update(objs)
162 def clear_restricted_objects_from_set(self, model, objs):
163 if model in self.restricted_objects:
164 self.restricted_objects[model] = {
165 field: items - objs
166 for field, items in self.restricted_objects[model].items()
167 }
169 def clear_restricted_objects_from_queryset(self, model, qs):
170 if model in self.restricted_objects:
171 objs = set(
172 qs.filter(
173 pk__in=[
174 obj.pk
175 for objs in self.restricted_objects[model].values()
176 for obj in objs
177 ]
178 )
179 )
180 self.clear_restricted_objects_from_set(model, objs)
182 def _has_signal_listeners(self, model):
183 return signals.pre_delete.has_listeners(
184 model
185 ) or signals.post_delete.has_listeners(model)
187 def can_fast_delete(self, objs, from_field=None):
188 """
189 Determine if the objects in the given queryset-like or single object
190 can be fast-deleted. This can be done if there are no cascades, no
191 parents and no signal listeners for the object class.
193 The 'from_field' tells where we are coming from - we need this to
194 determine if the objects are in fact to be deleted. Allow also
195 skipping parent -> child -> parent chain preventing fast delete of
196 the child.
197 """
198 if from_field and from_field.remote_field.on_delete is not CASCADE:
199 return False
200 if hasattr(objs, "_meta"):
201 model = objs._meta.model
202 elif hasattr(objs, "model") and hasattr(objs, "_raw_delete"):
203 model = objs.model
204 else:
205 return False
206 if self._has_signal_listeners(model):
207 return False
208 # The use of from_field comes from the need to avoid cascade back to
209 # parent when parent delete is cascading to child.
210 opts = model._meta
211 return (
212 all(
213 link == from_field
214 for link in opts.concrete_model._meta.parents.values()
215 )
216 and
217 # Foreign keys pointing to this model.
218 all(
219 related.field.remote_field.on_delete is DO_NOTHING
220 for related in get_candidate_relations_to_delete(opts)
221 )
222 and (
223 # Something like generic foreign key.
224 not any(
225 hasattr(field, "bulk_related_objects")
226 for field in opts.private_fields
227 )
228 )
229 )
231 def get_del_batches(self, objs, fields):
232 """
233 Return the objs in suitably sized batches for the used connection.
234 """
235 field_names = [field.name for field in fields]
236 conn_batch_size = max(
237 connections[self.using].ops.bulk_batch_size(field_names, objs), 1
238 )
239 if len(objs) > conn_batch_size:
240 return [
241 objs[i : i + conn_batch_size]
242 for i in range(0, len(objs), conn_batch_size)
243 ]
244 else:
245 return [objs]
247 def collect(
248 self,
249 objs,
250 source=None,
251 nullable=False,
252 collect_related=True,
253 source_attr=None,
254 reverse_dependency=False,
255 keep_parents=False,
256 fail_on_restricted=True,
257 ):
258 """
259 Add 'objs' to the collection of objects to be deleted as well as all
260 parent instances. 'objs' must be a homogeneous iterable collection of
261 model instances (e.g. a QuerySet). If 'collect_related' is True,
262 related objects will be handled by their respective on_delete handler.
264 If the call is the result of a cascade, 'source' should be the model
265 that caused it and 'nullable' should be set to True, if the relation
266 can be null.
268 If 'reverse_dependency' is True, 'source' will be deleted before the
269 current model, rather than after. (Needed for cascading to parent
270 models, the one case in which the cascade follows the forwards
271 direction of an FK rather than the reverse direction.)
273 If 'keep_parents' is True, data of parent model's will be not deleted.
275 If 'fail_on_restricted' is False, error won't be raised even if it's
276 prohibited to delete such objects due to RESTRICT, that defers
277 restricted object checking in recursive calls where the top-level call
278 may need to collect more objects to determine whether restricted ones
279 can be deleted.
280 """
281 if self.can_fast_delete(objs):
282 self.fast_deletes.append(objs)
283 return
284 new_objs = self.add(
285 objs, source, nullable, reverse_dependency=reverse_dependency
286 )
287 if not new_objs:
288 return
290 model = new_objs[0].__class__
292 if not keep_parents:
293 # Recursively collect concrete model's parent models, but not their
294 # related objects. These will be found by meta.get_fields()
295 concrete_model = model._meta.concrete_model
296 for ptr in concrete_model._meta.parents.values():
297 if ptr:
298 parent_objs = [getattr(obj, ptr.name) for obj in new_objs]
299 self.collect(
300 parent_objs,
301 source=model,
302 source_attr=ptr.remote_field.related_name,
303 collect_related=False,
304 reverse_dependency=True,
305 fail_on_restricted=False,
306 )
307 if not collect_related:
308 return
310 if keep_parents:
311 parents = set(model._meta.get_parent_list())
312 model_fast_deletes = defaultdict(list)
313 protected_objects = defaultdict(list)
314 for related in get_candidate_relations_to_delete(model._meta):
315 # Preserve parent reverse relationships if keep_parents=True.
316 if keep_parents and related.model in parents:
317 continue
318 field = related.field
319 on_delete = field.remote_field.on_delete
320 if on_delete == DO_NOTHING:
321 continue
322 related_model = related.related_model
323 if self.can_fast_delete(related_model, from_field=field):
324 model_fast_deletes[related_model].append(field)
325 continue
326 batches = self.get_del_batches(new_objs, [field])
327 for batch in batches:
328 sub_objs = self.related_objects(related_model, [field], batch)
329 # Non-referenced fields can be deferred if no signal receivers
330 # are connected for the related model as they'll never be
331 # exposed to the user. Skip field deferring when some
332 # relationships are select_related as interactions between both
333 # features are hard to get right. This should only happen in
334 # the rare cases where .related_objects is overridden anyway.
335 if not (
336 sub_objs.query.select_related
337 or self._has_signal_listeners(related_model)
338 ):
339 referenced_fields = set(
340 chain.from_iterable(
341 (rf.attname for rf in rel.field.foreign_related_fields)
342 for rel in get_candidate_relations_to_delete(
343 related_model._meta
344 )
345 )
346 )
347 sub_objs = sub_objs.only(*tuple(referenced_fields))
348 if getattr(on_delete, "lazy_sub_objs", False) or sub_objs:
349 try:
350 on_delete(self, field, sub_objs, self.using)
351 except ProtectedError as error:
352 key = f"'{field.model.__name__}.{field.name}'"
353 protected_objects[key] += error.protected_objects
354 if protected_objects:
355 raise ProtectedError(
356 "Cannot delete some instances of model {!r} because they are "
357 "referenced through protected foreign keys: {}.".format(
358 model.__name__,
359 ", ".join(protected_objects),
360 ),
361 set(chain.from_iterable(protected_objects.values())),
362 )
363 for related_model, related_fields in model_fast_deletes.items():
364 batches = self.get_del_batches(new_objs, related_fields)
365 for batch in batches:
366 sub_objs = self.related_objects(related_model, related_fields, batch)
367 self.fast_deletes.append(sub_objs)
368 for field in model._meta.private_fields:
369 if hasattr(field, "bulk_related_objects"):
370 # It's something like generic foreign key.
371 sub_objs = field.bulk_related_objects(new_objs, self.using)
372 self.collect(
373 sub_objs, source=model, nullable=True, fail_on_restricted=False
374 )
376 if fail_on_restricted:
377 # Raise an error if collected restricted objects (RESTRICT) aren't
378 # candidates for deletion also collected via CASCADE.
379 for related_model, instances in self.data.items():
380 self.clear_restricted_objects_from_set(related_model, instances)
381 for qs in self.fast_deletes:
382 self.clear_restricted_objects_from_queryset(qs.model, qs)
383 if self.restricted_objects.values():
384 restricted_objects = defaultdict(list)
385 for related_model, fields in self.restricted_objects.items():
386 for field, objs in fields.items():
387 if objs:
388 key = f"'{related_model.__name__}.{field.name}'"
389 restricted_objects[key] += objs
390 if restricted_objects:
391 raise RestrictedError(
392 "Cannot delete some instances of model {!r} because "
393 "they are referenced through restricted foreign keys: "
394 "{}.".format(
395 model.__name__,
396 ", ".join(restricted_objects),
397 ),
398 set(chain.from_iterable(restricted_objects.values())),
399 )
401 def related_objects(self, related_model, related_fields, objs):
402 """
403 Get a QuerySet of the related model to objs via related fields.
404 """
405 predicate = query_utils.Q.create(
406 [(f"{related_field.name}__in", objs) for related_field in related_fields],
407 connector=query_utils.Q.OR,
408 )
409 return related_model._base_manager.using(self.using).filter(predicate)
411 def instances_with_model(self):
412 for model, instances in self.data.items():
413 for obj in instances:
414 yield model, obj
416 def sort(self):
417 sorted_models = []
418 concrete_models = set()
419 models = list(self.data)
420 while len(sorted_models) < len(models):
421 found = False
422 for model in models:
423 if model in sorted_models:
424 continue
425 dependencies = self.dependencies.get(model._meta.concrete_model)
426 if not (dependencies and dependencies.difference(concrete_models)):
427 sorted_models.append(model)
428 concrete_models.add(model._meta.concrete_model)
429 found = True
430 if not found:
431 return
432 self.data = {model: self.data[model] for model in sorted_models}
434 def delete(self):
435 # sort instance collections
436 for model, instances in self.data.items():
437 self.data[model] = sorted(instances, key=attrgetter("pk"))
439 # if possible, bring the models in an order suitable for databases that
440 # don't support transactions or cannot defer constraint checks until the
441 # end of a transaction.
442 self.sort()
443 # number of objects deleted for each model label
444 deleted_counter = Counter()
446 # Optimize for the case with a single obj and no dependencies
447 if len(self.data) == 1 and len(instances) == 1:
448 instance = list(instances)[0]
449 if self.can_fast_delete(instance):
450 with transaction.mark_for_rollback_on_error(self.using):
451 count = sql.DeleteQuery(model).delete_batch(
452 [instance.pk], self.using
453 )
454 setattr(instance, model._meta.pk.attname, None)
455 return count, {model._meta.label: count}
457 with transaction.atomic(using=self.using, savepoint=False):
458 # send pre_delete signals
459 for model, obj in self.instances_with_model():
460 if not model._meta.auto_created:
461 signals.pre_delete.send(
462 sender=model,
463 instance=obj,
464 using=self.using,
465 origin=self.origin,
466 )
468 # fast deletes
469 for qs in self.fast_deletes:
470 count = qs._raw_delete(using=self.using)
471 if count:
472 deleted_counter[qs.model._meta.label] += count
474 # update fields
475 for (field, value), instances_list in self.field_updates.items():
476 updates = []
477 objs = []
478 for instances in instances_list:
479 if (
480 isinstance(instances, QuerySet)
481 and instances._result_cache is None
482 ):
483 updates.append(instances)
484 else:
485 objs.extend(instances)
486 if updates:
487 combined_updates = reduce(or_, updates)
488 combined_updates.update(**{field.name: value})
489 if objs:
490 model = objs[0].__class__
491 query = sql.UpdateQuery(model)
492 query.update_batch(
493 list({obj.pk for obj in objs}), {field.name: value}, self.using
494 )
496 # reverse instance collections
497 for instances in self.data.values():
498 instances.reverse()
500 # delete instances
501 for model, instances in self.data.items():
502 query = sql.DeleteQuery(model)
503 pk_list = [obj.pk for obj in instances]
504 count = query.delete_batch(pk_list, self.using)
505 if count:
506 deleted_counter[model._meta.label] += count
508 if not model._meta.auto_created:
509 for obj in instances:
510 signals.post_delete.send(
511 sender=model,
512 instance=obj,
513 using=self.using,
514 origin=self.origin,
515 )
517 for model, instances in self.data.items():
518 for instance in instances:
519 setattr(instance, model._meta.pk.attname, None)
520 return sum(deleted_counter.values()), dict(deleted_counter)