Перейти к содержанию

Мягкое удаление

Meta.soft_delete_field превращает delete() в пометку строки удалённой: строка остаётся в таблице, и каждый запрос пропускает её, если только не просит удалённые строки.

class Book(Model):
    title = fields.CharField(max_length=200)
    deleted_at = fields.DatetimeField(null=True)

    class Meta:
        soft_delete_field = "deleted_at"

Поле должно существовать в модели и допускать None.

Удаление: delete()

  • delete() становится UPDATE, который записывает deleted_at = now() и в той же транзакции так же помечает связанные строки по каскаду — вместо настоящего DELETE. Это относится и к QuerySet.filter(...).delete() (массовому удалению), а не только к delete() одного объекта: для модели, на которую ничто не ссылается, это один массовый UPDATE; иначе найденные строки и их каскад помечаются партиями — несколько запросов на волну связанных строк, а не на каждую строку. Все строки, которые помечает один delete(), включая каскадные, получают одно и то же deleted_at. Удаляемые строки сначала блокируются (SELECT ... FOR NO KEY UPDATE в PostgreSQL): строку, которую другой delete() пометил после того, как этот объект или запрос её прочитал (устаревший объект или два одновременных удаления), удаление не трогает — она сохраняет своё исходное deleted_at, повторно по каскаду не обрабатывается и не считается. Устаревший объект получает deleted_at строки.
  • Строка, до которой доходит каскад, но у модели которой нет своего soft_delete_field, сохраняется и по-прежнему ссылается на удалённую строку; так же сохраняются строки автоматических промежуточных таблиц связей «многие-ко-многим». Пока строка удалена, они читаются как отсутствие связанной строки (см. Что видят запросы), а после восстановления снова на неё указывают. Meta.soft_delete_hard_cascade = True у модели, которую удаляют мягко, заставляет её мягкое удаление удалять их по-настоящему, как при обычном удалении, — тогда restore() их не вернёт. В обоих случаях связи SET_NULL/SET_DEFAULT обновляются, а PROTECT/RESTRICT блокируют удаление.
  • .delete() у запроса помечает только ещё живые строки: уже помеченная строка сохраняет своё исходное deleted_at и не считается — так же, как instance.delete() не трогает уже помеченный объект (поэтому only_deleted().delete() возвращает 0). Проверка объекта читает deleted_at из базы, если .only()/.defer() его не загрузили.
  • Каскад доходит до связанных строк всех арендаторов и удаляет их — мягко или настоящим DELETE; текущий Tenancy.scope() проверяется только для объекта, у которого вызван delete(). Если связанная модель переопределяет delete(), это переопределение вызывается внутри Tenancy.scope() арендатора самой строки.
  • После delete()/restore() get_dirty_fields() не показывает поля optimistic_lock_field и auto_now, которые мягкое удаление записало вместе с soft_delete_field.

Восстановление: restore()

restore() отменяет пометку, возвращая полю None. По умолчанию возвращается только эта строка. restore(cascade=True) восстанавливает и строки, которые её мягкое удаление убрало вместе с ней: каждую строку, до которой ведёт on_delete=CASCADE и у которой всё ещё стоит время удаления этой строки, — каскад ставит одно время на всё, что удаляет, поэтому это время и определяет удаление. Строка, мягко удалённая отдельно, раньше или позже, остаётся удалённой, как и строки ниже неё; значения, перезаписанные SET_NULL/SET_DEFAULT, не возвращаются.

Настоящее удаление: hard_delete()

hard_delete() удаляет строку по-настоящему, живую или уже помеченную (очистка корзины); связанные строки поступают по своему on_delete, как при настоящем DELETE, — строка по on_delete=CASCADE тоже удаляется по-настоящему, в том числе модели с soft_delete_field и через связь db_constraint=False. Удаление модели без soft_delete_field распространяется так же. QuerySet.hard_delete() делает то же для всех строк запроса:

await Book.objects.only_deleted().filter(deleted_at__lt=cutoff).hard_delete()

Что видят запросы

  • Менеджер по умолчанию добавляет WHERE deleted_at IS NULL к каждому запросу (.filter(), .all(), ...).
  • Фильтр из другой модели через обратную связь тоже пропускает помеченные связанные строки — и вложенный (Author.objects.filter(books__title=...)), и по самой связи (books__isnull=..., books=book, books__in=[...], а также их варианты в exclude()).
  • Прямая связь, указывающая на помеченную строку (или на строку другого арендатора, или на строку, которую отсекает собственный Meta.manager, — см. Разделение данных по арендаторам), читается как отсутствие связанной строки: book.author, select_related()/prefetch_related() и values("author__name") дают None, фильтр через неё (Book.objects.filter(author__name=...)) с ней не совпадает, и проверки связи на NULL с этим согласны — author__isnull=True (и author=None, author__not_isnull=False) находит такую книгу, а author__isnull=False — нет; так же и author__name__isnull=True (author__name=None), потому что у скрытой цели нет видимых колонок. Сама колонка ключа сравнивает записанное значение: author_id__isnull, author=obj и author_id=... видят ключ как он записан, удалена цель или нет. С include_deleted()/only_deleted() у фильтруемого запроса цель снова видна, поэтому author__isnull там — простая проверка ключа.

Model.objects.include_deleted() возвращает QuerySet без этого фильтра:

await Book.objects.include_deleted().filter(id=book_id).first()

Model.objects.only_deleted() / QuerySet.only_deleted() возвращают только помеченные строки (WHERE deleted_at IS NOT NULL) — корзина, из которой можно восстановить:

for book in await Book.objects.only_deleted().filter(shelf=shelf):
    await book.restore()

only_deleted() и include_deleted() заменяют друг друга — действует последний вызов (Book.objects.only_deleted().include_deleted() видит все строки). Как и include_deleted(), он сохраняет фильтр Meta.tenant_field (чтобы его убрать, добавьте .all_tenants()) и позволяет связям, доступным из запроса (select_related()/prefetch_related()/фильтр relation__field), тоже видеть помеченные строки. Он работает и у запроса связанного менеджера: author.books.all().only_deleted().

Запись soft_delete_field

  • QuerySet.update(...), bulk_update(), save(update_fields=[...]), merge().when_matched(update=...) и bulk_create(update_fields=[...]) отказываются записывать soft_delete_field (QueryError) — используйте .delete()/.restore(), чтобы сработала логика каскада. Обычный save() его тоже никогда не записывает.
  • Вставка-или-обновление (bulk_create(update_fields=..., on_conflict=...)), конфликтующая с помеченной строкой, обновляет другие колонки этой строки, но оставляет её удалённой — чтобы вернуть, вызовите restore(). Так же поступают save() и bulk_update() экземпляров, прочитанных до того, как их строки пометили удалёнными в другом месте: оба находят строку только по ключу, как экземпляр был прочитан; с optimistic_lock_field они вместо этого бросают StaleObjectError.
  • create() и bulk_create() принимают значения soft_delete_field и Meta.optimistic_lock_field, поэтому строку можно вставить сразу удалённой или с заданной версией — для импорта или восстановления данных, удалённых или версионированных в другом месте. update(), save() и bulk_update() ни то, ни другое не записывают: update()/bulk_update() дают QueryError, save() даёт ошибку при изменённом soft_delete_field, а optimistic_lock_field считает версией для сверки. Не передавайте данные запроса прямо в create(**data)/bulk_create() — сначала выберите разрешённые поля, иначе клиент сможет вставить скрытую (помеченную удалённой) строку или заранее задать её версию.

Уникальность среди живых строк

hare не переписывает поле с unique=True или запись Meta.constraints так, чтобы исключить помеченные строки: удалённая книга по-прежнему занимает свой ISBN. Для «уникальности среди неудалённых» добавьте условие явно:

from hare import Q
from hare.ddl import UniqueConstraint


class Book(Model):
    isbn = fields.CharField(max_length=13)
    deleted_at = fields.DatetimeField(null=True)

    class Meta:
        soft_delete_field = "deleted_at"
        constraints = (UniqueConstraint(fields=("isbn",), condition=Q(deleted_at__isnull=True)),)

Вместе с optimistic_lock_field

soft_delete_field и optimistic_lock_field сочетаются в одной модели. Мягкое удаление сверяет каждую строку с версией, с которой удаление её прочитало, и увеличивает версию один раз; одновременное изменение даёт StaleObjectError с .model/.pk/.expected_version — см. Исключения. Исключение — строка, мягко удалённая за это время: повторное удаление ничего не делает, как для любой мягко удалённой строки, — экземпляр получает время удаления строки, какой бы ни стала её версия.

Объект после неудачной записи

save()/delete()/restore() обновляют auto_now/auto_now_add и soft_delete_field в объекте Python заранее, ещё не зная, удалась ли запись. Если запись затем не удалась — StaleObjectError из-за конфликта optimistic_lock_field или любое другое исключение посреди записи, — эти значения в памяти возвращаются к тому, что на самом деле в базе, поэтому объект никогда не выглядит сохранённым или удалённым, если этого не произошло.