Soft Delete — deletedAt, mongoose-delete plugin

~15 daq · MongoDB · Amaliy va Professional

Muammodan boshlaylik

Admin xato bilan mahsulotni o'chirdi:

await Mahsulot.findByIdAndDelete(id)
  • Mahsulot — butunlay yo'q. Tiklash — faqat zaxiradan (34-dars), va zaxiradan keyingi o'zgarishlar yo'qoladi
  • Eski buyurtmalarda shu mahsulotga havola bor — populate null qaytaradi, buyurtma tarixi "singan"
  • "Kim, qachon o'chirdi?" — hech qanday iz yo'q

Soft delete nima?

Soft delete — documentni haqiqatan o'chirmasdan, uni o'chirilgan deb belgilash:

// hard delete
db.mahsulotlar.deleteOne({ _id })

// soft delete
db.mahsulotlar.updateOne({ _id }, { $set: { deletedAt: new Date(), deletedBy: adminId } })

Oddiy so'rovlarda o'chirilganlar ko'rinmaydi, lekin ma'lumot bazada qoladi — tiklash, audit va tarixiy hisobotlar uchun.


Hayotiy o'xshatma

Kompyuterdagi "Korzina". Faylni o'chirsangiz — u yo'qolmaydi, korzinaga tushadi. Papkada ko'rinmaydi, lekin kerak bo'lsa — qaytarasiz. Korzinani vaqti-vaqti bilan tozalaysiz — shunda fayl butunlay o'chadi.


Qo'lda amalga oshirish

const mahsulotSchema = new Schema({
  nomi: String,
  narx: Number,
  deletedAt: { type: Date, default: null, index: true },
  deletedBy: { type: Schema.Types.ObjectId, ref: 'User' },
})

// o'chirish va tiklash
mahsulotSchema.methods.softDelete = function (userId) {
  this.deletedAt = new Date()
  this.deletedBy = userId
  return this.save()
}

mahsulotSchema.methods.tikla = function () {
  this.deletedAt = null
  this.deletedBy = undefined
  return this.save()
}

// barcha find so'rovlarida avtomatik filtr
mahsulotSchema.pre(/^find|^count/, function () {
  if (this.getOptions().ochirilganlarBilan) return
  this.where({ deletedAt: null })
})

mahsulotSchema.pre('aggregate', function () {
  if (this.options.ochirilganlarBilan) return
  this.pipeline().unshift({ $match: { deletedAt: null } })
})
const m = await Mahsulot.findById(id)
await m.softDelete(req.user.id)

await Mahsulot.find()                     // o'chirilganlar yo'q
await Mahsulot.findById(id)               // null

// admin "korzina" sahifasi
await Mahsulot.find({ deletedAt: { $ne: null } }).setOptions({ ochirilganlarBilan: true })

setOptions — query ga o'z opsiyangizni berish; middleware uni getOptions() bilan o'qiydi.


Plugin sifatida

Bir nechta model uchun — plugin (48-dars):

// src/plugins/softDelete.js
export function softDeletePlugin(schema) {
  schema.add({
    deletedAt: { type: Date, default: null, index: true },
    deletedBy: { type: schema.base.Schema.Types.ObjectId, ref: 'User' },
  })

  schema.methods.softDelete = function (userId) {
    this.deletedAt = new Date()
    this.deletedBy = userId
    return this.save()
  }

  schema.methods.tikla = function () {
    this.deletedAt = null
    this.deletedBy = undefined
    return this.save()
  }

  schema.pre(/^find|^count/, function () {
    if (!this.getOptions().ochirilganlarBilan) this.where({ deletedAt: null })
  })
}
mahsulotSchema.plugin(softDeletePlugin)
postSchema.plugin(softDeletePlugin)

mongoose-delete

Tayyor plagin:

npm install mongoose-delete
import mongooseDelete from 'mongoose-delete'

mahsulotSchema.plugin(mongooseDelete, {
  deletedAt: true,
  deletedBy: true,
  overrideMethods: 'all',
})
await Mahsulot.deleteById(id, req.user.id)   // soft delete
await Mahsulot.restore({ _id: id })           // tiklash
await Mahsulot.find()                         // o'chirilmaganlar
await Mahsulot.findDeleted()                  // faqat o'chirilganlar
await Mahsulot.findWithDeleted()              // hammasi

overrideMethods: 'all' — find, count, update va boshqalar avtomatik o'chirilganlarni chiqarib tashlaydi. Plaginning Mongoose versiyangizga mosligini tekshiring.


Noyob maydonlar muammosi

email: { type: String, unique: true }

User o'chirildi (soft), keyin xuddi shu email bilan qayta ro'yxatdan o'tmoqchi — E11000 duplicate key: eski document hali bazada.

Yechim — partial unique index (27-dars): noyoblik faqat o'chirilmaganlar orasida:

userSchema.index(
  { email: 1 },
  { unique: true, partialFilterExpression: { deletedAt: null } }
)

Butunlay tozalash

Soft delete qilingan ma'lumotlar abadiy saqlanmasin — xotira va qonuniy talablar (shaxsiy ma'lumotlarni o'chirish huquqi):

// 90 kundan keyin avtomatik — TTL index
mahsulotSchema.index({ deletedAt: 1 }, { expireAfterSeconds: 90 * 24 * 60 * 60 })

deletedAt: null bo'lgan documentlarga TTL ta'sir qilmaydi — faqat sana qo'yilganlarga.

[!WARNING] Soft delete — shaxsiy ma'lumotlarni o'chirish talabini bajarmaydi: ma'lumot bazada qoladi. Foydalanuvchi "hisobimni o'chiring" desa — shaxsiy maydonlarni (ism, email, telefon) haqiqatan o'chiring yoki anonimlashtiring, faqat statistika uchun kerakli qismini qoldiring.


Qachon soft delete

✅ Mos:

  • Xato bilan o'chirish ehtimoli bor (admin panel)
  • Boshqa documentlar unga havola qiladi (buyurtmadagi mahsulot)
  • Audit kerak: kim, qachon o'chirdi

❌ Mos emas:

  • Vaqtinchalik ma'lumotlar (sessiyalar, kodlar) — hard delete yoki TTL
  • Juda katta, tez o'zgaruvchan collection lar — "o'lik" documentlar index va so'rovlarni og'irlashtiradi

Tez-tez uchraydigan xatolar

Filtrni unutish

Middleware siz — har bir so'rovda deletedAt: null ni qo'lda yozish kerak va bir joyda albatta unutiladi. Avtomatik filtr (middleware/plagin).

Aggregation va $lookup da filtr

Find middleware aggregation da ishlamaydi (49-dars); $lookup ichida ham o'chirilganlar ko'rinadi — pipeline: [{ $match: { deletedAt: null } }].

Unique index

Partial unique index siz — o'chirilgan qiymatni qayta ishlatib bo'lmaydi.


Xulosa

  • Soft delete — deletedAt bilan belgilash, haqiqatan o'chirmaslik; tiklash va audit
  • Avtomatik filtr — pre(/^find|^count/) va pre('aggregate'); setOptions bilan istisno
  • Plugin sifatida qayta ishlatish yoki mongoose-delete
  • Noyob maydonlar — partialFilterExpression: { deletedAt: null }
  • TTL index — N kundan keyin butunlay o'chirish
  • Shaxsiy ma'lumotlarni o'chirish talabi — haqiqiy o'chirish yoki anonimlashtirish

Keyingi dars: 58-dars — Caching — Redis bilan MongoDB natijalarini keshlash