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

1from collections import Counter, defaultdict 

2from functools import partial, reduce 

3from itertools import chain 

4from operator import attrgetter, or_ 

5 

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 

14 

15 

16class ProtectedError(IntegrityError): 

17 def __init__(self, msg, protected_objects): 

18 self.protected_objects = protected_objects 

19 super().__init__(msg, protected_objects) 

20 

21 

22class RestrictedError(IntegrityError): 

23 def __init__(self, msg, restricted_objects): 

24 self.restricted_objects = restricted_objects 

25 super().__init__(msg, restricted_objects) 

26 

27 

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) 

38 

39 

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 ) 

46 

47 

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) 

51 

52 

53def SET(value): 

54 if callable(value): 

55 

56 def set_on_delete(collector, field, sub_objs, using): 

57 collector.add_field_update(field, value(), sub_objs) 

58 

59 else: 

60 

61 def set_on_delete(collector, field, sub_objs, using): 

62 collector.add_field_update(field, value, sub_objs) 

63 

64 set_on_delete.deconstruct = lambda: ("plain.models.SET", (value,), {}) 

65 set_on_delete.lazy_sub_objs = True 

66 return set_on_delete 

67 

68 

69def SET_NULL(collector, field, sub_objs, using): 

70 collector.add_field_update(field, None, sub_objs) 

71 

72 

73SET_NULL.lazy_sub_objs = True 

74 

75 

76def SET_DEFAULT(collector, field, sub_objs, using): 

77 collector.add_field_update(field, field.get_default(), sub_objs) 

78 

79 

80SET_DEFAULT.lazy_sub_objs = True 

81 

82 

83def DO_NOTHING(collector, field, sub_objs, using): 

84 pass 

85 

86 

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 ) 

95 

96 

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 = [] 

111 

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}} 

117 

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. 

123 

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 

141 

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()) 

149 

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) 

156 

157 def add_restricted_objects(self, field, objs): 

158 if objs: 

159 model = objs[0].__class__ 

160 self.restricted_objects[model][field].update(objs) 

161 

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 } 

168 

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) 

181 

182 def _has_signal_listeners(self, model): 

183 return signals.pre_delete.has_listeners( 

184 model 

185 ) or signals.post_delete.has_listeners(model) 

186 

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. 

192 

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 ) 

230 

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] 

246 

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. 

263 

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. 

267 

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.) 

272 

273 If 'keep_parents' is True, data of parent model's will be not deleted. 

274 

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 

289 

290 model = new_objs[0].__class__ 

291 

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 

309 

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 ) 

375 

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 ) 

400 

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) 

410 

411 def instances_with_model(self): 

412 for model, instances in self.data.items(): 

413 for obj in instances: 

414 yield model, obj 

415 

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} 

433 

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")) 

438 

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() 

445 

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} 

456 

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 ) 

467 

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 

473 

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 ) 

495 

496 # reverse instance collections 

497 for instances in self.data.values(): 

498 instances.reverse() 

499 

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 

507 

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 ) 

516 

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)