Skip to content

Commit 8b410c9

Browse files
chore: allow soft delete hooks in aggregate
1 parent 16090dc commit 8b410c9

8 files changed

Lines changed: 617 additions & 0 deletions

File tree

src/soft-delete-plugin.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import mongoose, { CallbackError, MongooseQueryMiddleware, SaveOptions } from 'mongoose';
2+
import { overwriteAggregatePipeline } from './utils';
23

34
const QUERY_HOOK_METHODS: MongooseQueryMiddleware[] = [
45
'find',
@@ -35,6 +36,12 @@ export const softDeletePlugin = (schema: mongoose.Schema) => {
3536
},
3637
);
3738

39+
schema.pre('aggregate', function (next) {
40+
if (this.options.skipHook) return next();
41+
overwriteAggregatePipeline(this.pipeline());
42+
next();
43+
});
44+
3845
schema.static('findDeleted', async function () {
3946
return this.find({ isDeleted: true });
4047
});

src/utils.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { PipelineStage } from "mongoose";
2+
3+
type FilterFieldsStage = {
4+
[k: string]: {
5+
$filter: {
6+
input: `$${string}`;
7+
as: string;
8+
cond: { $ne: [string, boolean] }
9+
}
10+
};
11+
};
12+
13+
/**
14+
* Checks if a lookup stage is valid for soft delete filtering
15+
*/
16+
const isValidLookupStage = (lookupStage: PipelineStage.Lookup['$lookup']): boolean => {
17+
return !!(
18+
lookupStage.from &&
19+
lookupStage.localField &&
20+
lookupStage.foreignField &&
21+
lookupStage.as &&
22+
!lookupStage.localField.includes('.') // exclude nested lookups
23+
);
24+
};
25+
26+
/**
27+
* Creates an $addFields stage to filter out soft deleted documents from lookup results
28+
*/
29+
const createSoftDeleteFilterStage = (fieldName: string): { $addFields: FilterFieldsStage } => {
30+
return {
31+
$addFields: {
32+
[fieldName]: {
33+
$filter: {
34+
input: `$${fieldName}`,
35+
as: 'temp',
36+
cond: { $ne: ['$$temp.isDeleted', true] },
37+
},
38+
},
39+
},
40+
};
41+
};
42+
43+
/**
44+
* Processes a $lookup stage and adds soft delete filtering if applicable
45+
*/
46+
const processLookupStage = (
47+
stage: PipelineStage,
48+
pipeline: PipelineStage[],
49+
index: number
50+
): void => {
51+
const lookupStage = stage['$lookup' as keyof typeof stage] as PipelineStage.Lookup['$lookup'];
52+
53+
if (!lookupStage || !isValidLookupStage(lookupStage)) {
54+
return;
55+
}
56+
57+
const { as } = lookupStage;
58+
const filterStage = createSoftDeleteFilterStage(as);
59+
pipeline.splice(index + 1, 0, filterStage);
60+
};
61+
62+
/**
63+
* Processes a $match stage and adds soft delete filtering if needed
64+
*/
65+
const processMatchStage = (stage: PipelineStage, pipeline: PipelineStage[], index: number): void => {
66+
const matchStage = stage['$match' as keyof typeof stage] as PipelineStage.Match['$match'];
67+
68+
if (!matchStage) {
69+
return;
70+
}
71+
72+
// Skip if already filtering for deleted documents
73+
if (matchStage.isDeleted === true) {
74+
return;
75+
}
76+
77+
// Add soft delete filter to existing match conditions
78+
(pipeline[index] as { '$match': PipelineStage.Match['$match'] })['$match'] = {
79+
...matchStage,
80+
isDeleted: false
81+
};
82+
};
83+
84+
/**
85+
* Overwrites aggregation pipeline to handle soft deleted documents
86+
* - Adds filtering for soft deleted documents in $lookup results
87+
* - Ensures $match stages exclude soft deleted documents
88+
*/
89+
export const overwriteAggregatePipeline = (pipeline: PipelineStage[]): PipelineStage[] => {
90+
pipeline.forEach((stage, index) => {
91+
processLookupStage(stage, pipeline, index);
92+
processMatchStage(stage, pipeline, index);
93+
});
94+
95+
return pipeline;
96+
};

0 commit comments

Comments
 (0)