Skip to content

Commit ef24829

Browse files
committed
feat(model): implement soft delete functionality
- Add soft delete feature with full configuration support - softDelete: true for simple enable - softDelete: { enabled, field, type, ttl } for advanced config - Support timestamp (default) and boolean types - Support custom field name (default: deletedAt) - Support TTL index for auto-cleanup - Automatically convert deleteOne/deleteMany to updateOne/updateMany - Mark documents as deleted instead of physical deletion - Return format compatible with delete operations - Auto-filter deleted documents in queries - find/findOne/count automatically exclude deleted documents - findWithDeleted/findOnlyDeleted for querying deleted docs - countWithDeleted/countOnlyDeleted for counting - Restore deleted documents - restore() for single document - restoreMany() for multiple documents - Force physical deletion - forceDelete() to bypass soft delete - forceDeleteMany() for batch deletion - Integration with timestamps - updatedAt auto-updates on soft delete - Works seamlessly with existing timestamps feature - Comprehensive tests (7 passing) - Configuration validation - Different delete types (timestamp/boolean) - Custom field names - Integration with timestamps Related: P1 requirement - soft delete feature
1 parent 444c876 commit ef24829

3 files changed

Lines changed: 547 additions & 1 deletion

File tree

‎lib/model/features/soft-delete.js‎

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
/**
2+
* Soft Delete Feature for Model
3+
*
4+
* Provides soft delete functionality:
5+
* - Mark documents as deleted instead of physical deletion
6+
* - Auto-filter deleted documents in queries
7+
* - Restore deleted documents
8+
* - Force physical deletion
9+
* - TTL index for auto-cleanup
10+
*
11+
* @module lib/model/features/soft-delete
12+
*/
13+
14+
/**
15+
* Parse soft delete configuration
16+
* @param {Object|boolean} config - Soft delete config
17+
* @returns {Object|null} Parsed config or null if disabled
18+
*/
19+
function parseSoftDeleteConfig(config) {
20+
if (!config) return null;
21+
22+
// shorthand: softDelete: true
23+
if (config === true) {
24+
return {
25+
enabled: true,
26+
field: 'deletedAt',
27+
type: 'timestamp',
28+
ttl: null
29+
};
30+
}
31+
32+
// full config: softDelete: { ... }
33+
return {
34+
enabled: config.enabled !== false,
35+
field: config.field || 'deletedAt',
36+
type: config.type || 'timestamp',
37+
ttl: config.ttl || null
38+
};
39+
}
40+
41+
/**
42+
* Get delete value based on type
43+
* @param {string} type - 'timestamp' or 'boolean'
44+
* @returns {Date|boolean} Delete value
45+
*/
46+
function getDeleteValue(type) {
47+
return type === 'boolean' ? true : new Date();
48+
}
49+
50+
/**
51+
* Apply soft delete filter to query
52+
* @param {Object} filter - Original filter
53+
* @param {Object} config - Soft delete config
54+
* @param {Object} options - Query options
55+
* @returns {Object} Modified filter
56+
*/
57+
function applySoftDeleteFilter(filter, config, options = {}) {
58+
if (!config?.enabled) return filter;
59+
60+
const field = config.field;
61+
62+
// Already has explicit deletedAt filter - don't modify
63+
if (filter[field] !== undefined) {
64+
return filter;
65+
}
66+
67+
// withDeleted: include all (no filter)
68+
if (options.withDeleted) {
69+
return filter;
70+
}
71+
72+
// onlyDeleted: only deleted documents
73+
if (options.onlyDeleted) {
74+
return {
75+
...filter,
76+
[field]: { $ne: null }
77+
};
78+
}
79+
80+
// default: only non-deleted documents
81+
return {
82+
...filter,
83+
[field]: null
84+
};
85+
}
86+
87+
/**
88+
* Register soft delete hooks
89+
* @param {Object} modelInstance - Model instance
90+
* @param {Object} config - Soft delete config
91+
*/
92+
function registerSoftDeleteHooks(modelInstance, config) {
93+
if (!config?.enabled) return;
94+
95+
const field = config.field;
96+
const type = config.type;
97+
98+
// Store original deleteOne and deleteMany methods
99+
const originalDeleteOne = modelInstance.collection.deleteOne.bind(modelInstance.collection);
100+
const originalDeleteMany = modelInstance.collection.deleteMany.bind(modelInstance.collection);
101+
102+
// Override deleteOne - convert to updateOne
103+
modelInstance.collection.deleteOne = async function(filter, options = {}) {
104+
// Check if force delete (bypass soft delete)
105+
if (options._forceDelete) {
106+
delete options._forceDelete;
107+
return await originalDeleteOne(filter, options);
108+
}
109+
110+
// Soft delete: convert to updateOne
111+
const updateResult = await modelInstance.collection.updateOne(
112+
filter,
113+
{ $set: { [field]: getDeleteValue(type) } },
114+
options
115+
);
116+
117+
// Convert updateOne result to deleteOne result format
118+
return {
119+
acknowledged: updateResult.acknowledged,
120+
deletedCount: updateResult.modifiedCount
121+
};
122+
};
123+
124+
// Override deleteMany - convert to updateMany
125+
modelInstance.collection.deleteMany = async function(filter, options = {}) {
126+
// Check if force delete (bypass soft delete)
127+
if (options._forceDelete) {
128+
delete options._forceDelete;
129+
return await originalDeleteMany(filter, options);
130+
}
131+
132+
// Soft delete: convert to updateMany
133+
const updateResult = await modelInstance.collection.updateMany(
134+
filter,
135+
{ $set: { [field]: getDeleteValue(type) } },
136+
options
137+
);
138+
139+
// Convert updateMany result to deleteMany result format
140+
return {
141+
acknowledged: updateResult.acknowledged,
142+
deletedCount: updateResult.modifiedCount
143+
};
144+
};
145+
146+
// Store original find/findOne/count methods
147+
const originalFind = modelInstance.collection.find.bind(modelInstance.collection);
148+
const originalFindOne = modelInstance.collection.findOne.bind(modelInstance.collection);
149+
const originalCount = modelInstance.collection.count.bind(modelInstance.collection);
150+
151+
// Override find - auto-filter deleted
152+
modelInstance.collection.find = async function(filter = {}, options = {}) {
153+
const modifiedFilter = applySoftDeleteFilter(filter, config, options);
154+
return await originalFind(modifiedFilter, options);
155+
};
156+
157+
// Override findOne - auto-filter deleted
158+
modelInstance.collection.findOne = async function(filter = {}, options = {}) {
159+
const modifiedFilter = applySoftDeleteFilter(filter, config, options);
160+
return await originalFindOne(modifiedFilter, options);
161+
};
162+
163+
// Override count - auto-filter deleted
164+
modelInstance.collection.count = async function(filter = {}, options = {}) {
165+
const modifiedFilter = applySoftDeleteFilter(filter, config, options);
166+
return await originalCount(modifiedFilter, options);
167+
};
168+
}
169+
170+
/**
171+
* Add soft delete methods to ModelInstance
172+
* @param {Object} modelInstance - Model instance
173+
* @param {Object} config - Soft delete config
174+
*/
175+
function addSoftDeleteMethods(modelInstance, config) {
176+
if (!config?.enabled) return;
177+
178+
const field = config.field;
179+
180+
/**
181+
* Find documents including deleted ones
182+
* @param {Object} filter - Query filter
183+
* @param {Object} options - Query options
184+
* @returns {Promise<Array>} Documents
185+
*/
186+
modelInstance.findWithDeleted = async function(filter = {}, options = {}) {
187+
return await this.find(filter, { ...options, withDeleted: true });
188+
};
189+
190+
/**
191+
* Find only deleted documents
192+
* @param {Object} filter - Query filter
193+
* @param {Object} options - Query options
194+
* @returns {Promise<Array>} Deleted documents
195+
*/
196+
modelInstance.findOnlyDeleted = async function(filter = {}, options = {}) {
197+
return await this.find(filter, { ...options, onlyDeleted: true });
198+
};
199+
200+
/**
201+
* Find one document including deleted ones
202+
* @param {Object} filter - Query filter
203+
* @param {Object} options - Query options
204+
* @returns {Promise<Object|null>} Document
205+
*/
206+
modelInstance.findOneWithDeleted = async function(filter = {}, options = {}) {
207+
return await this.findOne(filter, { ...options, withDeleted: true });
208+
};
209+
210+
/**
211+
* Find one deleted document
212+
* @param {Object} filter - Query filter
213+
* @param {Object} options - Query options
214+
* @returns {Promise<Object|null>} Deleted document
215+
*/
216+
modelInstance.findOneOnlyDeleted = async function(filter = {}, options = {}) {
217+
return await this.findOne(filter, { ...options, onlyDeleted: true });
218+
};
219+
220+
/**
221+
* Count documents including deleted ones
222+
* @param {Object} filter - Query filter
223+
* @param {Object} options - Query options
224+
* @returns {Promise<number>} Count
225+
*/
226+
modelInstance.countWithDeleted = async function(filter = {}, options = {}) {
227+
return await this.count(filter, { ...options, withDeleted: true });
228+
};
229+
230+
/**
231+
* Count only deleted documents
232+
* @param {Object} filter - Query filter
233+
* @param {Object} options - Query options
234+
* @returns {Promise<number>} Count
235+
*/
236+
modelInstance.countOnlyDeleted = async function(filter = {}, options = {}) {
237+
return await this.count(filter, { ...options, onlyDeleted: true });
238+
};
239+
240+
/**
241+
* Restore a deleted document
242+
* @param {Object} filter - Query filter
243+
* @param {Object} options - Update options
244+
* @returns {Promise<Object>} Update result
245+
*/
246+
modelInstance.restore = async function(filter, options) {
247+
// Add deleted filter to ensure we only restore deleted documents
248+
const restoreFilter = {
249+
...filter,
250+
[field]: { $ne: null }
251+
};
252+
253+
return await this.updateOne(
254+
restoreFilter,
255+
{ $unset: { [field]: 1 } },
256+
options
257+
);
258+
};
259+
260+
/**
261+
* Restore multiple deleted documents
262+
* @param {Object} filter - Query filter
263+
* @param {Object} options - Update options
264+
* @returns {Promise<Object>} Update result
265+
*/
266+
modelInstance.restoreMany = async function(filter, options) {
267+
// Add deleted filter to ensure we only restore deleted documents
268+
const restoreFilter = {
269+
...filter,
270+
[field]: { $ne: null }
271+
};
272+
273+
return await this.updateMany(
274+
restoreFilter,
275+
{ $unset: { [field]: 1 } },
276+
options
277+
);
278+
};
279+
280+
/**
281+
* Force physical deletion (bypass soft delete)
282+
* @param {Object} filter - Query filter
283+
* @param {Object} options - Delete options
284+
* @returns {Promise<Object>} Delete result
285+
*/
286+
modelInstance.forceDelete = async function(filter, options = {}) {
287+
// Set flag to bypass soft delete
288+
return await this.collection.deleteOne(filter, { ...options, _forceDelete: true });
289+
};
290+
291+
/**
292+
* Force physical deletion of multiple documents
293+
* @param {Object} filter - Query filter
294+
* @param {Object} options - Delete options
295+
* @returns {Promise<Object>} Delete result
296+
*/
297+
modelInstance.forceDeleteMany = async function(filter, options = {}) {
298+
// Set flag to bypass soft delete
299+
return await this.collection.deleteMany(filter, { ...options, _forceDelete: true });
300+
};
301+
}
302+
303+
/**
304+
* Add TTL index for soft deleted documents
305+
* @param {Object} modelInstance - Model instance
306+
* @param {Object} config - Soft delete config
307+
*/
308+
function addTTLIndex(modelInstance, config) {
309+
if (!config?.enabled || !config.ttl) return;
310+
311+
// Add TTL index to automatically clean up old deleted documents
312+
modelInstance.indexes.push({
313+
key: { [config.field]: 1 },
314+
expireAfterSeconds: config.ttl,
315+
name: `${config.field}_ttl`
316+
});
317+
}
318+
319+
/**
320+
* Setup soft delete feature for a model
321+
* @param {Object} modelInstance - Model instance
322+
* @param {Object|boolean} config - Soft delete config
323+
*/
324+
function setupSoftDelete(modelInstance, config) {
325+
const parsedConfig = parseSoftDeleteConfig(config);
326+
327+
if (!parsedConfig) return;
328+
329+
// Store config on model instance
330+
modelInstance.softDeleteConfig = parsedConfig;
331+
332+
// Register hooks
333+
registerSoftDeleteHooks(modelInstance, parsedConfig);
334+
335+
// Add methods
336+
addSoftDeleteMethods(modelInstance, parsedConfig);
337+
338+
// Add TTL index
339+
addTTLIndex(modelInstance, parsedConfig);
340+
}
341+
342+
module.exports = {
343+
setupSoftDelete,
344+
parseSoftDeleteConfig,
345+
applySoftDeleteFilter,
346+
getDeleteValue
347+
};
348+

‎lib/model/index.js‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,8 +338,14 @@ class ModelInstance {
338338
this._instanceMethods = {};
339339
}
340340

341+
// ========== 软删除功能 ==========
342+
const { setupSoftDelete } = require('./features/soft-delete');
343+
setupSoftDelete(this, definition.options?.softDelete);
344+
341345
// ========== 自动创建索引 ==========
342-
if (Array.isArray(definition.indexes) && definition.indexes.length > 0) {
346+
// 注意:indexes 数组可能已被 setupSoftDelete 添加 TTL 索引
347+
this.indexes = definition.indexes || [];
348+
if (Array.isArray(this.indexes) && this.indexes.length > 0) {
343349
// 延迟执行,避免阻塞初始化
344350
setImmediate(() => {
345351
this._createIndexes().catch(err => {

0 commit comments

Comments
 (0)