Soft Delete va Pagination, Filtering

~25 daq · NestJS · Ma'lumotlar bazasi

Muammodan boshlaylik

Foydalanuvchi o'z hisobini o'chirdi. Siz deleteOne qildingiz — ma'lumot yo'qoldi. Bir haftadan keyin u texnik yordam bilan murojaat qildi: "Hisobimni tiklashim mumkinmi?" Javob: "Yo'q, ma'lumotlar yo'q."

Boshqa muammo: frontend /users endpointga so'rov yubordi. Bazada 50 000 ta foydalanuvchi bor. Hammasi bitta javobda keldi — brauzer muzlab qoldi.

Bu ikki muammoni Soft Delete va Pagination hal qiladi.


Soft Delete — o'chirmasdan yashirish

Soft Delete — ma'lumotni aslida o'chirmaslik. O'rniga deletedAt maydoniga sana yoziladi. So'rovlarda deletedAt: null bo'lganlar ko'rsatiladi — o'chirilganlar ko'rinmaydi, lekin bazada qoladi.

Hayotiy o'xshatma: arxiv xonasi. Hujjatni yo'q qilish o'rniga "arxivlangan" tamg'asi bosiladi va alohida tortmachaga qo'yiladi. Kerak bo'lsa topiladi, odatda ko'rinmaydi.

Mongoose bilan Soft Delete

// src/users/schemas/user.schema.ts
@Schema({ timestamps: true })
export class User {
  @Prop({ required: true })
  name: string;

  @Prop({ required: true, unique: true })
  email: string;

  @Prop({ default: null })
  deletedAt: Date | null; // ← null = faol, Date = o'chirilgan
}
// src/users/users.service.ts
@Injectable()
export class UsersService {
  constructor(
    @InjectModel(User.name) private userModel: Model<UserDocument>,
  ) {}

  // Faqat o'chirilmaganlarni ko'rsatish
  async findAll(): Promise<User[]> {
    return this.userModel
      .find({ deletedAt: null }) // ← asosiy filter
      .exec();
  }

  // Soft delete — aslida o'chirmaslik
  async remove(id: string): Promise<{ message: string }> {
    const user = await this.userModel.findOneAndUpdate(
      { _id: id, deletedAt: null },      // faqat faol foydalanuvchi
      { deletedAt: new Date() },          // ← sana yoziladi
      { new: true },
    ).exec();

    if (!user) throw new NotFoundException('Foydalanuvchi topilmadi');
    return { message: 'O\'chirildi' };
  }

  // Tiklash
  async restore(id: string): Promise<User> {
    const user = await this.userModel.findOneAndUpdate(
      { _id: id, deletedAt: { $ne: null } }, // faqat o'chirilganlar
      { deletedAt: null },                    // ← null ga qaytariladi
      { new: true },
    ).exec();

    if (!user) throw new NotFoundException('Topilmadi yoki allaqachon faol');
    return user;
  }

  // Admin uchun: o'chirilganlarni ham ko'rish
  async findAllWithDeleted(): Promise<User[]> {
    return this.userModel.find().exec(); // filter yo'q
  }
}

Mongoose Middleware bilan avtomatlashtirish

Har find da { deletedAt: null } yozishdan charchadingizmi? Mongoose middleware yordamida avtomatik qo'shiladi:

// user.schema.ts — sxemadan keyin
UserSchema.pre(/^find/, function (next) {
  // "this" — joriy query
  this.where({ deletedAt: null }); // ← har find da avtomatik
  next();
});

Endi findAll, findById, findOne — barchasi avtomatik deletedAt: null bilan ishlaydi. Alohida yozish shart emas.


Pagination — sahifalab berish

50 000 ta yozuvni bitta javobda yuborish — server va brauzer uchun ham og'ir. Pagination ma'lumotni bo'laklarga bo'ladi.

Offset-based Pagination

Eng keng tarqalgan usul. page va limit query params orqali:

GET /users?page=2&limit=10
→ 11-20 yozuvlar
// src/users/dto/pagination.dto.ts
import { IsOptional, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';

export class PaginationDto {
  @IsOptional()
  @Type(() => Number)       // query string → number
  @IsInt()
  @Min(1)
  page?: number = 1;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  limit?: number = 10;
}
// users.service.ts
async findAll(dto: PaginationDto) {
  const { page = 1, limit = 10 } = dto;
  const skip = (page - 1) * limit; // ← nechtasini o'tkazish

  const [data, total] = await Promise.all([
    this.userModel
      .find({ deletedAt: null })
      .sort({ createdAt: -1 })
      .skip(skip)   // ← o'tkazib yuborish
      .limit(limit) // ← nechta olish
      .exec(),
    this.userModel.countDocuments({ deletedAt: null }),
  ]);

  return {
    data,
    meta: {
      total,           // jami yozuvlar soni
      page,            // joriy sahifa
      limit,           // sahifada nechta
      totalPages: Math.ceil(total / limit),
      hasNext: page < Math.ceil(total / limit),
      hasPrev: page > 1,
    },
  };
}
// users.controller.ts
@Get()
findAll(@Query() dto: PaginationDto) {
  return this.usersService.findAll(dto);
}

Javob:

{
  "data": [ { "_id": "...", "name": "Jasur" }, ... ],
  "meta": {
    "total": 50000,
    "page": 2,
    "limit": 10,
    "totalPages": 5000,
    "hasNext": true,
    "hasPrev": true
  }
}

Filtering — so'rovni filtrlash

Foydalanuvchilar ro'yxatini isim, email yoki holat bo'yicha filtrlash:

GET /users?name=jasur&isActive=true&sort=createdAt&order=desc
// src/users/dto/filter-user.dto.ts
import { IsOptional, IsString, IsBoolean, IsIn } from 'class-validator';
import { Transform, Type } from 'class-transformer';

export class FilterUserDto extends PaginationDto {
  @IsOptional()
  @IsString()
  name?: string; // qisman qidirish uchun

  @IsOptional()
  @IsString()
  email?: string;

  @IsOptional()
  @Transform(({ value }) => value === 'true') // "true" → true
  @IsBoolean()
  isActive?: boolean;

  @IsOptional()
  @IsIn(['createdAt', 'name', 'email'])
  sort?: string = 'createdAt';

  @IsOptional()
  @IsIn(['asc', 'desc'])
  order?: 'asc' | 'desc' = 'desc';
}
// users.service.ts
async findAll(dto: FilterUserDto) {
  const { page = 1, limit = 10, name, email, isActive, sort = 'createdAt', order = 'desc' } = dto;
  const skip = (page - 1) * limit;

  // Filter qurish
  const filter: any = { deletedAt: null };

  if (name) {
    filter.name = { $regex: name, $options: 'i' }; // ← katta-kichik harfsiz qidirish
  }
  if (email) {
    filter.email = { $regex: email, $options: 'i' };
  }
  if (isActive !== undefined) {
    filter.isActive = isActive;
  }

  const sortObj = { [sort]: order === 'desc' ? -1 : 1 };

  const [data, total] = await Promise.all([
    this.userModel
      .find(filter)
      .sort(sortObj)
      .skip(skip)
      .limit(limit)
      .exec(),
    this.userModel.countDocuments(filter),
  ]);

  return {
    data,
    meta: {
      total,
      page,
      limit,
      totalPages: Math.ceil(total / limit),
      hasNext: page < Math.ceil(total / limit),
      hasPrev: page > 1,
    },
  };
}

Tez-tez uchraydigan xatolar

skip katta bo'lsa sekinlashadi

// ❌ skip(49990) — MongoDB 49990 ta hujjatni sanab o'tadi
.skip(49990).limit(10)

// ✅ Katta ma'lumotlar uchun cursor-based pagination
// Oxirgi elementning _id sini saqlang, keyingisini shundan qidiring:
.find({ _id: { $gt: lastId }, deletedAt: null })
.limit(10)

Query string dan boolean olish

// ❌ query string da "true" — bu string, boolean emas
// isActive === true — hech qachon true bo'lmaydi
const { isActive } = req.query; // "true" string

// ✅ Transform dekoratori yoki qo'lda aylantirish
@Transform(({ value }) => value === 'true')
@IsBoolean()
isActive?: boolean;

countDocuments va find ni parallel ishlatmaslik

// ❌ Ketma-ket — ikkinchisi birinchisi tugaguncha kutadi
const data  = await this.userModel.find(filter)...
const total = await this.userModel.countDocuments(filter);

// ✅ Parallel — ikkalasi birga ishlaydi
const [data, total] = await Promise.all([
  this.userModel.find(filter)...,
  this.userModel.countDocuments(filter),
]);

Xulosa

Soft Delete — deletedAt maydoni bilan ma'lumotni yashirish. O'chirilgan narsa bazada qoladi, tiklash mumkin.

Pagination — skip va limit bilan ma'lumotni bo'laklash. meta obyektida total, page, totalPages qaytariladi.

Filtering — dinamik filter obyekti qurib, regex bilan qisman qidirish.

Eslab qoling:

  • deletedAt: null — faol yozuvlar filtri
  • skip = (page - 1) * limit — o'tkazib yuborish formulasi
  • Promise.all([find, countDocuments]) — parallel so'rovlar
  • $regex + $options: 'i' — katta-kichik harfsiz qidirish
  • @Transform — query string ni to'g'ri tipga aylantirish

Keyingi dars

25-dars: PassportJS — asoslar va strategiyalar