SoftDeletableRepository
Tổng quan
SoftDeletableRepository là một lớp repository cơ sở triển khai mẫu xóa mềm. Thay vì xóa vật lý các bản ghi khỏi cơ sở dữ liệu, các thao tác xóa đặt dấu thời gian deletedAt. Các bản ghi có thể được khôi phục sau đó hoặc xóa cứng vĩnh viễn.
Tất cả repository thực thể nghiệp vụ trong hệ thống BANA đều kế thừa lớp này.
Nguồn: packages/core/src/base/repository/soft-deletable.repository.ts
Xem tham chiếu IGNIS Repositories để biết chi tiết về lớp cha
DefaultCRUDRepository.
Định nghĩa Lớp
import { DefaultCRUDRepository, IExtraOptions, TTableObject, TTableInsert } from '@venizia/ignis';
export type TSoftDeletableTableSchema = TTableSchemaWithId & {
deletedAt: AnyPgColumn<{ data: Date | null }>;
};
export class SoftDeletableRepository<
EntitySchema extends TSoftDeletableTableSchema,
DataObject extends TTableObject<EntitySchema> = TTableObject<EntitySchema>,
PersistObject extends TTableInsert<EntitySchema> = TTableInsert<EntitySchema>,
ExtraOptions extends IExtraOptions = IExtraOptions,
> extends DefaultCRUDRepository<EntitySchema, DataObject, PersistObject, ExtraOptions> {
// Read (overridden)
findById(opts): Promise<R | null>; // with isStrict option
// Delete (overridden — soft delete by default)
deleteById(opts): Promise<TCount & { data: R }>;
deleteAll(opts): Promise<TCount & { data: R[] }>;
deleteBy(opts): Promise<TCount & { data: R[] }>;
// Restore (new methods)
restoreById(opts): Promise<TCount & { data: R }>;
restoreAll(opts): Promise<TCount & { data: R[] }>;
restoreBy(opts): Promise<TCount & { data: R[] }>;
}Phân cấp Kế thừa
Sơ đồ Trạng thái Bản ghi
Luồng Xóa Mềm
Yêu cầu Model
Để một model hoạt động với SoftDeletableRepository, nó phải đáp ứng ba yêu cầu:
1. Schema phải bao gồm cột deletedAt
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { generateCommonColumnDefs } from '@nx/core';
export const CategorySchema = pgTable('Category', {
...generateCommonColumnDefs(), // includes id, createdAt, modifiedAt, deletedAt
name: text('name').notNull(),
// ...
});2. Model phải đặt defaultFilter để loại trừ bản ghi đã xóa
import { BaseEntity, model } from '@venizia/ignis';
@model({
type: 'entity',
settings: {
defaultFilter: { where: { deletedAt: null } },
hiddenProperties: ['createdAt', 'modifiedAt', 'deletedAt'],
},
})
export class Category extends BaseEntity<typeof CategorySchema> {
static override TABLE_NAME = 'Category';
static override schema = CategorySchema;
}3. Repository phải kế thừa SoftDeletableRepository
import { repository } from '@venizia/ignis';
import { SoftDeletableRepository } from '@nx/core';
import { PostgresCoreDataSource } from '@/datasources';
@repository({ dataSource: PostgresCoreDataSource, model: Category })
export class CategoryRepository extends SoftDeletableRepository<
typeof CategorySchema,
TCategory,
TCategoryPersist
> {}Tham chiếu API
Thao tác Đọc
findById
Ghi đè phương thức cha để thêm chế độ nghiêm ngặt. Khi isStrict: true, lỗi 404 Not Found được ném ra nếu bản ghi không tồn tại.
// Non-strict (default) — returns null if not found
const product = await repository.findById({
id: 'product-123',
});
// Returns: TProduct | null
// Strict mode — throws 404 if not found
const product = await repository.findById({
id: 'product-123',
options: { isStrict: true },
});
// Returns: TProduct (or throws HTTP 404)Chữ ký phương thức:
// Non-strict overload
findById<R = DataObject>(opts: {
id: IdType;
filter?: Omit<TFilter<DataObject>, 'where'>;
options?: ExtraOptions & { isStrict?: false };
}): Promise<TNullable<R>>;
// Strict overload
findById<R = DataObject>(opts: {
id: IdType;
filter?: Omit<TFilter<DataObject>, 'where'>;
options?: ExtraOptions & { isStrict?: true };
}): Promise<R>;Thao tác Xóa
Tất cả phương thức xóa thực hiện xóa mềm theo mặc định (đặt deletedAt = new Date()). Truyền shouldHardDelete: true để xóa vĩnh viễn bản ghi thay thế.
deleteById
Xóa mềm một bản ghi theo ID.
// Soft delete (default)
const result = await repository.deleteById({
id: 'product-123',
});
// result: { count: 1, data: { id: 'product-123', deletedAt: '2026-01-20T...' } }
// Without returning data
const result = await repository.deleteById({
id: 'product-123',
options: { shouldReturn: false },
});
// result: { count: 1, data: null }
// Hard delete (permanent removal)
const result = await repository.deleteById({
id: 'product-123',
options: { shouldHardDelete: true },
});Chữ ký phương thức:
deleteById(opts: {
id: IdType;
options: ExtraOptions & { shouldReturn: false; shouldHardDelete?: boolean };
}): Promise<TCount & { data: undefined | null }>;
deleteById<R = DataObject>(opts: {
id: IdType;
options?: ExtraOptions & { shouldReturn?: true; shouldHardDelete?: boolean };
}): Promise<TCount & { data: R }>;deleteAll
Xóa mềm tất cả bản ghi khớp với bộ lọc where.
// Soft delete by filter
const result = await repository.deleteAll({
where: { merchantId: 'merchant-123' },
});
// result: { count: 5, data: [...softDeletedRecords] }
// Without filter (requires force: true)
const result = await repository.deleteAll({
where: {},
options: { force: true },
});
// Hard delete
const result = await repository.deleteAll({
where: { status: 'archived' },
options: { shouldHardDelete: true },
});Chữ ký phương thức:
deleteAll(opts: {
where?: TWhere<DataObject>;
options: ExtraOptions & { shouldReturn: false; force?: boolean; shouldHardDelete?: boolean };
}): Promise<TCount & { data: undefined | null }>;
deleteAll<R = DataObject>(opts: {
where?: TWhere<DataObject>;
options?: ExtraOptions & { shouldReturn?: true; force?: boolean; shouldHardDelete?: boolean };
}): Promise<TCount & { data: Array<R> }>;deleteBy
Xóa mềm các bản ghi khớp với điều kiện cụ thể. Hành vi giống hệt deleteAll nhưng tham số where là bắt buộc.
const result = await repository.deleteBy({
where: { status: 'inactive', merchantId: 'merchant-123' },
});Chữ ký phương thức:
deleteBy(opts: {
where: TWhere<DataObject>;
options: ExtraOptions & { shouldReturn: false; force?: boolean; shouldHardDelete?: boolean };
}): Promise<TCount & { data: undefined | null }>;
deleteBy<R = DataObject>(opts: {
where: TWhere<DataObject>;
options?: ExtraOptions & { shouldReturn?: true; force?: boolean; shouldHardDelete?: boolean };
}): Promise<TCount & { data: Array<R> }>;Thao tác Khôi phục
Các phương thức khôi phục đặt deletedAt = null trên các bản ghi đã xóa mềm. Chúng sử dụng shouldSkipDefaultFilter: true nội bộ để có thể tìm các bản ghi bị loại trừ bởi defaultFilter.
restoreById
Khôi phục một bản ghi đã xóa mềm theo ID.
const result = await repository.restoreById({
id: 'product-123',
});
// result: { count: 1, data: { id: 'product-123', deletedAt: null, ... } }
// Without returning data
const result = await repository.restoreById({
id: 'product-123',
options: { shouldReturn: false },
});Chữ ký phương thức:
restoreById<R = DataObject>(opts: {
id: IdType;
options?: ExtraOptions & { shouldReturn?: boolean };
}): Promise<TCount & { data: TNullable<R> }>;restoreAll
Khôi phục tất cả bản ghi đã xóa mềm khớp với bộ lọc.
const result = await repository.restoreAll({
where: { merchantId: 'merchant-123' },
});
// Restores all soft-deleted records for that merchant
// Without filter (requires force: true)
const result = await repository.restoreAll({
where: {},
options: { force: true },
});Chữ ký phương thức:
restoreAll<R = DataObject>(opts: {
where?: TWhere<DataObject>;
options?: ExtraOptions & { shouldReturn?: boolean; force?: boolean };
}): Promise<TCount & { data: TNullable<Array<R>> }>;restoreBy
Ủy quyền cho restoreAll. Được cung cấp để nhất quán API với deleteBy.
const result = await repository.restoreBy({
where: { merchantId: 'merchant-123', status: 'active' },
});Chữ ký phương thức:
restoreBy<R = DataObject>(opts: {
where: TWhere<DataObject>;
options?: ExtraOptions & { shouldReturn?: boolean; force?: boolean };
}): Promise<TCount & { data: TNullable<Array<R>> }>;Tóm tắt API
| Phương thức | Hành vi Mặc định | Tùy chọn Chính |
|---|---|---|
findById | Trả về null nếu không tìm thấy | isStrict: true ném lỗi 404 |
deleteById | Đặt deletedAt = new Date() | shouldHardDelete: true để xóa vĩnh viễn |
deleteAll | Đặt deletedAt = new Date() | shouldHardDelete, force |
deleteBy | Đặt deletedAt = new Date() | shouldHardDelete, force |
restoreById | Đặt deletedAt = null | shouldReturn |
restoreAll | Đặt deletedAt = null | shouldReturn, force |
restoreBy | Ủy quyền cho restoreAll | shouldReturn, force |
Ví dụ Sử dụng
Định nghĩa Repository Hoàn chỉnh
import { repository } from '@venizia/ignis';
import { SoftDeletableRepository } from '@nx/core';
import { PostgresCoreDataSource } from '@/datasources';
import { Category, TCategory, TCategoryPersist, TCategorySchema } from '@nx/core/models';
@repository({ dataSource: PostgresCoreDataSource, model: Category })
export class CategoryRepository extends SoftDeletableRepository<
TCategorySchema,
TCategory,
TCategoryPersist
> {
// Custom query — defaultFilter automatically excludes deleted records
async findByMerchant(merchantId: string): Promise<TCategory[]> {
return this.find({
filter: { where: { merchantId } },
});
}
// Query including soft-deleted records
async findAllIncludingDeleted(merchantId: string): Promise<TCategory[]> {
return this.find({
filter: { where: { merchantId } },
options: { shouldSkipDefaultFilter: true },
});
}
}Service với Xóa Mềm
import { inject, BaseService } from '@venizia/ignis';
export class CategoryService extends BaseService {
constructor(
@inject({ key: 'repositories.CategoryRepository' })
private categoryRepository: CategoryRepository,
) {
super({ scope: CategoryService.name });
}
async deleteCategory(id: string) {
// Soft delete — sets deletedAt, record remains in DB
return this.categoryRepository.deleteById({ id });
}
async restoreCategory(id: string) {
// Restore — clears deletedAt
return this.categoryRepository.restoreById({ id });
}
async permanentlyDelete(id: string) {
// Hard delete — physically removes the record
return this.categoryRepository.deleteById({
id,
options: { shouldHardDelete: true },
});
}
async getCategoryOrFail(id: string) {
// Strict mode — throws 404 if not found or soft-deleted
return this.categoryRepository.findById({
id,
options: { isStrict: true },
});
}
}Transaction với Xóa Mềm
await this.categoryRepository.dataSource.withTransaction(async (tx) => {
// Soft delete parent
await this.categoryRepository.deleteById({
id: parentId,
options: { transaction: tx },
});
// Soft delete all children
await this.categoryRepository.deleteAll({
where: { parentId },
options: { transaction: tx },
});
});Hành vi Truy vấn
Bộ lọc Mặc định
Khi defaultFilter: { where: { deletedAt: null } } được cấu hình trên model, tất cả truy vấn tự động loại trừ các bản ghi đã xóa mềm:
// Automatically excludes deleted records
const categories = await repository.find({
filter: { where: { merchantId: 'merchant-123' } },
});
// SQL: SELECT * FROM "Category" WHERE merchant_id = ? AND deleted_at IS NULLBỏ qua Bộ lọc Mặc định
Sử dụng shouldSkipDefaultFilter: true để bao gồm các bản ghi đã xóa mềm:
// Includes all records regardless of deletedAt
const all = await repository.find({
filter: { where: { merchantId: 'merchant-123' } },
options: { shouldSkipDefaultFilter: true },
});
// SQL: SELECT * FROM "Category" WHERE merchant_id = ?Truy vấn Chỉ Bản ghi Đã xóa
const deleted = await repository.find({
filter: {
where: {
merchantId: 'merchant-123',
deletedAt: { neq: null },
},
},
options: { shouldSkipDefaultFilter: true },
});Thực hành Tốt nhất
1. Luôn kế thừa SoftDeletableRepository cho thực thể nghiệp vụ
// Correct
@repository({ dataSource: PostgresCoreDataSource, model: Product })
export class ProductRepository extends SoftDeletableRepository<...> {}
// Avoid — no soft-delete, no restore, no isStrict
@repository({ dataSource: PostgresCoreDataSource, model: Product })
export class ProductRepository extends DefaultCRUDRepository<...> {}2. Luôn cấu hình defaultFilter trên model
@model({
type: 'entity',
settings: {
defaultFilter: { where: { deletedAt: null } },
hiddenProperties: ['createdAt', 'modifiedAt', 'deletedAt'],
},
})
export class Product extends BaseEntity<typeof ProductSchema> {}3. Ưu tiên xóa mềm; dành xóa cứng cho dọn dẹp
// Normal operation — soft delete
await repository.deleteById({ id });
// Cleanup job — hard delete old soft-deleted records
await repository.deleteAll({
where: { deletedAt: { lt: thirtyDaysAgo } },
options: { shouldHardDelete: true, shouldSkipDefaultFilter: true, force: true },
});4. Sử dụng isStrict cho tra cứu cấp controller
// Throws 404 automatically — no manual null checking needed
const product = await repository.findById({
id: params.id,
options: { isStrict: true },
});