plain.postgres 0.84.0__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.
Files changed (93) hide show
  1. plain/postgres/CHANGELOG.md +1028 -0
  2. plain/postgres/README.md +925 -0
  3. plain/postgres/__init__.py +120 -0
  4. plain/postgres/agents/.claude/rules/plain-postgres.md +78 -0
  5. plain/postgres/aggregates.py +236 -0
  6. plain/postgres/backups/__init__.py +0 -0
  7. plain/postgres/backups/cli.py +148 -0
  8. plain/postgres/backups/clients.py +94 -0
  9. plain/postgres/backups/core.py +172 -0
  10. plain/postgres/base.py +1415 -0
  11. plain/postgres/cli/__init__.py +3 -0
  12. plain/postgres/cli/db.py +142 -0
  13. plain/postgres/cli/migrations.py +1085 -0
  14. plain/postgres/config.py +18 -0
  15. plain/postgres/connection.py +1331 -0
  16. plain/postgres/connections.py +77 -0
  17. plain/postgres/constants.py +13 -0
  18. plain/postgres/constraints.py +495 -0
  19. plain/postgres/database_url.py +94 -0
  20. plain/postgres/db.py +59 -0
  21. plain/postgres/default_settings.py +38 -0
  22. plain/postgres/deletion.py +475 -0
  23. plain/postgres/dialect.py +640 -0
  24. plain/postgres/entrypoints.py +4 -0
  25. plain/postgres/enums.py +103 -0
  26. plain/postgres/exceptions.py +217 -0
  27. plain/postgres/expressions.py +1912 -0
  28. plain/postgres/fields/__init__.py +2118 -0
  29. plain/postgres/fields/encrypted.py +354 -0
  30. plain/postgres/fields/json.py +413 -0
  31. plain/postgres/fields/mixins.py +30 -0
  32. plain/postgres/fields/related.py +1192 -0
  33. plain/postgres/fields/related_descriptors.py +290 -0
  34. plain/postgres/fields/related_lookups.py +223 -0
  35. plain/postgres/fields/related_managers.py +661 -0
  36. plain/postgres/fields/reverse_descriptors.py +229 -0
  37. plain/postgres/fields/reverse_related.py +328 -0
  38. plain/postgres/fields/timezones.py +143 -0
  39. plain/postgres/forms.py +773 -0
  40. plain/postgres/functions/__init__.py +189 -0
  41. plain/postgres/functions/comparison.py +127 -0
  42. plain/postgres/functions/datetime.py +454 -0
  43. plain/postgres/functions/math.py +140 -0
  44. plain/postgres/functions/mixins.py +59 -0
  45. plain/postgres/functions/text.py +282 -0
  46. plain/postgres/functions/window.py +125 -0
  47. plain/postgres/indexes.py +286 -0
  48. plain/postgres/lookups.py +758 -0
  49. plain/postgres/meta.py +584 -0
  50. plain/postgres/migrations/__init__.py +53 -0
  51. plain/postgres/migrations/autodetector.py +1379 -0
  52. plain/postgres/migrations/exceptions.py +54 -0
  53. plain/postgres/migrations/executor.py +188 -0
  54. plain/postgres/migrations/graph.py +364 -0
  55. plain/postgres/migrations/loader.py +377 -0
  56. plain/postgres/migrations/migration.py +180 -0
  57. plain/postgres/migrations/operations/__init__.py +34 -0
  58. plain/postgres/migrations/operations/base.py +139 -0
  59. plain/postgres/migrations/operations/fields.py +373 -0
  60. plain/postgres/migrations/operations/models.py +798 -0
  61. plain/postgres/migrations/operations/special.py +184 -0
  62. plain/postgres/migrations/optimizer.py +74 -0
  63. plain/postgres/migrations/questioner.py +340 -0
  64. plain/postgres/migrations/recorder.py +119 -0
  65. plain/postgres/migrations/serializer.py +378 -0
  66. plain/postgres/migrations/state.py +882 -0
  67. plain/postgres/migrations/utils.py +147 -0
  68. plain/postgres/migrations/writer.py +302 -0
  69. plain/postgres/options.py +207 -0
  70. plain/postgres/otel.py +231 -0
  71. plain/postgres/preflight.py +336 -0
  72. plain/postgres/query.py +2242 -0
  73. plain/postgres/query_utils.py +456 -0
  74. plain/postgres/registry.py +217 -0
  75. plain/postgres/schema.py +1885 -0
  76. plain/postgres/sql/__init__.py +40 -0
  77. plain/postgres/sql/compiler.py +1869 -0
  78. plain/postgres/sql/constants.py +22 -0
  79. plain/postgres/sql/datastructures.py +222 -0
  80. plain/postgres/sql/query.py +2947 -0
  81. plain/postgres/sql/where.py +374 -0
  82. plain/postgres/test/__init__.py +0 -0
  83. plain/postgres/test/pytest.py +117 -0
  84. plain/postgres/test/utils.py +18 -0
  85. plain/postgres/transaction.py +222 -0
  86. plain/postgres/types.py +92 -0
  87. plain/postgres/types.pyi +751 -0
  88. plain/postgres/utils.py +345 -0
  89. plain_postgres-0.84.0.dist-info/METADATA +937 -0
  90. plain_postgres-0.84.0.dist-info/RECORD +93 -0
  91. plain_postgres-0.84.0.dist-info/WHEEL +4 -0
  92. plain_postgres-0.84.0.dist-info/entry_points.txt +5 -0
  93. plain_postgres-0.84.0.dist-info/licenses/LICENSE +61 -0
plain/postgres/db.py ADDED
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from plain import signals
6
+
7
+ from .connections import get_connection, has_connection
8
+ from .exceptions import (
9
+ DatabaseError,
10
+ DatabaseErrorWrapper,
11
+ DataError,
12
+ Error,
13
+ IntegrityError,
14
+ InterfaceError,
15
+ InternalError,
16
+ NotSupportedError,
17
+ OperationalError,
18
+ ProgrammingError,
19
+ )
20
+
21
+ PLAIN_VERSION_PICKLE_KEY = "_plain_version"
22
+
23
+
24
+ # Register an event to reset saved queries when a Plain request is started.
25
+ def reset_queries(**kwargs: Any) -> None:
26
+ if has_connection():
27
+ get_connection().queries_log.clear()
28
+
29
+
30
+ signals.request_started.connect(reset_queries)
31
+
32
+
33
+ # Register an event to reset transaction state and close connections past
34
+ # their lifetime.
35
+ def close_old_connections(**kwargs: Any) -> None:
36
+ if has_connection():
37
+ get_connection().close_if_unusable_or_obsolete()
38
+
39
+
40
+ signals.request_started.connect(close_old_connections)
41
+ signals.request_finished.connect(close_old_connections)
42
+
43
+
44
+ __all__ = [
45
+ "get_connection",
46
+ "has_connection",
47
+ "PLAIN_VERSION_PICKLE_KEY",
48
+ "Error",
49
+ "InterfaceError",
50
+ "DatabaseError",
51
+ "DataError",
52
+ "OperationalError",
53
+ "IntegrityError",
54
+ "InternalError",
55
+ "ProgrammingError",
56
+ "NotSupportedError",
57
+ "DatabaseErrorWrapper",
58
+ "close_old_connections",
59
+ ]
@@ -0,0 +1,38 @@
1
+ from os import environ
2
+
3
+ from plain.runtime.secret import Secret
4
+
5
+ from . import database_url
6
+
7
+ # Connection behavior (always have defaults)
8
+ POSTGRES_PORT: int | None = None
9
+ POSTGRES_CONN_MAX_AGE: int = 600
10
+ POSTGRES_CONN_HEALTH_CHECKS: bool = True
11
+ POSTGRES_OPTIONS: dict = {}
12
+ POSTGRES_TIME_ZONE: str | None = None
13
+
14
+ if "DATABASE_URL" in environ:
15
+ _db_url = environ["DATABASE_URL"]
16
+ if _db_url.lower() == "none":
17
+ # Explicitly disable database (e.g. during Docker builds)
18
+ POSTGRES_HOST: str = ""
19
+ POSTGRES_DATABASE: str = ""
20
+ POSTGRES_USER: str = ""
21
+ POSTGRES_PASSWORD: Secret[str] = ""
22
+ else:
23
+ _parsed = database_url.parse_database_url(_db_url)
24
+ POSTGRES_HOST: str = _parsed["HOST"]
25
+ POSTGRES_DATABASE: str = _parsed["DATABASE"] or ""
26
+ POSTGRES_USER: str = _parsed["USER"]
27
+ POSTGRES_PASSWORD: Secret[str] = _parsed["PASSWORD"]
28
+ if _parsed["PORT"]:
29
+ POSTGRES_PORT = _parsed["PORT"]
30
+ if _parsed.get("OPTIONS"):
31
+ POSTGRES_OPTIONS = _parsed["OPTIONS"]
32
+ else:
33
+ # Individual settings are required when no DATABASE_URL is provided.
34
+ # Set via PLAIN_POSTGRES_* environment variables or in app/settings.py.
35
+ POSTGRES_HOST: str
36
+ POSTGRES_DATABASE: str
37
+ POSTGRES_USER: str
38
+ POSTGRES_PASSWORD: Secret[str]
@@ -0,0 +1,475 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter, defaultdict
4
+ from collections.abc import Callable, Generator, Iterable
5
+ from functools import partial, reduce
6
+ from itertools import chain
7
+ from operator import attrgetter, or_
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from plain.postgres import (
11
+ query_utils,
12
+ transaction,
13
+ )
14
+ from plain.postgres.db import IntegrityError
15
+ from plain.postgres.meta import Meta
16
+ from plain.postgres.query import QuerySet
17
+ from plain.postgres.sql import DeleteQuery, UpdateQuery
18
+
19
+ if TYPE_CHECKING:
20
+ from plain.postgres.fields import Field
21
+ from plain.postgres.fields.related import RelatedField
22
+ from plain.postgres.fields.reverse_related import ForeignKeyRel
23
+
24
+ # Handlers in this set skip the sub_objs emptiness check in Collector.collect,
25
+ # allowing the handler to run even when there are no related objects. External
26
+ # on_delete handlers can still opt-in by setting ``lazy_sub_objs = True`` as an
27
+ # attribute — the Collector checks both this set and getattr as a fallback.
28
+ _LAZY_ON_DELETE: set[Callable[..., Any]] = set()
29
+
30
+
31
+ class ProtectedError(IntegrityError):
32
+ def __init__(self, msg: str, protected_objects: Iterable[Any]) -> None:
33
+ self.protected_objects = protected_objects
34
+ super().__init__(msg, protected_objects)
35
+
36
+
37
+ class RestrictedError(IntegrityError):
38
+ def __init__(self, msg: str, restricted_objects: Iterable[Any]) -> None:
39
+ self.restricted_objects = restricted_objects
40
+ super().__init__(msg, restricted_objects)
41
+
42
+
43
+ def CASCADE(collector: Collector, field: RelatedField, sub_objs: Any) -> None:
44
+ collector.collect(
45
+ sub_objs,
46
+ source=field.remote_field.model,
47
+ nullable=field.allow_null,
48
+ fail_on_restricted=False,
49
+ )
50
+
51
+
52
+ def PROTECT(collector: Collector, field: RelatedField, sub_objs: Any) -> None:
53
+ raise ProtectedError(
54
+ f"Cannot delete some instances of model '{field.remote_field.model.__name__}' because they are "
55
+ f"referenced through a protected foreign key: '{sub_objs[0].__class__.__name__}.{field.name}'",
56
+ sub_objs,
57
+ )
58
+
59
+
60
+ def RESTRICT(collector: Collector, field: RelatedField, sub_objs: Any) -> None:
61
+ collector.add_restricted_objects(field, sub_objs)
62
+ collector.add_dependency(field.remote_field.model, field.model)
63
+
64
+
65
+ class _SetOnDelete:
66
+ """On-delete handler returned by :func:`SET`."""
67
+
68
+ lazy_sub_objs: bool = True
69
+
70
+ def __init__(self, value: Any) -> None:
71
+ self._value = value
72
+ self._resolve = callable(value)
73
+
74
+ def __call__(
75
+ self, collector: Collector, field: RelatedField, sub_objs: Any
76
+ ) -> None:
77
+ resolved = self._value() if self._resolve else self._value
78
+ collector.add_field_update(field, resolved, sub_objs)
79
+
80
+ def deconstruct(self) -> tuple[str, tuple[Any, ...], dict[str, Any]]:
81
+ return ("plain.postgres.SET", (self._value,), {})
82
+
83
+
84
+ def SET(value: Any) -> _SetOnDelete:
85
+ return _SetOnDelete(value)
86
+
87
+
88
+ def SET_NULL(collector: Collector, field: RelatedField, sub_objs: Any) -> None:
89
+ collector.add_field_update(field, None, sub_objs)
90
+
91
+
92
+ _LAZY_ON_DELETE.add(SET_NULL)
93
+
94
+
95
+ def SET_DEFAULT(collector: Collector, field: RelatedField, sub_objs: Any) -> None:
96
+ collector.add_field_update(field, field.get_default(), sub_objs)
97
+
98
+
99
+ _LAZY_ON_DELETE.add(SET_DEFAULT)
100
+
101
+
102
+ def DO_NOTHING(collector: Collector, field: RelatedField, sub_objs: Any) -> None:
103
+ pass
104
+
105
+
106
+ def get_candidate_relations_to_delete(
107
+ meta: Meta,
108
+ ) -> Generator[ForeignKeyRel]:
109
+ from plain.postgres.fields.reverse_related import ForeignKeyRel
110
+
111
+ # The candidate relations are the ones that come from N-1 and 1-1 relations.
112
+ # N-N (i.e., many-to-many) relations aren't candidates for deletion.
113
+ return (
114
+ f
115
+ for f in meta.get_fields(include_reverse=True)
116
+ if f.auto_created and not f.concrete and isinstance(f, ForeignKeyRel)
117
+ )
118
+
119
+
120
+ class Collector:
121
+ def __init__(self, origin: Any = None) -> None:
122
+ # A Model or QuerySet object.
123
+ self.origin = origin
124
+ # Initially, {model: {instances}}, later values become lists.
125
+ self.data: defaultdict[Any, Any] = defaultdict(set)
126
+ # {(field, value): [instances, …]}
127
+ self.field_updates: defaultdict[tuple[Field, Any], list[Any]] = defaultdict(
128
+ list
129
+ )
130
+ # {model: {field: {instances}}}
131
+ self.restricted_objects: defaultdict[Any, Any] = defaultdict(
132
+ partial(defaultdict, set)
133
+ )
134
+ # fast_deletes is a list of queryset-likes that can be deleted without
135
+ # fetching the objects into memory.
136
+ self.fast_deletes: list[Any] = []
137
+
138
+ # Tracks deletion-order dependency for constraint checks. Only
139
+ # concrete model classes should be included, as the dependencies
140
+ # exist only between actual database tables.
141
+ self.dependencies: defaultdict[Any, set[Any]] = defaultdict(
142
+ set
143
+ ) # {model: {models}}
144
+
145
+ def add(
146
+ self,
147
+ objs: Iterable[Any],
148
+ source: Any = None,
149
+ nullable: bool = False,
150
+ reverse_dependency: bool = False,
151
+ ) -> list[Any]:
152
+ """
153
+ Add 'objs' to the collection of objects to be deleted. If the call is
154
+ the result of a cascade, 'source' should be the model that caused it,
155
+ and 'nullable' should be set to True if the relation can be null.
156
+
157
+ Return a list of all objects that were not already collected.
158
+ """
159
+ if not objs:
160
+ return []
161
+ new_objs = []
162
+ model = objs[0].__class__ # type: ignore[index]
163
+ instances = self.data[model]
164
+ for obj in objs:
165
+ if obj not in instances:
166
+ new_objs.append(obj)
167
+ instances.update(new_objs)
168
+ # Nullable relationships can be ignored -- they are nulled out before
169
+ # deleting, and therefore do not affect the order in which objects have
170
+ # to be deleted.
171
+ if source is not None and not nullable:
172
+ self.add_dependency(source, model, reverse_dependency=reverse_dependency)
173
+ return new_objs
174
+
175
+ def add_dependency(
176
+ self, model: Any, dependency: Any, reverse_dependency: bool = False
177
+ ) -> None:
178
+ if reverse_dependency:
179
+ model, dependency = dependency, model
180
+ self.dependencies[model].add(dependency)
181
+ self.data.setdefault(dependency, set())
182
+
183
+ def add_field_update(
184
+ self, field: RelatedField, value: Any, objs: Iterable[Any]
185
+ ) -> None:
186
+ """
187
+ Schedule a field update. 'objs' must be a homogeneous iterable
188
+ collection of model instances (e.g. a QuerySet).
189
+ """
190
+ self.field_updates[field, value].append(objs)
191
+
192
+ def add_restricted_objects(self, field: RelatedField, objs: Iterable[Any]) -> None:
193
+ if objs:
194
+ model = objs[0].__class__ # type: ignore[index]
195
+ self.restricted_objects[model][field].update(objs)
196
+
197
+ def clear_restricted_objects_from_set(self, model: Any, objs: set[Any]) -> None:
198
+ if model in self.restricted_objects:
199
+ self.restricted_objects[model] = {
200
+ field: items - objs
201
+ for field, items in self.restricted_objects[model].items()
202
+ }
203
+
204
+ def clear_restricted_objects_from_queryset(self, model: Any, qs: QuerySet) -> None:
205
+ if model in self.restricted_objects:
206
+ objs = set(
207
+ qs.filter(
208
+ id__in=[
209
+ obj.id
210
+ for objs in self.restricted_objects[model].values()
211
+ for obj in objs
212
+ ]
213
+ )
214
+ )
215
+ self.clear_restricted_objects_from_set(model, objs)
216
+
217
+ def can_fast_delete(self, objs: Any, from_field: Any = None) -> bool:
218
+ """
219
+ Determine if the objects in the given queryset-like or single object
220
+ can be fast-deleted. This can be done if there are no cascades, no
221
+ parents and no signal listeners for the object class.
222
+
223
+ The 'from_field' tells where we are coming from - we need this to
224
+ determine if the objects are in fact to be deleted. Allow also
225
+ skipping parent -> child -> parent chain preventing fast delete of
226
+ the child.
227
+ """
228
+ from plain.postgres.fields.related import RelatedField
229
+
230
+ if (
231
+ isinstance(from_field, RelatedField)
232
+ and from_field.remote_field.on_delete is not CASCADE
233
+ ):
234
+ return False
235
+ if hasattr(objs, "_model_meta"):
236
+ model = objs._model_meta.model
237
+ elif hasattr(objs, "model") and hasattr(objs, "_raw_delete"):
238
+ model = objs.model
239
+ else:
240
+ return False
241
+
242
+ # The use of from_field comes from the need to avoid cascade back to
243
+ # parent when parent delete is cascading to child.
244
+ meta = model._model_meta
245
+ return (
246
+ # Foreign keys pointing to this model.
247
+ all(
248
+ related.field.remote_field.on_delete is DO_NOTHING
249
+ for related in get_candidate_relations_to_delete(meta)
250
+ )
251
+ )
252
+
253
+ def collect(
254
+ self,
255
+ objs: Iterable[Any],
256
+ source: Any = None,
257
+ nullable: bool = False,
258
+ collect_related: bool = True,
259
+ reverse_dependency: bool = False,
260
+ fail_on_restricted: bool = True,
261
+ ) -> None:
262
+ """
263
+ Add 'objs' to the collection of objects to be deleted as well as all
264
+ parent instances. 'objs' must be a homogeneous iterable collection of
265
+ model instances (e.g. a QuerySet). If 'collect_related' is True,
266
+ related objects will be handled by their respective on_delete handler.
267
+
268
+ If the call is the result of a cascade, 'source' should be the model
269
+ that caused it and 'nullable' should be set to True, if the relation
270
+ can be null.
271
+
272
+ If 'reverse_dependency' is True, 'source' will be deleted before the
273
+ current model, rather than after. (Needed for cascading to parent
274
+ models, the one case in which the cascade follows the forwards
275
+ direction of an FK rather than the reverse direction.)
276
+
277
+ If 'fail_on_restricted' is False, error won't be raised even if it's
278
+ prohibited to delete such objects due to RESTRICT, that defers
279
+ restricted object checking in recursive calls where the top-level call
280
+ may need to collect more objects to determine whether restricted ones
281
+ can be deleted.
282
+ """
283
+ if self.can_fast_delete(objs):
284
+ self.fast_deletes.append(objs)
285
+ return
286
+ new_objs = self.add(
287
+ objs, source, nullable, reverse_dependency=reverse_dependency
288
+ )
289
+ if not new_objs:
290
+ return
291
+
292
+ model = new_objs[0].__class__
293
+
294
+ if not collect_related:
295
+ return
296
+
297
+ model_fast_deletes = defaultdict(list)
298
+ protected_objects = defaultdict(list)
299
+ for related in get_candidate_relations_to_delete(model._model_meta):
300
+ field = related.field
301
+ on_delete = field.remote_field.on_delete
302
+ if on_delete == DO_NOTHING:
303
+ continue
304
+ related_model = related.related_model
305
+ if self.can_fast_delete(related_model, from_field=field):
306
+ model_fast_deletes[related_model].append(field)
307
+ continue
308
+ sub_objs = self.related_objects(related_model, [field], new_objs)
309
+ # Non-referenced fields can be deferred if no signal receivers
310
+ # are connected for the related model as they'll never be
311
+ # exposed to the user. Skip field deferring when some
312
+ # relationships are select_related as interactions between both
313
+ # features are hard to get right. This should only happen in
314
+ # the rare cases where .related_objects is overridden anyway.
315
+ if not sub_objs.sql_query.select_related:
316
+ referenced_fields = set(
317
+ chain.from_iterable(
318
+ (rf.attname for rf in rel.field.foreign_related_fields)
319
+ for rel in get_candidate_relations_to_delete(
320
+ related_model._model_meta
321
+ )
322
+ )
323
+ )
324
+ sub_objs = sub_objs.only(*tuple(referenced_fields))
325
+ if (
326
+ on_delete in _LAZY_ON_DELETE
327
+ or getattr(on_delete, "lazy_sub_objs", False)
328
+ or sub_objs
329
+ ):
330
+ try:
331
+ on_delete(self, field, sub_objs)
332
+ except ProtectedError as error:
333
+ key = f"'{field.model.__name__}.{field.name}'"
334
+ protected_objects[key] += error.protected_objects
335
+ if protected_objects:
336
+ raise ProtectedError(
337
+ "Cannot delete some instances of model {!r} because they are "
338
+ "referenced through protected foreign keys: {}.".format(
339
+ model.__name__,
340
+ ", ".join(protected_objects),
341
+ ),
342
+ set(chain.from_iterable(protected_objects.values())),
343
+ )
344
+ for related_model, related_fields in model_fast_deletes.items():
345
+ sub_objs = self.related_objects(related_model, related_fields, new_objs)
346
+ self.fast_deletes.append(sub_objs)
347
+
348
+ if fail_on_restricted:
349
+ # Raise an error if collected restricted objects (RESTRICT) aren't
350
+ # candidates for deletion also collected via CASCADE.
351
+ for related_model, instances in self.data.items():
352
+ self.clear_restricted_objects_from_set(related_model, instances)
353
+ for qs in self.fast_deletes:
354
+ self.clear_restricted_objects_from_queryset(qs.model, qs)
355
+ if self.restricted_objects.values():
356
+ restricted_objects = defaultdict(list)
357
+ for related_model, fields in self.restricted_objects.items():
358
+ for field, objs in fields.items():
359
+ if objs:
360
+ key = f"'{related_model.__name__}.{field.name}'"
361
+ restricted_objects[key] += objs
362
+ if restricted_objects:
363
+ raise RestrictedError(
364
+ "Cannot delete some instances of model {!r} because "
365
+ "they are referenced through restricted foreign keys: "
366
+ "{}.".format(
367
+ model.__name__,
368
+ ", ".join(restricted_objects),
369
+ ),
370
+ set(chain.from_iterable(restricted_objects.values())),
371
+ )
372
+
373
+ def related_objects(
374
+ self, related_model: Any, related_fields: list[Field], objs: Iterable[Any]
375
+ ) -> QuerySet:
376
+ """
377
+ Get a QuerySet of the related model to objs via related fields.
378
+ """
379
+ predicate = query_utils.Q.create(
380
+ [(f"{related_field.name}__in", objs) for related_field in related_fields],
381
+ connector=query_utils.Q.OR,
382
+ )
383
+ return related_model._model_meta.base_queryset.filter(predicate)
384
+
385
+ def sort(self) -> None:
386
+ sorted_models = []
387
+ concrete_models = set()
388
+ models = list(self.data)
389
+ while len(sorted_models) < len(models):
390
+ found = False
391
+ for model in models:
392
+ if model in sorted_models:
393
+ continue
394
+ dependencies = self.dependencies.get(model)
395
+ if not (dependencies and dependencies.difference(concrete_models)):
396
+ sorted_models.append(model)
397
+ concrete_models.add(model)
398
+ found = True
399
+ if not found:
400
+ return
401
+ self.data = defaultdict(
402
+ set, {model: self.data[model] for model in sorted_models}
403
+ )
404
+
405
+ def delete(self) -> tuple[int, dict[str, int]]:
406
+ # sort instance collections
407
+ for model, instances in self.data.items():
408
+ self.data[model] = sorted(instances, key=attrgetter("id"))
409
+
410
+ # if possible, bring the models in an order suitable for databases that
411
+ # don't support transactions or cannot defer constraint checks until the
412
+ # end of a transaction.
413
+ self.sort()
414
+ # number of objects deleted for each model label
415
+ deleted_counter = Counter()
416
+
417
+ # Optimize for the case with a single obj and no dependencies
418
+ if len(self.data) == 1 and len(instances) == 1:
419
+ instance = list(instances)[0]
420
+ if self.can_fast_delete(instance):
421
+ with transaction.mark_for_rollback_on_error():
422
+ count = DeleteQuery(model).delete_batch([instance.id])
423
+ setattr(
424
+ instance, model._model_meta.get_forward_field("id").attname, None
425
+ )
426
+ return count, {model.model_options.label: count}
427
+
428
+ with transaction.atomic(savepoint=False):
429
+ # fast deletes
430
+ for qs in self.fast_deletes:
431
+ count = qs._raw_delete()
432
+ if count:
433
+ deleted_counter[qs.model.model_options.label] += count
434
+
435
+ # update fields
436
+ for (field, value), instances_list in self.field_updates.items():
437
+ assert field.name is not None
438
+ updates = []
439
+ objs = []
440
+ for instances in instances_list:
441
+ if (
442
+ isinstance(instances, QuerySet)
443
+ and instances._result_cache is None
444
+ ):
445
+ updates.append(instances)
446
+ else:
447
+ objs.extend(instances)
448
+ if updates:
449
+ combined_updates = reduce(or_, updates)
450
+ combined_updates.update(**{field.name: value})
451
+ if objs:
452
+ model = objs[0].__class__
453
+ query = UpdateQuery(model)
454
+ query.update_batch(
455
+ list({obj.id for obj in objs}), {field.name: value}
456
+ )
457
+
458
+ # reverse instance collections
459
+ for instances in self.data.values():
460
+ instances.reverse()
461
+
462
+ # delete instances
463
+ for model, instances in self.data.items():
464
+ query = DeleteQuery(model)
465
+ id_list = [obj.id for obj in instances]
466
+ count = query.delete_batch(id_list)
467
+ if count:
468
+ deleted_counter[model.model_options.label] += count
469
+
470
+ for model, instances in self.data.items():
471
+ for instance in instances:
472
+ setattr(
473
+ instance, model._model_meta.get_forward_field("id").attname, None
474
+ )
475
+ return sum(deleted_counter.values()), dict(deleted_counter)