@classytic/mongokit 3.2.1 → 3.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +592 -194
- package/dist/actions/index.d.mts +9 -0
- package/dist/actions/index.mjs +15 -0
- package/dist/aggregate-BAi4Do-X.mjs +767 -0
- package/dist/aggregate-CCHI7F51.d.mts +269 -0
- package/dist/ai/index.d.mts +125 -0
- package/dist/ai/index.mjs +203 -0
- package/dist/cache-keys-C8Z9B5sw.mjs +204 -0
- package/dist/chunk-DQk6qfdC.mjs +18 -0
- package/dist/create-BuO6xt0v.mjs +55 -0
- package/dist/custom-id.plugin-BmK0SjR9.d.mts +1039 -0
- package/dist/custom-id.plugin-m0VW6yYm.mjs +2169 -0
- package/dist/index.d.mts +1049 -0
- package/dist/index.mjs +2052 -0
- package/dist/limits-DsNeCx4D.mjs +299 -0
- package/dist/logger-D8ily-PP.mjs +51 -0
- package/dist/mongooseToJsonSchema-COdDEkIJ.mjs +317 -0
- package/dist/{mongooseToJsonSchema-CaRF_bCN.d.ts → mongooseToJsonSchema-Wbvjfwkn.d.mts} +16 -89
- package/dist/pagination/PaginationEngine.d.mts +93 -0
- package/dist/pagination/PaginationEngine.mjs +196 -0
- package/dist/plugins/index.d.mts +3 -0
- package/dist/plugins/index.mjs +3 -0
- package/dist/types-D-gploPr.d.mts +1241 -0
- package/dist/utils/{index.d.ts → index.d.mts} +14 -21
- package/dist/utils/index.mjs +5 -0
- package/package.json +21 -21
- package/dist/actions/index.d.ts +0 -3
- package/dist/actions/index.js +0 -5
- package/dist/ai/index.d.ts +0 -175
- package/dist/ai/index.js +0 -206
- package/dist/chunks/chunk-2ZN65ZOP.js +0 -93
- package/dist/chunks/chunk-44KXLGPO.js +0 -388
- package/dist/chunks/chunk-5G42WJHC.js +0 -737
- package/dist/chunks/chunk-B64F5ZWE.js +0 -1226
- package/dist/chunks/chunk-GZBKEPVE.js +0 -46
- package/dist/chunks/chunk-JWUAVZ3L.js +0 -8
- package/dist/chunks/chunk-UE2IEXZJ.js +0 -306
- package/dist/chunks/chunk-URLJFIR7.js +0 -22
- package/dist/chunks/chunk-WSFCRVEQ.js +0 -7
- package/dist/index-BDn5fSTE.d.ts +0 -516
- package/dist/index.d.ts +0 -1422
- package/dist/index.js +0 -1893
- package/dist/pagination/PaginationEngine.d.ts +0 -117
- package/dist/pagination/PaginationEngine.js +0 -3
- package/dist/plugins/index.d.ts +0 -922
- package/dist/plugins/index.js +0 -6
- package/dist/types-Jni1KgkP.d.ts +0 -780
- package/dist/utils/index.js +0 -5
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
import { t as __exportAll } from "./chunk-DQk6qfdC.mjs";
|
|
2
|
+
import { i as createError, r as warn } from "./logger-D8ily-PP.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/actions/read.ts
|
|
5
|
+
var read_exports = /* @__PURE__ */ __exportAll({
|
|
6
|
+
count: () => count,
|
|
7
|
+
exists: () => exists,
|
|
8
|
+
getAll: () => getAll,
|
|
9
|
+
getById: () => getById,
|
|
10
|
+
getByQuery: () => getByQuery,
|
|
11
|
+
getOrCreate: () => getOrCreate,
|
|
12
|
+
tryGetByQuery: () => tryGetByQuery
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* Parse populate specification into consistent format
|
|
16
|
+
*/
|
|
17
|
+
function parsePopulate$1(populate) {
|
|
18
|
+
if (!populate) return [];
|
|
19
|
+
if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
|
|
20
|
+
if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
|
|
21
|
+
return [populate];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Get document by ID
|
|
25
|
+
*
|
|
26
|
+
* @param Model - Mongoose model
|
|
27
|
+
* @param id - Document ID
|
|
28
|
+
* @param options - Query options
|
|
29
|
+
* @returns Document or null
|
|
30
|
+
* @throws Error if document not found and throwOnNotFound is true
|
|
31
|
+
*/
|
|
32
|
+
async function getById(Model, id, options = {}) {
|
|
33
|
+
const query = options.query ? Model.findOne({
|
|
34
|
+
_id: id,
|
|
35
|
+
...options.query
|
|
36
|
+
}) : Model.findById(id);
|
|
37
|
+
if (options.select) query.select(options.select);
|
|
38
|
+
if (options.populate) query.populate(parsePopulate$1(options.populate));
|
|
39
|
+
if (options.lean) query.lean();
|
|
40
|
+
if (options.session) query.session(options.session);
|
|
41
|
+
if (options.readPreference) query.read(options.readPreference);
|
|
42
|
+
const document = await query.exec();
|
|
43
|
+
if (!document && options.throwOnNotFound !== false) throw createError(404, "Document not found");
|
|
44
|
+
return document;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Get document by query
|
|
48
|
+
*
|
|
49
|
+
* @param Model - Mongoose model
|
|
50
|
+
* @param query - MongoDB query
|
|
51
|
+
* @param options - Query options
|
|
52
|
+
* @returns Document or null
|
|
53
|
+
* @throws Error if document not found and throwOnNotFound is true
|
|
54
|
+
*/
|
|
55
|
+
async function getByQuery(Model, query, options = {}) {
|
|
56
|
+
const mongoQuery = Model.findOne(query);
|
|
57
|
+
if (options.select) mongoQuery.select(options.select);
|
|
58
|
+
if (options.populate) mongoQuery.populate(parsePopulate$1(options.populate));
|
|
59
|
+
if (options.lean) mongoQuery.lean();
|
|
60
|
+
if (options.session) mongoQuery.session(options.session);
|
|
61
|
+
if (options.readPreference) mongoQuery.read(options.readPreference);
|
|
62
|
+
const document = await mongoQuery.exec();
|
|
63
|
+
if (!document && options.throwOnNotFound !== false) throw createError(404, "Document not found");
|
|
64
|
+
return document;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Get document by query without throwing (returns null if not found)
|
|
68
|
+
*/
|
|
69
|
+
async function tryGetByQuery(Model, query, options = {}) {
|
|
70
|
+
return getByQuery(Model, query, {
|
|
71
|
+
...options,
|
|
72
|
+
throwOnNotFound: false
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Get all documents (basic query without pagination)
|
|
77
|
+
* For pagination, use Repository.paginate() or Repository.stream()
|
|
78
|
+
*/
|
|
79
|
+
async function getAll(Model, query = {}, options = {}) {
|
|
80
|
+
let mongoQuery = Model.find(query);
|
|
81
|
+
if (options.select) mongoQuery = mongoQuery.select(options.select);
|
|
82
|
+
if (options.populate) mongoQuery = mongoQuery.populate(parsePopulate$1(options.populate));
|
|
83
|
+
if (options.sort) mongoQuery = mongoQuery.sort(options.sort);
|
|
84
|
+
if (options.limit) mongoQuery = mongoQuery.limit(options.limit);
|
|
85
|
+
if (options.skip) mongoQuery = mongoQuery.skip(options.skip);
|
|
86
|
+
mongoQuery = mongoQuery.lean(options.lean !== false);
|
|
87
|
+
if (options.session) mongoQuery = mongoQuery.session(options.session);
|
|
88
|
+
if (options.readPreference) mongoQuery = mongoQuery.read(options.readPreference);
|
|
89
|
+
return mongoQuery.exec();
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Get or create document (upsert)
|
|
93
|
+
*/
|
|
94
|
+
async function getOrCreate(Model, query, createData, options = {}) {
|
|
95
|
+
return Model.findOneAndUpdate(query, { $setOnInsert: createData }, {
|
|
96
|
+
upsert: true,
|
|
97
|
+
returnDocument: "after",
|
|
98
|
+
runValidators: true,
|
|
99
|
+
session: options.session,
|
|
100
|
+
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Count documents matching query
|
|
105
|
+
*/
|
|
106
|
+
async function count(Model, query = {}, options = {}) {
|
|
107
|
+
const q = Model.countDocuments(query).session(options.session ?? null);
|
|
108
|
+
if (options.readPreference) q.read(options.readPreference);
|
|
109
|
+
return q;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Check if document exists
|
|
113
|
+
*/
|
|
114
|
+
async function exists(Model, query, options = {}) {
|
|
115
|
+
const q = Model.exists(query).session(options.session ?? null);
|
|
116
|
+
if (options.readPreference) q.read(options.readPreference);
|
|
117
|
+
return q;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/actions/update.ts
|
|
122
|
+
var update_exports = /* @__PURE__ */ __exportAll({
|
|
123
|
+
increment: () => increment,
|
|
124
|
+
pullFromArray: () => pullFromArray,
|
|
125
|
+
pushToArray: () => pushToArray,
|
|
126
|
+
update: () => update,
|
|
127
|
+
updateByQuery: () => updateByQuery,
|
|
128
|
+
updateMany: () => updateMany,
|
|
129
|
+
updateWithConstraints: () => updateWithConstraints,
|
|
130
|
+
updateWithValidation: () => updateWithValidation
|
|
131
|
+
});
|
|
132
|
+
function assertUpdatePipelineAllowed(update, updatePipeline) {
|
|
133
|
+
if (Array.isArray(update) && updatePipeline !== true) throw createError(400, "Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates.");
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Parse populate specification into consistent format
|
|
137
|
+
*/
|
|
138
|
+
function parsePopulate(populate) {
|
|
139
|
+
if (!populate) return [];
|
|
140
|
+
if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
|
|
141
|
+
if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
|
|
142
|
+
return [populate];
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Update by ID
|
|
146
|
+
*/
|
|
147
|
+
async function update(Model, id, data, options = {}) {
|
|
148
|
+
assertUpdatePipelineAllowed(data, options.updatePipeline);
|
|
149
|
+
const query = {
|
|
150
|
+
_id: id,
|
|
151
|
+
...options.query
|
|
152
|
+
};
|
|
153
|
+
const document = await Model.findOneAndUpdate(query, data, {
|
|
154
|
+
returnDocument: "after",
|
|
155
|
+
runValidators: true,
|
|
156
|
+
session: options.session,
|
|
157
|
+
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
158
|
+
}).select(options.select || "").populate(parsePopulate(options.populate)).lean(options.lean ?? false);
|
|
159
|
+
if (!document) throw createError(404, "Document not found");
|
|
160
|
+
return document;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Update with query constraints (optimized)
|
|
164
|
+
* Returns null if constraints not met (not an error)
|
|
165
|
+
*/
|
|
166
|
+
async function updateWithConstraints(Model, id, data, constraints = {}, options = {}) {
|
|
167
|
+
assertUpdatePipelineAllowed(data, options.updatePipeline);
|
|
168
|
+
const query = {
|
|
169
|
+
_id: id,
|
|
170
|
+
...constraints
|
|
171
|
+
};
|
|
172
|
+
return await Model.findOneAndUpdate(query, data, {
|
|
173
|
+
returnDocument: "after",
|
|
174
|
+
runValidators: true,
|
|
175
|
+
session: options.session,
|
|
176
|
+
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
177
|
+
}).select(options.select || "").populate(parsePopulate(options.populate)).lean(options.lean ?? false);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Update with validation (smart optimization)
|
|
181
|
+
* 1-query on success, 2-queries for detailed errors
|
|
182
|
+
*/
|
|
183
|
+
async function updateWithValidation(Model, id, data, validationOptions = {}, options = {}) {
|
|
184
|
+
const { buildConstraints, validateUpdate } = validationOptions;
|
|
185
|
+
assertUpdatePipelineAllowed(data, options.updatePipeline);
|
|
186
|
+
if (buildConstraints) {
|
|
187
|
+
const document = await updateWithConstraints(Model, id, data, buildConstraints(data), options);
|
|
188
|
+
if (document) return {
|
|
189
|
+
success: true,
|
|
190
|
+
data: document
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const existing = await Model.findById(id).select(options.select || "").lean();
|
|
194
|
+
if (!existing) return {
|
|
195
|
+
success: false,
|
|
196
|
+
error: {
|
|
197
|
+
code: 404,
|
|
198
|
+
message: "Document not found"
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
if (validateUpdate) {
|
|
202
|
+
const validation = validateUpdate(existing, data);
|
|
203
|
+
if (!validation.valid) return {
|
|
204
|
+
success: false,
|
|
205
|
+
error: {
|
|
206
|
+
code: 403,
|
|
207
|
+
message: validation.message || "Update not allowed",
|
|
208
|
+
violations: validation.violations
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
success: true,
|
|
214
|
+
data: await update(Model, id, data, options)
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Update many documents
|
|
219
|
+
*/
|
|
220
|
+
async function updateMany(Model, query, data, options = {}) {
|
|
221
|
+
assertUpdatePipelineAllowed(data, options.updatePipeline);
|
|
222
|
+
const result = await Model.updateMany(query, data, {
|
|
223
|
+
runValidators: true,
|
|
224
|
+
session: options.session,
|
|
225
|
+
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
226
|
+
});
|
|
227
|
+
return {
|
|
228
|
+
matchedCount: result.matchedCount,
|
|
229
|
+
modifiedCount: result.modifiedCount
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Update by query
|
|
234
|
+
*/
|
|
235
|
+
async function updateByQuery(Model, query, data, options = {}) {
|
|
236
|
+
assertUpdatePipelineAllowed(data, options.updatePipeline);
|
|
237
|
+
const document = await Model.findOneAndUpdate(query, data, {
|
|
238
|
+
returnDocument: "after",
|
|
239
|
+
runValidators: true,
|
|
240
|
+
session: options.session,
|
|
241
|
+
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
242
|
+
}).select(options.select || "").populate(parsePopulate(options.populate)).lean(options.lean ?? false);
|
|
243
|
+
if (!document && options.throwOnNotFound !== false) throw createError(404, "Document not found");
|
|
244
|
+
return document;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Increment field
|
|
248
|
+
*/
|
|
249
|
+
async function increment(Model, id, field, value = 1, options = {}) {
|
|
250
|
+
return update(Model, id, { $inc: { [field]: value } }, options);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Push to array
|
|
254
|
+
*/
|
|
255
|
+
async function pushToArray(Model, id, field, value, options = {}) {
|
|
256
|
+
return update(Model, id, { $push: { [field]: value } }, options);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Pull from array
|
|
260
|
+
*/
|
|
261
|
+
async function pullFromArray(Model, id, field, value, options = {}) {
|
|
262
|
+
return update(Model, id, { $pull: { [field]: value } }, options);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/actions/delete.ts
|
|
267
|
+
var delete_exports = /* @__PURE__ */ __exportAll({
|
|
268
|
+
deleteById: () => deleteById,
|
|
269
|
+
deleteByQuery: () => deleteByQuery,
|
|
270
|
+
deleteMany: () => deleteMany,
|
|
271
|
+
restore: () => restore,
|
|
272
|
+
softDelete: () => softDelete
|
|
273
|
+
});
|
|
274
|
+
/**
|
|
275
|
+
* Delete by ID
|
|
276
|
+
*/
|
|
277
|
+
async function deleteById(Model, id, options = {}) {
|
|
278
|
+
const query = {
|
|
279
|
+
_id: id,
|
|
280
|
+
...options.query
|
|
281
|
+
};
|
|
282
|
+
if (!await Model.findOneAndDelete(query).session(options.session ?? null)) throw createError(404, "Document not found");
|
|
283
|
+
return {
|
|
284
|
+
success: true,
|
|
285
|
+
message: "Deleted successfully"
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Delete many documents
|
|
290
|
+
*/
|
|
291
|
+
async function deleteMany(Model, query, options = {}) {
|
|
292
|
+
return {
|
|
293
|
+
success: true,
|
|
294
|
+
count: (await Model.deleteMany(query).session(options.session ?? null)).deletedCount,
|
|
295
|
+
message: "Deleted successfully"
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Delete by query
|
|
300
|
+
*/
|
|
301
|
+
async function deleteByQuery(Model, query, options = {}) {
|
|
302
|
+
if (!await Model.findOneAndDelete(query).session(options.session ?? null) && options.throwOnNotFound !== false) throw createError(404, "Document not found");
|
|
303
|
+
return {
|
|
304
|
+
success: true,
|
|
305
|
+
message: "Deleted successfully"
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Soft delete (set deleted flag)
|
|
310
|
+
*/
|
|
311
|
+
async function softDelete(Model, id, options = {}) {
|
|
312
|
+
if (!await Model.findByIdAndUpdate(id, {
|
|
313
|
+
deleted: true,
|
|
314
|
+
deletedAt: /* @__PURE__ */ new Date(),
|
|
315
|
+
deletedBy: options.userId
|
|
316
|
+
}, {
|
|
317
|
+
returnDocument: "after",
|
|
318
|
+
session: options.session
|
|
319
|
+
})) throw createError(404, "Document not found");
|
|
320
|
+
return {
|
|
321
|
+
success: true,
|
|
322
|
+
message: "Soft deleted successfully"
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Restore soft deleted document
|
|
327
|
+
*/
|
|
328
|
+
async function restore(Model, id, options = {}) {
|
|
329
|
+
if (!await Model.findByIdAndUpdate(id, {
|
|
330
|
+
deleted: false,
|
|
331
|
+
deletedAt: null,
|
|
332
|
+
deletedBy: null
|
|
333
|
+
}, {
|
|
334
|
+
returnDocument: "after",
|
|
335
|
+
session: options.session
|
|
336
|
+
})) throw createError(404, "Document not found");
|
|
337
|
+
return {
|
|
338
|
+
success: true,
|
|
339
|
+
message: "Restored successfully"
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
//#endregion
|
|
344
|
+
//#region src/query/LookupBuilder.ts
|
|
345
|
+
/** Stages that are never valid inside a $lookup pipeline */
|
|
346
|
+
const BLOCKED_PIPELINE_STAGES = [
|
|
347
|
+
"$out",
|
|
348
|
+
"$merge",
|
|
349
|
+
"$unionWith",
|
|
350
|
+
"$collStats",
|
|
351
|
+
"$currentOp",
|
|
352
|
+
"$listSessions"
|
|
353
|
+
];
|
|
354
|
+
/** Operators that can enable arbitrary code execution */
|
|
355
|
+
const DANGEROUS_OPERATORS = [
|
|
356
|
+
"$where",
|
|
357
|
+
"$function",
|
|
358
|
+
"$accumulator",
|
|
359
|
+
"$expr"
|
|
360
|
+
];
|
|
361
|
+
/**
|
|
362
|
+
* Fluent builder for MongoDB $lookup aggregation stage
|
|
363
|
+
* Optimized for custom field joins at scale
|
|
364
|
+
*/
|
|
365
|
+
var LookupBuilder = class LookupBuilder {
|
|
366
|
+
options = {};
|
|
367
|
+
constructor(from) {
|
|
368
|
+
if (from) this.options.from = from;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Set the collection to join with
|
|
372
|
+
*/
|
|
373
|
+
from(collection) {
|
|
374
|
+
this.options.from = collection;
|
|
375
|
+
return this;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Set the local field (source collection)
|
|
379
|
+
* IMPORTANT: This field should be indexed for optimal performance
|
|
380
|
+
*/
|
|
381
|
+
localField(field) {
|
|
382
|
+
this.options.localField = field;
|
|
383
|
+
return this;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Set the foreign field (target collection)
|
|
387
|
+
* IMPORTANT: This field should be indexed (preferably unique) for optimal performance
|
|
388
|
+
*/
|
|
389
|
+
foreignField(field) {
|
|
390
|
+
this.options.foreignField = field;
|
|
391
|
+
return this;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Set the output field name
|
|
395
|
+
* Defaults to the collection name if not specified
|
|
396
|
+
*/
|
|
397
|
+
as(fieldName) {
|
|
398
|
+
this.options.as = fieldName;
|
|
399
|
+
return this;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Mark this lookup as returning a single document
|
|
403
|
+
* Automatically unwraps the array result to a single object or null
|
|
404
|
+
*/
|
|
405
|
+
single(isSingle = true) {
|
|
406
|
+
this.options.single = isSingle;
|
|
407
|
+
return this;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Add a pipeline to filter/transform joined documents
|
|
411
|
+
* Useful for filtering, sorting, or limiting joined results
|
|
412
|
+
*
|
|
413
|
+
* @example
|
|
414
|
+
* ```typescript
|
|
415
|
+
* lookup.pipeline([
|
|
416
|
+
* { $match: { status: 'active' } },
|
|
417
|
+
* { $sort: { priority: -1 } },
|
|
418
|
+
* { $limit: 5 }
|
|
419
|
+
* ]);
|
|
420
|
+
* ```
|
|
421
|
+
*/
|
|
422
|
+
pipeline(stages) {
|
|
423
|
+
this.options.pipeline = stages;
|
|
424
|
+
return this;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Set let variables for use in pipeline
|
|
428
|
+
* Allows referencing local document fields in the pipeline
|
|
429
|
+
*/
|
|
430
|
+
let(variables) {
|
|
431
|
+
this.options.let = variables;
|
|
432
|
+
return this;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Build the $lookup aggregation stage(s)
|
|
436
|
+
* Returns an array of pipeline stages including $lookup and optional $unwind
|
|
437
|
+
*
|
|
438
|
+
* IMPORTANT: MongoDB $lookup has two mutually exclusive forms:
|
|
439
|
+
* 1. Simple form: { from, localField, foreignField, as }
|
|
440
|
+
* 2. Pipeline form: { from, let, pipeline, as }
|
|
441
|
+
*
|
|
442
|
+
* When pipeline or let is specified, we use the pipeline form.
|
|
443
|
+
* Otherwise, we use the simpler localField/foreignField form.
|
|
444
|
+
*/
|
|
445
|
+
build() {
|
|
446
|
+
const { from, localField, foreignField, as, single, pipeline, let: letVars } = this.options;
|
|
447
|
+
if (!from) throw new Error("LookupBuilder: \"from\" collection is required");
|
|
448
|
+
const outputField = as || from;
|
|
449
|
+
const stages = [];
|
|
450
|
+
const usePipelineForm = pipeline || letVars;
|
|
451
|
+
let lookupStage;
|
|
452
|
+
if (usePipelineForm) if (!pipeline || pipeline.length === 0) {
|
|
453
|
+
if (!localField || !foreignField) throw new Error("LookupBuilder: When using pipeline form without a custom pipeline, both localField and foreignField are required to auto-generate the pipeline");
|
|
454
|
+
const autoPipeline = [{ $match: { $expr: { $eq: [`$${foreignField}`, `$$${localField}`] } } }];
|
|
455
|
+
lookupStage = { $lookup: {
|
|
456
|
+
from,
|
|
457
|
+
let: {
|
|
458
|
+
[localField]: `$${localField}`,
|
|
459
|
+
...letVars || {}
|
|
460
|
+
},
|
|
461
|
+
pipeline: autoPipeline,
|
|
462
|
+
as: outputField
|
|
463
|
+
} };
|
|
464
|
+
} else {
|
|
465
|
+
const safePipeline = this.options.sanitize !== false ? LookupBuilder.sanitizePipeline(pipeline) : pipeline;
|
|
466
|
+
lookupStage = { $lookup: {
|
|
467
|
+
from,
|
|
468
|
+
...letVars && { let: letVars },
|
|
469
|
+
pipeline: safePipeline,
|
|
470
|
+
as: outputField
|
|
471
|
+
} };
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
if (!localField || !foreignField) throw new Error("LookupBuilder: localField and foreignField are required for simple lookup");
|
|
475
|
+
lookupStage = { $lookup: {
|
|
476
|
+
from,
|
|
477
|
+
localField,
|
|
478
|
+
foreignField,
|
|
479
|
+
as: outputField
|
|
480
|
+
} };
|
|
481
|
+
}
|
|
482
|
+
stages.push(lookupStage);
|
|
483
|
+
if (single) stages.push({ $unwind: {
|
|
484
|
+
path: `$${outputField}`,
|
|
485
|
+
preserveNullAndEmptyArrays: true
|
|
486
|
+
} });
|
|
487
|
+
return stages;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Build and return only the $lookup stage (without $unwind)
|
|
491
|
+
* Useful when you want to handle unwrapping yourself
|
|
492
|
+
*/
|
|
493
|
+
buildLookupOnly() {
|
|
494
|
+
return this.build()[0];
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Static helper: Create a simple lookup in one line
|
|
498
|
+
*/
|
|
499
|
+
static simple(from, localField, foreignField, options = {}) {
|
|
500
|
+
return new LookupBuilder(from).localField(localField).foreignField(foreignField).as(options.as || from).single(options.single || false).build();
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Static helper: Create multiple lookups at once
|
|
504
|
+
*
|
|
505
|
+
* @example
|
|
506
|
+
* ```typescript
|
|
507
|
+
* const pipeline = LookupBuilder.multiple([
|
|
508
|
+
* { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
|
|
509
|
+
* { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
|
|
510
|
+
* ]);
|
|
511
|
+
* ```
|
|
512
|
+
*/
|
|
513
|
+
static multiple(lookups) {
|
|
514
|
+
return lookups.flatMap((lookup) => {
|
|
515
|
+
const builder = new LookupBuilder(lookup.from).localField(lookup.localField).foreignField(lookup.foreignField);
|
|
516
|
+
if (lookup.as) builder.as(lookup.as);
|
|
517
|
+
if (lookup.single) builder.single(lookup.single);
|
|
518
|
+
if (lookup.pipeline) builder.pipeline(lookup.pipeline);
|
|
519
|
+
if (lookup.let) builder.let(lookup.let);
|
|
520
|
+
return builder.build();
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Static helper: Create a nested lookup (lookup within lookup)
|
|
525
|
+
* Useful for multi-level joins like Order -> Product -> Category
|
|
526
|
+
*
|
|
527
|
+
* @example
|
|
528
|
+
* ```typescript
|
|
529
|
+
* // Join orders with products, then products with categories
|
|
530
|
+
* const pipeline = LookupBuilder.nested([
|
|
531
|
+
* { from: 'products', localField: 'productSku', foreignField: 'sku', as: 'product', single: true },
|
|
532
|
+
* { from: 'categories', localField: 'product.categorySlug', foreignField: 'slug', as: 'product.category', single: true }
|
|
533
|
+
* ]);
|
|
534
|
+
* ```
|
|
535
|
+
*/
|
|
536
|
+
static nested(lookups) {
|
|
537
|
+
return lookups.flatMap((lookup, index) => {
|
|
538
|
+
const builder = new LookupBuilder(lookup.from).localField(lookup.localField).foreignField(lookup.foreignField);
|
|
539
|
+
if (lookup.as) builder.as(lookup.as);
|
|
540
|
+
if (lookup.single !== void 0) builder.single(lookup.single);
|
|
541
|
+
if (lookup.pipeline) builder.pipeline(lookup.pipeline);
|
|
542
|
+
if (lookup.let) builder.let(lookup.let);
|
|
543
|
+
return builder.build();
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Sanitize pipeline stages by blocking dangerous stages and operators.
|
|
548
|
+
* Used internally by build() and available for external use (e.g., aggregate.ts).
|
|
549
|
+
*/
|
|
550
|
+
static sanitizePipeline(stages) {
|
|
551
|
+
const sanitized = [];
|
|
552
|
+
for (const stage of stages) {
|
|
553
|
+
if (!stage || typeof stage !== "object") continue;
|
|
554
|
+
const entries = Object.entries(stage);
|
|
555
|
+
if (entries.length !== 1) continue;
|
|
556
|
+
const [op, config] = entries[0];
|
|
557
|
+
if (BLOCKED_PIPELINE_STAGES.includes(op)) {
|
|
558
|
+
warn(`[mongokit] Blocked dangerous pipeline stage in lookup: ${op}`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if ((op === "$match" || op === "$addFields" || op === "$set") && typeof config === "object" && config !== null) sanitized.push({ [op]: LookupBuilder._sanitizeDeep(config) });
|
|
562
|
+
else sanitized.push(stage);
|
|
563
|
+
}
|
|
564
|
+
return sanitized;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Recursively remove dangerous operators from an expression object.
|
|
568
|
+
*/
|
|
569
|
+
static _sanitizeDeep(config) {
|
|
570
|
+
const sanitized = {};
|
|
571
|
+
for (const [key, value] of Object.entries(config)) {
|
|
572
|
+
if (DANGEROUS_OPERATORS.includes(key)) {
|
|
573
|
+
warn(`[mongokit] Blocked dangerous operator in lookup pipeline: ${key}`);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = LookupBuilder._sanitizeDeep(value);
|
|
577
|
+
else if (Array.isArray(value)) sanitized[key] = value.map((item) => {
|
|
578
|
+
if (item && typeof item === "object" && !Array.isArray(item)) return LookupBuilder._sanitizeDeep(item);
|
|
579
|
+
return item;
|
|
580
|
+
});
|
|
581
|
+
else sanitized[key] = value;
|
|
582
|
+
}
|
|
583
|
+
return sanitized;
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
//#endregion
|
|
588
|
+
//#region src/actions/aggregate.ts
|
|
589
|
+
var aggregate_exports = /* @__PURE__ */ __exportAll({
|
|
590
|
+
aggregate: () => aggregate,
|
|
591
|
+
aggregatePaginate: () => aggregatePaginate,
|
|
592
|
+
average: () => average,
|
|
593
|
+
countBy: () => countBy,
|
|
594
|
+
distinct: () => distinct,
|
|
595
|
+
facet: () => facet,
|
|
596
|
+
groupBy: () => groupBy,
|
|
597
|
+
lookup: () => lookup,
|
|
598
|
+
minMax: () => minMax,
|
|
599
|
+
sum: () => sum,
|
|
600
|
+
unwind: () => unwind
|
|
601
|
+
});
|
|
602
|
+
/**
|
|
603
|
+
* Execute aggregation pipeline
|
|
604
|
+
*/
|
|
605
|
+
async function aggregate(Model, pipeline, options = {}) {
|
|
606
|
+
const aggregation = Model.aggregate(pipeline);
|
|
607
|
+
if (options.session) aggregation.session(options.session);
|
|
608
|
+
return aggregation.exec();
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Aggregate with pagination using native MongoDB $facet
|
|
612
|
+
* WARNING: $facet results must be <16MB. For larger results (limit >1000),
|
|
613
|
+
* consider using Repository.aggregatePaginate() or splitting into separate queries.
|
|
614
|
+
*/
|
|
615
|
+
async function aggregatePaginate(Model, pipeline, options = {}) {
|
|
616
|
+
const page = parseInt(String(options.page || 1), 10);
|
|
617
|
+
const limit = parseInt(String(options.limit || 10), 10);
|
|
618
|
+
const skip = (page - 1) * limit;
|
|
619
|
+
if (limit > 1e3) warn(`[mongokit] Large aggregation limit (${limit}). $facet results must be <16MB. Consider using Repository.aggregatePaginate() for safer handling of large datasets.`);
|
|
620
|
+
const facetPipeline = [...pipeline, { $facet: {
|
|
621
|
+
docs: [{ $skip: skip }, { $limit: limit }],
|
|
622
|
+
total: [{ $count: "count" }]
|
|
623
|
+
} }];
|
|
624
|
+
const aggregation = Model.aggregate(facetPipeline);
|
|
625
|
+
if (options.session) aggregation.session(options.session);
|
|
626
|
+
const [result] = await aggregation.exec();
|
|
627
|
+
const docs = result.docs || [];
|
|
628
|
+
const total = result.total[0]?.count || 0;
|
|
629
|
+
const pages = Math.ceil(total / limit);
|
|
630
|
+
return {
|
|
631
|
+
docs,
|
|
632
|
+
total,
|
|
633
|
+
page,
|
|
634
|
+
limit,
|
|
635
|
+
pages,
|
|
636
|
+
hasNext: page < pages,
|
|
637
|
+
hasPrev: page > 1
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Group documents by field value
|
|
642
|
+
*/
|
|
643
|
+
async function groupBy(Model, field, options = {}) {
|
|
644
|
+
const pipeline = [{ $group: {
|
|
645
|
+
_id: `$${field}`,
|
|
646
|
+
count: { $sum: 1 }
|
|
647
|
+
} }, { $sort: { count: -1 } }];
|
|
648
|
+
if (options.limit) pipeline.push({ $limit: options.limit });
|
|
649
|
+
return aggregate(Model, pipeline, options);
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Count by field values
|
|
653
|
+
*/
|
|
654
|
+
async function countBy(Model, field, query = {}, options = {}) {
|
|
655
|
+
const pipeline = [];
|
|
656
|
+
if (Object.keys(query).length > 0) pipeline.push({ $match: query });
|
|
657
|
+
pipeline.push({ $group: {
|
|
658
|
+
_id: `$${field}`,
|
|
659
|
+
count: { $sum: 1 }
|
|
660
|
+
} }, { $sort: { count: -1 } });
|
|
661
|
+
return aggregate(Model, pipeline, options);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Lookup (join) with another collection
|
|
665
|
+
*
|
|
666
|
+
* MongoDB $lookup has two mutually exclusive forms:
|
|
667
|
+
* 1. Simple form: { from, localField, foreignField, as }
|
|
668
|
+
* 2. Pipeline form: { from, let, pipeline, as }
|
|
669
|
+
*
|
|
670
|
+
* This function automatically selects the appropriate form based on parameters.
|
|
671
|
+
*/
|
|
672
|
+
async function lookup(Model, lookupOptions) {
|
|
673
|
+
const { from, localField, foreignField, as, pipeline = [], let: letVars, query = {}, options = {} } = lookupOptions;
|
|
674
|
+
const aggPipeline = [];
|
|
675
|
+
if (Object.keys(query).length > 0) aggPipeline.push({ $match: query });
|
|
676
|
+
if (pipeline.length > 0 || letVars) if (pipeline.length === 0 && localField && foreignField) {
|
|
677
|
+
const autoPipeline = [{ $match: { $expr: { $eq: [`$${foreignField}`, `$$${localField}`] } } }];
|
|
678
|
+
aggPipeline.push({ $lookup: {
|
|
679
|
+
from,
|
|
680
|
+
let: {
|
|
681
|
+
[localField]: `$${localField}`,
|
|
682
|
+
...letVars || {}
|
|
683
|
+
},
|
|
684
|
+
pipeline: autoPipeline,
|
|
685
|
+
as
|
|
686
|
+
} });
|
|
687
|
+
} else {
|
|
688
|
+
const safePipeline = lookupOptions.sanitize !== false ? LookupBuilder.sanitizePipeline(pipeline) : pipeline;
|
|
689
|
+
aggPipeline.push({ $lookup: {
|
|
690
|
+
from,
|
|
691
|
+
...letVars && { let: letVars },
|
|
692
|
+
pipeline: safePipeline,
|
|
693
|
+
as
|
|
694
|
+
} });
|
|
695
|
+
}
|
|
696
|
+
else aggPipeline.push({ $lookup: {
|
|
697
|
+
from,
|
|
698
|
+
localField,
|
|
699
|
+
foreignField,
|
|
700
|
+
as
|
|
701
|
+
} });
|
|
702
|
+
return aggregate(Model, aggPipeline, options);
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Unwind array field
|
|
706
|
+
*/
|
|
707
|
+
async function unwind(Model, field, options = {}) {
|
|
708
|
+
return aggregate(Model, [{ $unwind: {
|
|
709
|
+
path: `$${field}`,
|
|
710
|
+
preserveNullAndEmptyArrays: options.preserveEmpty !== false
|
|
711
|
+
} }], { session: options.session });
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Facet search (multiple aggregations in one query)
|
|
715
|
+
*/
|
|
716
|
+
async function facet(Model, facets, options = {}) {
|
|
717
|
+
return aggregate(Model, [{ $facet: facets }], options);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Get distinct values
|
|
721
|
+
*/
|
|
722
|
+
async function distinct(Model, field, query = {}, options = {}) {
|
|
723
|
+
return Model.distinct(field, query).session(options.session ?? null);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Calculate sum
|
|
727
|
+
*/
|
|
728
|
+
async function sum(Model, field, query = {}, options = {}) {
|
|
729
|
+
const pipeline = [];
|
|
730
|
+
if (Object.keys(query).length > 0) pipeline.push({ $match: query });
|
|
731
|
+
pipeline.push({ $group: {
|
|
732
|
+
_id: null,
|
|
733
|
+
total: { $sum: `$${field}` }
|
|
734
|
+
} });
|
|
735
|
+
return (await aggregate(Model, pipeline, options))[0]?.total || 0;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Calculate average
|
|
739
|
+
*/
|
|
740
|
+
async function average(Model, field, query = {}, options = {}) {
|
|
741
|
+
const pipeline = [];
|
|
742
|
+
if (Object.keys(query).length > 0) pipeline.push({ $match: query });
|
|
743
|
+
pipeline.push({ $group: {
|
|
744
|
+
_id: null,
|
|
745
|
+
average: { $avg: `$${field}` }
|
|
746
|
+
} });
|
|
747
|
+
return (await aggregate(Model, pipeline, options))[0]?.average || 0;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Min/Max
|
|
751
|
+
*/
|
|
752
|
+
async function minMax(Model, field, query = {}, options = {}) {
|
|
753
|
+
const pipeline = [];
|
|
754
|
+
if (Object.keys(query).length > 0) pipeline.push({ $match: query });
|
|
755
|
+
pipeline.push({ $group: {
|
|
756
|
+
_id: null,
|
|
757
|
+
min: { $min: `$${field}` },
|
|
758
|
+
max: { $max: `$${field}` }
|
|
759
|
+
} });
|
|
760
|
+
return (await aggregate(Model, pipeline, options))[0] || {
|
|
761
|
+
min: null,
|
|
762
|
+
max: null
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
//#endregion
|
|
767
|
+
export { deleteById as a, update_exports as c, getById as d, getByQuery as f, LookupBuilder as i, count as l, read_exports as m, aggregate_exports as n, delete_exports as o, getOrCreate as p, distinct as r, update as s, aggregate as t, exists as u };
|