@classytic/mongokit 3.0.6 → 3.1.1
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 +625 -463
- package/dist/actions/index.d.ts +2 -2
- package/dist/actions/index.js +3 -484
- package/dist/chunks/chunk-2ZN65ZOP.js +93 -0
- package/dist/chunks/chunk-CF6FLC2G.js +46 -0
- package/dist/chunks/chunk-CSLJ2PL2.js +1092 -0
- package/dist/chunks/chunk-IT7DCOKR.js +299 -0
- package/dist/chunks/chunk-M2XHQGZB.js +361 -0
- package/dist/chunks/chunk-SAKSLT47.js +470 -0
- package/dist/chunks/chunk-VJXDGP3C.js +14 -0
- package/dist/{index-CkwbNdpJ.d.ts → index-C2NCVxJK.d.ts} +170 -3
- package/dist/index.d.ts +997 -8
- package/dist/index.js +1143 -2476
- package/dist/{queryParser-Do3SgsyJ.d.ts → mongooseToJsonSchema-BKMxPbPp.d.ts} +8 -111
- package/dist/pagination/PaginationEngine.d.ts +1 -1
- package/dist/pagination/PaginationEngine.js +2 -368
- package/dist/plugins/index.d.ts +1 -1
- package/dist/plugins/index.js +4 -1170
- package/dist/{types-DDDYo18H.d.ts → types-DA0rs2Jh.d.ts} +109 -35
- package/dist/utils/index.d.ts +2 -2
- package/dist/utils/index.js +3 -711
- package/package.json +8 -3
package/dist/index.js
CHANGED
|
@@ -1,902 +1,709 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { getById, getByQuery, getOrCreate, count, exists, update, deleteById, aggregate, distinct } from './chunks/chunk-SAKSLT47.js';
|
|
2
|
+
export { actions_exports as actions } from './chunks/chunk-SAKSLT47.js';
|
|
3
|
+
import { PaginationEngine } from './chunks/chunk-M2XHQGZB.js';
|
|
4
|
+
export { PaginationEngine } from './chunks/chunk-M2XHQGZB.js';
|
|
5
|
+
export { aggregateHelpersPlugin, auditLogPlugin, autoInject, batchOperationsPlugin, blockIf, cachePlugin, cascadePlugin, fieldFilterPlugin, immutableField, methodRegistryPlugin, mongoOperationsPlugin, requireField, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validationChainPlugin } from './chunks/chunk-CSLJ2PL2.js';
|
|
6
|
+
import { create, createMany } from './chunks/chunk-CF6FLC2G.js';
|
|
7
|
+
export { buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, createMemoryCache, getImmutableFields, getSystemManagedFields, isFieldUpdateAllowed, validateUpdateBody } from './chunks/chunk-IT7DCOKR.js';
|
|
8
|
+
export { createFieldPreset, filterResponseData, getFieldsForUser, getMongooseProjection } from './chunks/chunk-2ZN65ZOP.js';
|
|
9
|
+
import { createError } from './chunks/chunk-VJXDGP3C.js';
|
|
10
|
+
export { createError } from './chunks/chunk-VJXDGP3C.js';
|
|
11
|
+
import mongoose from 'mongoose';
|
|
2
12
|
|
|
3
|
-
|
|
4
|
-
var
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// src/utils/error.ts
|
|
10
|
-
function createError(status, message) {
|
|
11
|
-
const error = new Error(message);
|
|
12
|
-
error.status = status;
|
|
13
|
-
return error;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// src/actions/create.ts
|
|
17
|
-
var create_exports = {};
|
|
18
|
-
__export(create_exports, {
|
|
19
|
-
create: () => create,
|
|
20
|
-
createDefault: () => createDefault,
|
|
21
|
-
createMany: () => createMany,
|
|
22
|
-
upsert: () => upsert
|
|
23
|
-
});
|
|
24
|
-
async function create(Model, data, options = {}) {
|
|
25
|
-
const document = new Model(data);
|
|
26
|
-
await document.save({ session: options.session });
|
|
27
|
-
return document;
|
|
28
|
-
}
|
|
29
|
-
async function createMany(Model, dataArray, options = {}) {
|
|
30
|
-
return Model.insertMany(dataArray, {
|
|
31
|
-
session: options.session,
|
|
32
|
-
ordered: options.ordered !== false
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
async function createDefault(Model, overrides = {}, options = {}) {
|
|
36
|
-
const defaults = {};
|
|
37
|
-
Model.schema.eachPath((path, schemaType) => {
|
|
38
|
-
const schemaOptions = schemaType.options;
|
|
39
|
-
if (schemaOptions.default !== void 0 && path !== "_id") {
|
|
40
|
-
defaults[path] = typeof schemaOptions.default === "function" ? schemaOptions.default() : schemaOptions.default;
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
return create(Model, { ...defaults, ...overrides }, options);
|
|
44
|
-
}
|
|
45
|
-
async function upsert(Model, query, data, options = {}) {
|
|
46
|
-
return Model.findOneAndUpdate(
|
|
47
|
-
query,
|
|
48
|
-
{ $setOnInsert: data },
|
|
49
|
-
{
|
|
50
|
-
upsert: true,
|
|
51
|
-
new: true,
|
|
52
|
-
runValidators: true,
|
|
53
|
-
session: options.session,
|
|
54
|
-
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
55
|
-
}
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// src/actions/read.ts
|
|
60
|
-
var read_exports = {};
|
|
61
|
-
__export(read_exports, {
|
|
62
|
-
count: () => count,
|
|
63
|
-
exists: () => exists,
|
|
64
|
-
getAll: () => getAll,
|
|
65
|
-
getById: () => getById,
|
|
66
|
-
getByQuery: () => getByQuery,
|
|
67
|
-
getOrCreate: () => getOrCreate,
|
|
68
|
-
tryGetByQuery: () => tryGetByQuery
|
|
69
|
-
});
|
|
70
|
-
function parsePopulate(populate) {
|
|
71
|
-
if (!populate) return [];
|
|
72
|
-
if (typeof populate === "string") {
|
|
73
|
-
return populate.split(",").map((p) => p.trim());
|
|
74
|
-
}
|
|
75
|
-
if (Array.isArray(populate)) {
|
|
76
|
-
return populate.map((p) => typeof p === "string" ? p.trim() : p);
|
|
77
|
-
}
|
|
78
|
-
return [populate];
|
|
79
|
-
}
|
|
80
|
-
async function getById(Model, id, options = {}) {
|
|
81
|
-
const query = options.query ? Model.findOne({ _id: id, ...options.query }) : Model.findById(id);
|
|
82
|
-
if (options.select) query.select(options.select);
|
|
83
|
-
if (options.populate) query.populate(parsePopulate(options.populate));
|
|
84
|
-
if (options.lean) query.lean();
|
|
85
|
-
if (options.session) query.session(options.session);
|
|
86
|
-
const document = await query.exec();
|
|
87
|
-
if (!document && options.throwOnNotFound !== false) {
|
|
88
|
-
throw createError(404, "Document not found");
|
|
89
|
-
}
|
|
90
|
-
return document;
|
|
91
|
-
}
|
|
92
|
-
async function getByQuery(Model, query, options = {}) {
|
|
93
|
-
const mongoQuery = Model.findOne(query);
|
|
94
|
-
if (options.select) mongoQuery.select(options.select);
|
|
95
|
-
if (options.populate) mongoQuery.populate(parsePopulate(options.populate));
|
|
96
|
-
if (options.lean) mongoQuery.lean();
|
|
97
|
-
if (options.session) mongoQuery.session(options.session);
|
|
98
|
-
const document = await mongoQuery.exec();
|
|
99
|
-
if (!document && options.throwOnNotFound !== false) {
|
|
100
|
-
throw createError(404, "Document not found");
|
|
101
|
-
}
|
|
102
|
-
return document;
|
|
103
|
-
}
|
|
104
|
-
async function tryGetByQuery(Model, query, options = {}) {
|
|
105
|
-
return getByQuery(Model, query, { ...options, throwOnNotFound: false });
|
|
106
|
-
}
|
|
107
|
-
async function getAll(Model, query = {}, options = {}) {
|
|
108
|
-
let mongoQuery = Model.find(query);
|
|
109
|
-
if (options.select) mongoQuery = mongoQuery.select(options.select);
|
|
110
|
-
if (options.populate) mongoQuery = mongoQuery.populate(parsePopulate(options.populate));
|
|
111
|
-
if (options.sort) mongoQuery = mongoQuery.sort(options.sort);
|
|
112
|
-
if (options.limit) mongoQuery = mongoQuery.limit(options.limit);
|
|
113
|
-
if (options.skip) mongoQuery = mongoQuery.skip(options.skip);
|
|
114
|
-
mongoQuery = mongoQuery.lean(options.lean !== false);
|
|
115
|
-
if (options.session) mongoQuery = mongoQuery.session(options.session);
|
|
116
|
-
return mongoQuery.exec();
|
|
117
|
-
}
|
|
118
|
-
async function getOrCreate(Model, query, createData, options = {}) {
|
|
119
|
-
return Model.findOneAndUpdate(
|
|
120
|
-
query,
|
|
121
|
-
{ $setOnInsert: createData },
|
|
122
|
-
{
|
|
123
|
-
upsert: true,
|
|
124
|
-
new: true,
|
|
125
|
-
runValidators: true,
|
|
126
|
-
session: options.session,
|
|
127
|
-
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
128
|
-
}
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
async function count(Model, query = {}, options = {}) {
|
|
132
|
-
return Model.countDocuments(query).session(options.session ?? null);
|
|
133
|
-
}
|
|
134
|
-
async function exists(Model, query, options = {}) {
|
|
135
|
-
return Model.exists(query).session(options.session ?? null);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// src/actions/update.ts
|
|
139
|
-
var update_exports = {};
|
|
140
|
-
__export(update_exports, {
|
|
141
|
-
increment: () => increment,
|
|
142
|
-
pullFromArray: () => pullFromArray,
|
|
143
|
-
pushToArray: () => pushToArray,
|
|
144
|
-
update: () => update,
|
|
145
|
-
updateByQuery: () => updateByQuery,
|
|
146
|
-
updateMany: () => updateMany,
|
|
147
|
-
updateWithConstraints: () => updateWithConstraints,
|
|
148
|
-
updateWithValidation: () => updateWithValidation
|
|
149
|
-
});
|
|
150
|
-
function assertUpdatePipelineAllowed(update2, updatePipeline) {
|
|
151
|
-
if (Array.isArray(update2) && updatePipeline !== true) {
|
|
152
|
-
throw createError(
|
|
153
|
-
400,
|
|
154
|
-
"Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates."
|
|
155
|
-
);
|
|
13
|
+
// src/query/LookupBuilder.ts
|
|
14
|
+
var LookupBuilder = class _LookupBuilder {
|
|
15
|
+
options = {};
|
|
16
|
+
constructor(from) {
|
|
17
|
+
if (from) this.options.from = from;
|
|
156
18
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Set the collection to join with
|
|
21
|
+
*/
|
|
22
|
+
from(collection) {
|
|
23
|
+
this.options.from = collection;
|
|
24
|
+
return this;
|
|
162
25
|
}
|
|
163
|
-
|
|
164
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Set the local field (source collection)
|
|
28
|
+
* IMPORTANT: This field should be indexed for optimal performance
|
|
29
|
+
*/
|
|
30
|
+
localField(field) {
|
|
31
|
+
this.options.localField = field;
|
|
32
|
+
return this;
|
|
165
33
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
session: options.session,
|
|
174
|
-
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
175
|
-
}).select(options.select || "").populate(parsePopulate2(options.populate)).lean(options.lean ?? false);
|
|
176
|
-
if (!document) {
|
|
177
|
-
throw createError(404, "Document not found");
|
|
178
|
-
}
|
|
179
|
-
return document;
|
|
180
|
-
}
|
|
181
|
-
async function updateWithConstraints(Model, id, data, constraints = {}, options = {}) {
|
|
182
|
-
assertUpdatePipelineAllowed(data, options.updatePipeline);
|
|
183
|
-
const query = { _id: id, ...constraints };
|
|
184
|
-
const document = await Model.findOneAndUpdate(query, data, {
|
|
185
|
-
new: true,
|
|
186
|
-
runValidators: true,
|
|
187
|
-
session: options.session,
|
|
188
|
-
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
189
|
-
}).select(options.select || "").populate(parsePopulate2(options.populate)).lean(options.lean ?? false);
|
|
190
|
-
return document;
|
|
191
|
-
}
|
|
192
|
-
async function updateWithValidation(Model, id, data, validationOptions = {}, options = {}) {
|
|
193
|
-
const { buildConstraints, validateUpdate } = validationOptions;
|
|
194
|
-
assertUpdatePipelineAllowed(data, options.updatePipeline);
|
|
195
|
-
if (buildConstraints) {
|
|
196
|
-
const constraints = buildConstraints(data);
|
|
197
|
-
const document = await updateWithConstraints(Model, id, data, constraints, options);
|
|
198
|
-
if (document) {
|
|
199
|
-
return { success: true, data: document };
|
|
200
|
-
}
|
|
34
|
+
/**
|
|
35
|
+
* Set the foreign field (target collection)
|
|
36
|
+
* IMPORTANT: This field should be indexed (preferably unique) for optimal performance
|
|
37
|
+
*/
|
|
38
|
+
foreignField(field) {
|
|
39
|
+
this.options.foreignField = field;
|
|
40
|
+
return this;
|
|
201
41
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
};
|
|
42
|
+
/**
|
|
43
|
+
* Set the output field name
|
|
44
|
+
* Defaults to the collection name if not specified
|
|
45
|
+
*/
|
|
46
|
+
as(fieldName) {
|
|
47
|
+
this.options.as = fieldName;
|
|
48
|
+
return this;
|
|
211
49
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
message: validation.message || "Update not allowed",
|
|
220
|
-
violations: validation.violations
|
|
221
|
-
}
|
|
222
|
-
};
|
|
223
|
-
}
|
|
50
|
+
/**
|
|
51
|
+
* Mark this lookup as returning a single document
|
|
52
|
+
* Automatically unwraps the array result to a single object or null
|
|
53
|
+
*/
|
|
54
|
+
single(isSingle = true) {
|
|
55
|
+
this.options.single = isSingle;
|
|
56
|
+
return this;
|
|
224
57
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
assertUpdatePipelineAllowed(data, options.updatePipeline);
|
|
242
|
-
const document = await Model.findOneAndUpdate(query, data, {
|
|
243
|
-
new: true,
|
|
244
|
-
runValidators: true,
|
|
245
|
-
session: options.session,
|
|
246
|
-
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
247
|
-
}).select(options.select || "").populate(parsePopulate2(options.populate)).lean(options.lean ?? false);
|
|
248
|
-
if (!document && options.throwOnNotFound !== false) {
|
|
249
|
-
throw createError(404, "Document not found");
|
|
250
|
-
}
|
|
251
|
-
return document;
|
|
252
|
-
}
|
|
253
|
-
async function increment(Model, id, field, value = 1, options = {}) {
|
|
254
|
-
return update(Model, id, { $inc: { [field]: value } }, options);
|
|
255
|
-
}
|
|
256
|
-
async function pushToArray(Model, id, field, value, options = {}) {
|
|
257
|
-
return update(Model, id, { $push: { [field]: value } }, options);
|
|
258
|
-
}
|
|
259
|
-
async function pullFromArray(Model, id, field, value, options = {}) {
|
|
260
|
-
return update(Model, id, { $pull: { [field]: value } }, options);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// src/actions/delete.ts
|
|
264
|
-
var delete_exports = {};
|
|
265
|
-
__export(delete_exports, {
|
|
266
|
-
deleteById: () => deleteById,
|
|
267
|
-
deleteByQuery: () => deleteByQuery,
|
|
268
|
-
deleteMany: () => deleteMany,
|
|
269
|
-
restore: () => restore,
|
|
270
|
-
softDelete: () => softDelete
|
|
271
|
-
});
|
|
272
|
-
async function deleteById(Model, id, options = {}) {
|
|
273
|
-
const document = await Model.findByIdAndDelete(id).session(options.session ?? null);
|
|
274
|
-
if (!document) {
|
|
275
|
-
throw createError(404, "Document not found");
|
|
276
|
-
}
|
|
277
|
-
return { success: true, message: "Deleted successfully" };
|
|
278
|
-
}
|
|
279
|
-
async function deleteMany(Model, query, options = {}) {
|
|
280
|
-
const result = await Model.deleteMany(query).session(options.session ?? null);
|
|
281
|
-
return {
|
|
282
|
-
success: true,
|
|
283
|
-
count: result.deletedCount,
|
|
284
|
-
message: "Deleted successfully"
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
async function deleteByQuery(Model, query, options = {}) {
|
|
288
|
-
const document = await Model.findOneAndDelete(query).session(options.session ?? null);
|
|
289
|
-
if (!document && options.throwOnNotFound !== false) {
|
|
290
|
-
throw createError(404, "Document not found");
|
|
58
|
+
/**
|
|
59
|
+
* Add a pipeline to filter/transform joined documents
|
|
60
|
+
* Useful for filtering, sorting, or limiting joined results
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```typescript
|
|
64
|
+
* lookup.pipeline([
|
|
65
|
+
* { $match: { status: 'active' } },
|
|
66
|
+
* { $sort: { priority: -1 } },
|
|
67
|
+
* { $limit: 5 }
|
|
68
|
+
* ]);
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
pipeline(stages) {
|
|
72
|
+
this.options.pipeline = stages;
|
|
73
|
+
return this;
|
|
291
74
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
{
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
console.warn(
|
|
354
|
-
`[mongokit] Large aggregation limit (${limit}). $facet results must be <16MB. Consider using Repository.aggregatePaginate() for safer handling of large datasets.`
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
const facetPipeline = [
|
|
358
|
-
...pipeline,
|
|
359
|
-
{
|
|
360
|
-
$facet: {
|
|
361
|
-
docs: [{ $skip: skip }, { $limit: limit }],
|
|
362
|
-
total: [{ $count: "count" }]
|
|
75
|
+
/**
|
|
76
|
+
* Set let variables for use in pipeline
|
|
77
|
+
* Allows referencing local document fields in the pipeline
|
|
78
|
+
*/
|
|
79
|
+
let(variables) {
|
|
80
|
+
this.options.let = variables;
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Build the $lookup aggregation stage(s)
|
|
85
|
+
* Returns an array of pipeline stages including $lookup and optional $unwind
|
|
86
|
+
*
|
|
87
|
+
* IMPORTANT: MongoDB $lookup has two mutually exclusive forms:
|
|
88
|
+
* 1. Simple form: { from, localField, foreignField, as }
|
|
89
|
+
* 2. Pipeline form: { from, let, pipeline, as }
|
|
90
|
+
*
|
|
91
|
+
* When pipeline or let is specified, we use the pipeline form.
|
|
92
|
+
* Otherwise, we use the simpler localField/foreignField form.
|
|
93
|
+
*/
|
|
94
|
+
build() {
|
|
95
|
+
const { from, localField, foreignField, as, single, pipeline, let: letVars } = this.options;
|
|
96
|
+
if (!from) {
|
|
97
|
+
throw new Error('LookupBuilder: "from" collection is required');
|
|
98
|
+
}
|
|
99
|
+
const outputField = as || from;
|
|
100
|
+
const stages = [];
|
|
101
|
+
const usePipelineForm = pipeline || letVars;
|
|
102
|
+
let lookupStage;
|
|
103
|
+
if (usePipelineForm) {
|
|
104
|
+
if (!pipeline || pipeline.length === 0) {
|
|
105
|
+
if (!localField || !foreignField) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
"LookupBuilder: When using pipeline form without a custom pipeline, both localField and foreignField are required to auto-generate the pipeline"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const autoPipeline = [
|
|
111
|
+
{
|
|
112
|
+
$match: {
|
|
113
|
+
$expr: {
|
|
114
|
+
$eq: [`$${foreignField}`, `$$${localField}`]
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
];
|
|
119
|
+
lookupStage = {
|
|
120
|
+
$lookup: {
|
|
121
|
+
from,
|
|
122
|
+
let: { [localField]: `$${localField}`, ...letVars || {} },
|
|
123
|
+
pipeline: autoPipeline,
|
|
124
|
+
as: outputField
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
} else {
|
|
128
|
+
lookupStage = {
|
|
129
|
+
$lookup: {
|
|
130
|
+
from,
|
|
131
|
+
...letVars && { let: letVars },
|
|
132
|
+
pipeline,
|
|
133
|
+
as: outputField
|
|
134
|
+
}
|
|
135
|
+
};
|
|
363
136
|
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
if (options.session) {
|
|
368
|
-
aggregation.session(options.session);
|
|
369
|
-
}
|
|
370
|
-
const [result] = await aggregation.exec();
|
|
371
|
-
const docs = result.docs || [];
|
|
372
|
-
const total = result.total[0]?.count || 0;
|
|
373
|
-
const pages = Math.ceil(total / limit);
|
|
374
|
-
return {
|
|
375
|
-
docs,
|
|
376
|
-
total,
|
|
377
|
-
page,
|
|
378
|
-
limit,
|
|
379
|
-
pages,
|
|
380
|
-
hasNext: page < pages,
|
|
381
|
-
hasPrev: page > 1
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
|
-
async function groupBy(Model, field, options = {}) {
|
|
385
|
-
const pipeline = [
|
|
386
|
-
{ $group: { _id: `$${field}`, count: { $sum: 1 } } },
|
|
387
|
-
{ $sort: { count: -1 } }
|
|
388
|
-
];
|
|
389
|
-
if (options.limit) {
|
|
390
|
-
pipeline.push({ $limit: options.limit });
|
|
391
|
-
}
|
|
392
|
-
return aggregate(Model, pipeline, options);
|
|
393
|
-
}
|
|
394
|
-
async function countBy(Model, field, query = {}, options = {}) {
|
|
395
|
-
const pipeline = [];
|
|
396
|
-
if (Object.keys(query).length > 0) {
|
|
397
|
-
pipeline.push({ $match: query });
|
|
398
|
-
}
|
|
399
|
-
pipeline.push(
|
|
400
|
-
{ $group: { _id: `$${field}`, count: { $sum: 1 } } },
|
|
401
|
-
{ $sort: { count: -1 } }
|
|
402
|
-
);
|
|
403
|
-
return aggregate(Model, pipeline, options);
|
|
404
|
-
}
|
|
405
|
-
async function lookup(Model, lookupOptions) {
|
|
406
|
-
const { from, localField, foreignField, as, pipeline = [], query = {}, options = {} } = lookupOptions;
|
|
407
|
-
const aggPipeline = [];
|
|
408
|
-
if (Object.keys(query).length > 0) {
|
|
409
|
-
aggPipeline.push({ $match: query });
|
|
410
|
-
}
|
|
411
|
-
aggPipeline.push({
|
|
412
|
-
$lookup: {
|
|
413
|
-
from,
|
|
414
|
-
localField,
|
|
415
|
-
foreignField,
|
|
416
|
-
as,
|
|
417
|
-
...pipeline.length > 0 ? { pipeline } : {}
|
|
418
|
-
}
|
|
419
|
-
});
|
|
420
|
-
return aggregate(Model, aggPipeline, options);
|
|
421
|
-
}
|
|
422
|
-
async function unwind(Model, field, options = {}) {
|
|
423
|
-
const pipeline = [
|
|
424
|
-
{
|
|
425
|
-
$unwind: {
|
|
426
|
-
path: `$${field}`,
|
|
427
|
-
preserveNullAndEmptyArrays: options.preserveEmpty !== false
|
|
137
|
+
} else {
|
|
138
|
+
if (!localField || !foreignField) {
|
|
139
|
+
throw new Error("LookupBuilder: localField and foreignField are required for simple lookup");
|
|
428
140
|
}
|
|
141
|
+
lookupStage = {
|
|
142
|
+
$lookup: {
|
|
143
|
+
from,
|
|
144
|
+
localField,
|
|
145
|
+
foreignField,
|
|
146
|
+
as: outputField
|
|
147
|
+
}
|
|
148
|
+
};
|
|
429
149
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
async function sum(Model, field, query = {}, options = {}) {
|
|
441
|
-
const pipeline = [];
|
|
442
|
-
if (Object.keys(query).length > 0) {
|
|
443
|
-
pipeline.push({ $match: query });
|
|
444
|
-
}
|
|
445
|
-
pipeline.push({
|
|
446
|
-
$group: {
|
|
447
|
-
_id: null,
|
|
448
|
-
total: { $sum: `$${field}` }
|
|
449
|
-
}
|
|
450
|
-
});
|
|
451
|
-
const result = await aggregate(Model, pipeline, options);
|
|
452
|
-
return result[0]?.total || 0;
|
|
453
|
-
}
|
|
454
|
-
async function average(Model, field, query = {}, options = {}) {
|
|
455
|
-
const pipeline = [];
|
|
456
|
-
if (Object.keys(query).length > 0) {
|
|
457
|
-
pipeline.push({ $match: query });
|
|
458
|
-
}
|
|
459
|
-
pipeline.push({
|
|
460
|
-
$group: {
|
|
461
|
-
_id: null,
|
|
462
|
-
average: { $avg: `$${field}` }
|
|
463
|
-
}
|
|
464
|
-
});
|
|
465
|
-
const result = await aggregate(Model, pipeline, options);
|
|
466
|
-
return result[0]?.average || 0;
|
|
467
|
-
}
|
|
468
|
-
async function minMax(Model, field, query = {}, options = {}) {
|
|
469
|
-
const pipeline = [];
|
|
470
|
-
if (Object.keys(query).length > 0) {
|
|
471
|
-
pipeline.push({ $match: query });
|
|
472
|
-
}
|
|
473
|
-
pipeline.push({
|
|
474
|
-
$group: {
|
|
475
|
-
_id: null,
|
|
476
|
-
min: { $min: `$${field}` },
|
|
477
|
-
max: { $max: `$${field}` }
|
|
150
|
+
stages.push(lookupStage);
|
|
151
|
+
if (single) {
|
|
152
|
+
stages.push({
|
|
153
|
+
$unwind: {
|
|
154
|
+
path: `$${outputField}`,
|
|
155
|
+
preserveNullAndEmptyArrays: true
|
|
156
|
+
// Keep documents even if no match found
|
|
157
|
+
}
|
|
158
|
+
});
|
|
478
159
|
}
|
|
479
|
-
|
|
480
|
-
const result = await aggregate(Model, pipeline, options);
|
|
481
|
-
return result[0] || { min: null, max: null };
|
|
482
|
-
}
|
|
483
|
-
function encodeCursor(doc, primaryField, sort, version = 1) {
|
|
484
|
-
const primaryValue = doc[primaryField];
|
|
485
|
-
const idValue = doc._id;
|
|
486
|
-
const payload = {
|
|
487
|
-
v: serializeValue(primaryValue),
|
|
488
|
-
t: getValueType(primaryValue),
|
|
489
|
-
id: serializeValue(idValue),
|
|
490
|
-
idType: getValueType(idValue),
|
|
491
|
-
sort,
|
|
492
|
-
ver: version
|
|
493
|
-
};
|
|
494
|
-
return Buffer.from(JSON.stringify(payload)).toString("base64");
|
|
495
|
-
}
|
|
496
|
-
function decodeCursor(token) {
|
|
497
|
-
try {
|
|
498
|
-
const json = Buffer.from(token, "base64").toString("utf-8");
|
|
499
|
-
const payload = JSON.parse(json);
|
|
500
|
-
return {
|
|
501
|
-
value: rehydrateValue(payload.v, payload.t),
|
|
502
|
-
id: rehydrateValue(payload.id, payload.idType),
|
|
503
|
-
sort: payload.sort,
|
|
504
|
-
version: payload.ver
|
|
505
|
-
};
|
|
506
|
-
} catch {
|
|
507
|
-
throw new Error("Invalid cursor token");
|
|
160
|
+
return stages;
|
|
508
161
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Build and return only the $lookup stage (without $unwind)
|
|
164
|
+
* Useful when you want to handle unwrapping yourself
|
|
165
|
+
*/
|
|
166
|
+
buildLookupOnly() {
|
|
167
|
+
const stages = this.build();
|
|
168
|
+
return stages[0];
|
|
515
169
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Static helper: Create a simple lookup in one line
|
|
172
|
+
*/
|
|
173
|
+
static simple(from, localField, foreignField, options = {}) {
|
|
174
|
+
return new _LookupBuilder(from).localField(localField).foreignField(foreignField).as(options.as || from).single(options.single || false).build();
|
|
520
175
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
case "boolean":
|
|
542
|
-
return Boolean(serialized);
|
|
543
|
-
case "number":
|
|
544
|
-
return Number(serialized);
|
|
545
|
-
default:
|
|
546
|
-
return serialized;
|
|
176
|
+
/**
|
|
177
|
+
* Static helper: Create multiple lookups at once
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```typescript
|
|
181
|
+
* const pipeline = LookupBuilder.multiple([
|
|
182
|
+
* { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
|
|
183
|
+
* { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
|
|
184
|
+
* ]);
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
static multiple(lookups) {
|
|
188
|
+
return lookups.flatMap((lookup) => {
|
|
189
|
+
const builder = new _LookupBuilder(lookup.from).localField(lookup.localField).foreignField(lookup.foreignField);
|
|
190
|
+
if (lookup.as) builder.as(lookup.as);
|
|
191
|
+
if (lookup.single) builder.single(lookup.single);
|
|
192
|
+
if (lookup.pipeline) builder.pipeline(lookup.pipeline);
|
|
193
|
+
if (lookup.let) builder.let(lookup.let);
|
|
194
|
+
return builder.build();
|
|
195
|
+
});
|
|
547
196
|
}
|
|
548
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Static helper: Create a nested lookup (lookup within lookup)
|
|
199
|
+
* Useful for multi-level joins like Order -> Product -> Category
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* ```typescript
|
|
203
|
+
* // Join orders with products, then products with categories
|
|
204
|
+
* const pipeline = LookupBuilder.nested([
|
|
205
|
+
* { from: 'products', localField: 'productSku', foreignField: 'sku', as: 'product', single: true },
|
|
206
|
+
* { from: 'categories', localField: 'product.categorySlug', foreignField: 'slug', as: 'product.category', single: true }
|
|
207
|
+
* ]);
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
static nested(lookups) {
|
|
211
|
+
return lookups.flatMap((lookup, index) => {
|
|
212
|
+
const builder = new _LookupBuilder(lookup.from).localField(lookup.localField).foreignField(lookup.foreignField);
|
|
213
|
+
if (lookup.as) builder.as(lookup.as);
|
|
214
|
+
if (lookup.single !== void 0) builder.single(lookup.single);
|
|
215
|
+
if (lookup.pipeline) builder.pipeline(lookup.pipeline);
|
|
216
|
+
if (lookup.let) builder.let(lookup.let);
|
|
217
|
+
return builder.build();
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
};
|
|
549
221
|
|
|
550
|
-
// src/
|
|
551
|
-
function
|
|
222
|
+
// src/query/AggregationBuilder.ts
|
|
223
|
+
function normalizeSortSpec(sortSpec) {
|
|
552
224
|
const normalized = {};
|
|
553
|
-
Object.
|
|
554
|
-
if (
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
225
|
+
for (const [field, order] of Object.entries(sortSpec)) {
|
|
226
|
+
if (order === "asc") {
|
|
227
|
+
normalized[field] = 1;
|
|
228
|
+
} else if (order === "desc") {
|
|
229
|
+
normalized[field] = -1;
|
|
230
|
+
} else {
|
|
231
|
+
normalized[field] = order;
|
|
232
|
+
}
|
|
558
233
|
}
|
|
559
234
|
return normalized;
|
|
560
235
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
if (keys.length === 1 && keys[0] === "_id") {
|
|
569
|
-
return normalizeSort(sort);
|
|
570
|
-
}
|
|
571
|
-
if (keys.length === 2) {
|
|
572
|
-
if (!keys.includes("_id")) {
|
|
573
|
-
throw new Error("Keyset pagination requires _id as tie-breaker");
|
|
574
|
-
}
|
|
575
|
-
const primaryField = keys.find((k) => k !== "_id");
|
|
576
|
-
const primaryDirection = sort[primaryField];
|
|
577
|
-
const idDirection = sort._id;
|
|
578
|
-
if (primaryDirection !== idDirection) {
|
|
579
|
-
throw new Error("_id direction must match primary field direction");
|
|
580
|
-
}
|
|
581
|
-
return normalizeSort(sort);
|
|
236
|
+
var AggregationBuilder = class _AggregationBuilder {
|
|
237
|
+
pipeline = [];
|
|
238
|
+
/**
|
|
239
|
+
* Get the current pipeline
|
|
240
|
+
*/
|
|
241
|
+
get() {
|
|
242
|
+
return [...this.pipeline];
|
|
582
243
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
// src/pagination/utils/filter.ts
|
|
591
|
-
function buildKeysetFilter(baseFilters, sort, cursorValue, cursorId) {
|
|
592
|
-
const primaryField = Object.keys(sort).find((k) => k !== "_id") || "_id";
|
|
593
|
-
const direction = sort[primaryField];
|
|
594
|
-
const operator = direction === 1 ? "$gt" : "$lt";
|
|
595
|
-
return {
|
|
596
|
-
...baseFilters,
|
|
597
|
-
$or: [
|
|
598
|
-
{ [primaryField]: { [operator]: cursorValue } },
|
|
599
|
-
{
|
|
600
|
-
[primaryField]: cursorValue,
|
|
601
|
-
_id: { [operator]: cursorId }
|
|
602
|
-
}
|
|
603
|
-
]
|
|
604
|
-
};
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// src/pagination/utils/limits.ts
|
|
608
|
-
function validateLimit(limit, config) {
|
|
609
|
-
const parsed = Number(limit);
|
|
610
|
-
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
611
|
-
return config.defaultLimit || 10;
|
|
244
|
+
/**
|
|
245
|
+
* Build and return the final pipeline
|
|
246
|
+
*/
|
|
247
|
+
build() {
|
|
248
|
+
return this.get();
|
|
612
249
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
return
|
|
250
|
+
/**
|
|
251
|
+
* Reset the pipeline
|
|
252
|
+
*/
|
|
253
|
+
reset() {
|
|
254
|
+
this.pipeline = [];
|
|
255
|
+
return this;
|
|
619
256
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Add a raw pipeline stage
|
|
259
|
+
*/
|
|
260
|
+
addStage(stage) {
|
|
261
|
+
this.pipeline.push(stage);
|
|
262
|
+
return this;
|
|
623
263
|
}
|
|
624
|
-
return sanitized;
|
|
625
|
-
}
|
|
626
|
-
function shouldWarnDeepPagination(page, threshold) {
|
|
627
|
-
return page > threshold;
|
|
628
|
-
}
|
|
629
|
-
function calculateSkip(page, limit) {
|
|
630
|
-
return (page - 1) * limit;
|
|
631
|
-
}
|
|
632
|
-
function calculateTotalPages(total, limit) {
|
|
633
|
-
return Math.ceil(total / limit);
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// src/pagination/PaginationEngine.ts
|
|
637
|
-
var PaginationEngine = class {
|
|
638
|
-
Model;
|
|
639
|
-
config;
|
|
640
264
|
/**
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
* @param Model - Mongoose model to paginate
|
|
644
|
-
* @param config - Pagination configuration
|
|
265
|
+
* Add multiple raw pipeline stages
|
|
645
266
|
*/
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
this
|
|
649
|
-
this.config = {
|
|
650
|
-
defaultLimit: config.defaultLimit || 10,
|
|
651
|
-
maxLimit: config.maxLimit || 100,
|
|
652
|
-
maxPage: config.maxPage || 1e4,
|
|
653
|
-
deepPageThreshold: config.deepPageThreshold || 100,
|
|
654
|
-
cursorVersion: config.cursorVersion || 1,
|
|
655
|
-
useEstimatedCount: config.useEstimatedCount || false
|
|
656
|
-
};
|
|
267
|
+
addStages(stages) {
|
|
268
|
+
this.pipeline.push(...stages);
|
|
269
|
+
return this;
|
|
657
270
|
}
|
|
271
|
+
// ============================================================
|
|
272
|
+
// CORE AGGREGATION STAGES
|
|
273
|
+
// ============================================================
|
|
658
274
|
/**
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
* O(n) performance - slower for deep pages
|
|
662
|
-
*
|
|
663
|
-
* @param options - Pagination options
|
|
664
|
-
* @returns Pagination result with total count
|
|
665
|
-
*
|
|
666
|
-
* @example
|
|
667
|
-
* const result = await engine.paginate({
|
|
668
|
-
* filters: { status: 'active' },
|
|
669
|
-
* sort: { createdAt: -1 },
|
|
670
|
-
* page: 1,
|
|
671
|
-
* limit: 20
|
|
672
|
-
* });
|
|
673
|
-
* console.log(result.docs, result.total, result.hasNext);
|
|
275
|
+
* $match - Filter documents
|
|
276
|
+
* IMPORTANT: Place $match as early as possible for performance
|
|
674
277
|
*/
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
sort = { _id: -1 },
|
|
679
|
-
page = 1,
|
|
680
|
-
limit = this.config.defaultLimit,
|
|
681
|
-
select,
|
|
682
|
-
populate = [],
|
|
683
|
-
lean = true,
|
|
684
|
-
session
|
|
685
|
-
} = options;
|
|
686
|
-
const sanitizedPage = validatePage(page, this.config);
|
|
687
|
-
const sanitizedLimit = validateLimit(limit, this.config);
|
|
688
|
-
const skip = calculateSkip(sanitizedPage, sanitizedLimit);
|
|
689
|
-
let query = this.Model.find(filters);
|
|
690
|
-
if (select) query = query.select(select);
|
|
691
|
-
if (populate && (Array.isArray(populate) ? populate.length : populate)) {
|
|
692
|
-
query = query.populate(populate);
|
|
693
|
-
}
|
|
694
|
-
query = query.sort(sort).skip(skip).limit(sanitizedLimit).lean(lean);
|
|
695
|
-
if (session) query = query.session(session);
|
|
696
|
-
const hasFilters = Object.keys(filters).length > 0;
|
|
697
|
-
const useEstimated = this.config.useEstimatedCount && !hasFilters;
|
|
698
|
-
const [docs, total] = await Promise.all([
|
|
699
|
-
query.exec(),
|
|
700
|
-
useEstimated ? this.Model.estimatedDocumentCount() : this.Model.countDocuments(filters).session(session ?? null)
|
|
701
|
-
]);
|
|
702
|
-
const totalPages = calculateTotalPages(total, sanitizedLimit);
|
|
703
|
-
const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination (page ${sanitizedPage}). Consider getAll({ after, sort, limit }) for better performance.` : void 0;
|
|
704
|
-
return {
|
|
705
|
-
method: "offset",
|
|
706
|
-
docs,
|
|
707
|
-
page: sanitizedPage,
|
|
708
|
-
limit: sanitizedLimit,
|
|
709
|
-
total,
|
|
710
|
-
pages: totalPages,
|
|
711
|
-
hasNext: sanitizedPage < totalPages,
|
|
712
|
-
hasPrev: sanitizedPage > 1,
|
|
713
|
-
...warning && { warning }
|
|
714
|
-
};
|
|
278
|
+
match(query) {
|
|
279
|
+
this.pipeline.push({ $match: query });
|
|
280
|
+
return this;
|
|
715
281
|
}
|
|
716
282
|
/**
|
|
717
|
-
*
|
|
718
|
-
* Best for large datasets, infinite scroll, real-time feeds
|
|
719
|
-
* O(1) performance - consistent speed regardless of position
|
|
720
|
-
*
|
|
721
|
-
* @param options - Pagination options (sort is required)
|
|
722
|
-
* @returns Pagination result with next cursor
|
|
723
|
-
*
|
|
724
|
-
* @example
|
|
725
|
-
* // First page
|
|
726
|
-
* const page1 = await engine.stream({
|
|
727
|
-
* sort: { createdAt: -1 },
|
|
728
|
-
* limit: 20
|
|
729
|
-
* });
|
|
730
|
-
*
|
|
731
|
-
* // Next page using cursor
|
|
732
|
-
* const page2 = await engine.stream({
|
|
733
|
-
* sort: { createdAt: -1 },
|
|
734
|
-
* after: page1.next,
|
|
735
|
-
* limit: 20
|
|
736
|
-
* });
|
|
283
|
+
* $project - Include/exclude fields or compute new fields
|
|
737
284
|
*/
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
sort,
|
|
742
|
-
after,
|
|
743
|
-
limit = this.config.defaultLimit,
|
|
744
|
-
select,
|
|
745
|
-
populate = [],
|
|
746
|
-
lean = true,
|
|
747
|
-
session
|
|
748
|
-
} = options;
|
|
749
|
-
if (!sort) {
|
|
750
|
-
throw createError(400, "sort is required for keyset pagination");
|
|
751
|
-
}
|
|
752
|
-
const sanitizedLimit = validateLimit(limit, this.config);
|
|
753
|
-
const normalizedSort = validateKeysetSort(sort);
|
|
754
|
-
let query = { ...filters };
|
|
755
|
-
if (after) {
|
|
756
|
-
const cursor = decodeCursor(after);
|
|
757
|
-
validateCursorVersion(cursor.version, this.config.cursorVersion);
|
|
758
|
-
validateCursorSort(cursor.sort, normalizedSort);
|
|
759
|
-
query = buildKeysetFilter(query, normalizedSort, cursor.value, cursor.id);
|
|
760
|
-
}
|
|
761
|
-
let mongoQuery = this.Model.find(query);
|
|
762
|
-
if (select) mongoQuery = mongoQuery.select(select);
|
|
763
|
-
if (populate && (Array.isArray(populate) ? populate.length : populate)) {
|
|
764
|
-
mongoQuery = mongoQuery.populate(populate);
|
|
765
|
-
}
|
|
766
|
-
mongoQuery = mongoQuery.sort(normalizedSort).limit(sanitizedLimit + 1).lean(lean);
|
|
767
|
-
if (session) mongoQuery = mongoQuery.session(session);
|
|
768
|
-
const docs = await mongoQuery.exec();
|
|
769
|
-
const hasMore = docs.length > sanitizedLimit;
|
|
770
|
-
if (hasMore) docs.pop();
|
|
771
|
-
const primaryField = getPrimaryField(normalizedSort);
|
|
772
|
-
const nextCursor = hasMore && docs.length > 0 ? encodeCursor(docs[docs.length - 1], primaryField, normalizedSort, this.config.cursorVersion) : null;
|
|
773
|
-
return {
|
|
774
|
-
method: "keyset",
|
|
775
|
-
docs,
|
|
776
|
-
limit: sanitizedLimit,
|
|
777
|
-
hasMore,
|
|
778
|
-
next: nextCursor
|
|
779
|
-
};
|
|
285
|
+
project(projection) {
|
|
286
|
+
this.pipeline.push({ $project: projection });
|
|
287
|
+
return this;
|
|
780
288
|
}
|
|
781
289
|
/**
|
|
782
|
-
*
|
|
783
|
-
* Best for complex queries requiring aggregation stages
|
|
784
|
-
* Uses $facet to combine results and count in single query
|
|
785
|
-
*
|
|
786
|
-
* @param options - Aggregation options
|
|
787
|
-
* @returns Pagination result with total count
|
|
290
|
+
* $group - Group documents and compute aggregations
|
|
788
291
|
*
|
|
789
292
|
* @example
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
* limit: 20
|
|
798
|
-
* });
|
|
293
|
+
* ```typescript
|
|
294
|
+
* .group({
|
|
295
|
+
* _id: '$department',
|
|
296
|
+
* count: { $sum: 1 },
|
|
297
|
+
* avgSalary: { $avg: '$salary' }
|
|
298
|
+
* })
|
|
299
|
+
* ```
|
|
799
300
|
*/
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
page = 1,
|
|
804
|
-
limit = this.config.defaultLimit,
|
|
805
|
-
session
|
|
806
|
-
} = options;
|
|
807
|
-
const sanitizedPage = validatePage(page, this.config);
|
|
808
|
-
const sanitizedLimit = validateLimit(limit, this.config);
|
|
809
|
-
const skip = calculateSkip(sanitizedPage, sanitizedLimit);
|
|
810
|
-
const facetPipeline = [
|
|
811
|
-
...pipeline,
|
|
812
|
-
{
|
|
813
|
-
$facet: {
|
|
814
|
-
docs: [{ $skip: skip }, { $limit: sanitizedLimit }],
|
|
815
|
-
total: [{ $count: "count" }]
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
];
|
|
819
|
-
const aggregation = this.Model.aggregate(facetPipeline);
|
|
820
|
-
if (session) aggregation.session(session);
|
|
821
|
-
const [result] = await aggregation.exec();
|
|
822
|
-
const docs = result.docs;
|
|
823
|
-
const total = result.total[0]?.count || 0;
|
|
824
|
-
const totalPages = calculateTotalPages(total, sanitizedLimit);
|
|
825
|
-
const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination in aggregate (page ${sanitizedPage}). Uses $skip internally.` : void 0;
|
|
826
|
-
return {
|
|
827
|
-
method: "aggregate",
|
|
828
|
-
docs,
|
|
829
|
-
page: sanitizedPage,
|
|
830
|
-
limit: sanitizedLimit,
|
|
831
|
-
total,
|
|
832
|
-
pages: totalPages,
|
|
833
|
-
hasNext: sanitizedPage < totalPages,
|
|
834
|
-
hasPrev: sanitizedPage > 1,
|
|
835
|
-
...warning && { warning }
|
|
836
|
-
};
|
|
837
|
-
}
|
|
838
|
-
};
|
|
839
|
-
|
|
840
|
-
// src/Repository.ts
|
|
841
|
-
var Repository = class {
|
|
842
|
-
Model;
|
|
843
|
-
model;
|
|
844
|
-
_hooks;
|
|
845
|
-
_pagination;
|
|
846
|
-
_hookMode;
|
|
847
|
-
constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
|
|
848
|
-
this.Model = Model;
|
|
849
|
-
this.model = Model.modelName;
|
|
850
|
-
this._hooks = /* @__PURE__ */ new Map();
|
|
851
|
-
this._pagination = new PaginationEngine(Model, paginationConfig);
|
|
852
|
-
this._hookMode = options.hooks ?? "async";
|
|
853
|
-
plugins.forEach((plugin) => this.use(plugin));
|
|
301
|
+
group(groupSpec) {
|
|
302
|
+
this.pipeline.push({ $group: groupSpec });
|
|
303
|
+
return this;
|
|
854
304
|
}
|
|
855
305
|
/**
|
|
856
|
-
*
|
|
306
|
+
* $sort - Sort documents
|
|
857
307
|
*/
|
|
858
|
-
|
|
859
|
-
if (typeof
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
308
|
+
sort(sortSpec) {
|
|
309
|
+
if (typeof sortSpec === "string") {
|
|
310
|
+
const order = sortSpec.startsWith("-") ? -1 : 1;
|
|
311
|
+
const field = sortSpec.startsWith("-") ? sortSpec.substring(1) : sortSpec;
|
|
312
|
+
this.pipeline.push({ $sort: { [field]: order } });
|
|
313
|
+
} else {
|
|
314
|
+
this.pipeline.push({ $sort: normalizeSortSpec(sortSpec) });
|
|
863
315
|
}
|
|
864
316
|
return this;
|
|
865
317
|
}
|
|
866
318
|
/**
|
|
867
|
-
*
|
|
319
|
+
* $limit - Limit number of documents
|
|
868
320
|
*/
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
this._hooks.set(event, []);
|
|
872
|
-
}
|
|
873
|
-
this._hooks.get(event).push(listener);
|
|
321
|
+
limit(count2) {
|
|
322
|
+
this.pipeline.push({ $limit: count2 });
|
|
874
323
|
return this;
|
|
875
324
|
}
|
|
876
325
|
/**
|
|
877
|
-
*
|
|
326
|
+
* $skip - Skip documents
|
|
878
327
|
*/
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
try {
|
|
883
|
-
const result = listener(data);
|
|
884
|
-
if (result && typeof result.then === "function") {
|
|
885
|
-
void result.catch((error) => {
|
|
886
|
-
if (event === "error:hook") return;
|
|
887
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
888
|
-
this.emit("error:hook", { event, error: err });
|
|
889
|
-
});
|
|
890
|
-
}
|
|
891
|
-
} catch (error) {
|
|
892
|
-
if (event === "error:hook") continue;
|
|
893
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
894
|
-
this.emit("error:hook", { event, error: err });
|
|
895
|
-
}
|
|
896
|
-
}
|
|
328
|
+
skip(count2) {
|
|
329
|
+
this.pipeline.push({ $skip: count2 });
|
|
330
|
+
return this;
|
|
897
331
|
}
|
|
898
332
|
/**
|
|
899
|
-
*
|
|
333
|
+
* $unwind - Deconstruct array field
|
|
334
|
+
*/
|
|
335
|
+
unwind(path, preserveNullAndEmptyArrays = false) {
|
|
336
|
+
this.pipeline.push({
|
|
337
|
+
$unwind: {
|
|
338
|
+
path: path.startsWith("$") ? path : `$${path}`,
|
|
339
|
+
preserveNullAndEmptyArrays
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
return this;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* $addFields - Add new fields or replace existing fields
|
|
346
|
+
*/
|
|
347
|
+
addFields(fields) {
|
|
348
|
+
this.pipeline.push({ $addFields: fields });
|
|
349
|
+
return this;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* $set - Alias for $addFields
|
|
353
|
+
*/
|
|
354
|
+
set(fields) {
|
|
355
|
+
return this.addFields(fields);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* $unset - Remove fields
|
|
359
|
+
*/
|
|
360
|
+
unset(fields) {
|
|
361
|
+
this.pipeline.push({ $unset: fields });
|
|
362
|
+
return this;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* $replaceRoot - Replace the root document
|
|
366
|
+
*/
|
|
367
|
+
replaceRoot(newRoot) {
|
|
368
|
+
this.pipeline.push({
|
|
369
|
+
$replaceRoot: {
|
|
370
|
+
newRoot: typeof newRoot === "string" ? `$${newRoot}` : newRoot
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
return this;
|
|
374
|
+
}
|
|
375
|
+
// ============================================================
|
|
376
|
+
// LOOKUP (JOINS)
|
|
377
|
+
// ============================================================
|
|
378
|
+
/**
|
|
379
|
+
* $lookup - Join with another collection (simple form)
|
|
380
|
+
*
|
|
381
|
+
* @param from - Collection to join with
|
|
382
|
+
* @param localField - Field from source collection
|
|
383
|
+
* @param foreignField - Field from target collection
|
|
384
|
+
* @param as - Output field name
|
|
385
|
+
* @param single - Unwrap array to single object
|
|
386
|
+
*
|
|
387
|
+
* @example
|
|
388
|
+
* ```typescript
|
|
389
|
+
* // Join employees with departments by slug
|
|
390
|
+
* .lookup('departments', 'deptSlug', 'slug', 'department', true)
|
|
391
|
+
* ```
|
|
392
|
+
*/
|
|
393
|
+
lookup(from, localField, foreignField, as, single) {
|
|
394
|
+
const stages = new LookupBuilder(from).localField(localField).foreignField(foreignField).as(as || from).single(single || false).build();
|
|
395
|
+
this.pipeline.push(...stages);
|
|
396
|
+
return this;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* $lookup - Join with another collection (advanced form with pipeline)
|
|
400
|
+
*
|
|
401
|
+
* @example
|
|
402
|
+
* ```typescript
|
|
403
|
+
* .lookupWithPipeline({
|
|
404
|
+
* from: 'products',
|
|
405
|
+
* localField: 'productIds',
|
|
406
|
+
* foreignField: 'sku',
|
|
407
|
+
* as: 'products',
|
|
408
|
+
* pipeline: [
|
|
409
|
+
* { $match: { status: 'active' } },
|
|
410
|
+
* { $project: { name: 1, price: 1 } }
|
|
411
|
+
* ]
|
|
412
|
+
* })
|
|
413
|
+
* ```
|
|
414
|
+
*/
|
|
415
|
+
lookupWithPipeline(options) {
|
|
416
|
+
const builder = new LookupBuilder(options.from).localField(options.localField).foreignField(options.foreignField);
|
|
417
|
+
if (options.as) builder.as(options.as);
|
|
418
|
+
if (options.single) builder.single(options.single);
|
|
419
|
+
if (options.pipeline) builder.pipeline(options.pipeline);
|
|
420
|
+
if (options.let) builder.let(options.let);
|
|
421
|
+
this.pipeline.push(...builder.build());
|
|
422
|
+
return this;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Multiple lookups at once
|
|
426
|
+
*
|
|
427
|
+
* @example
|
|
428
|
+
* ```typescript
|
|
429
|
+
* .multiLookup([
|
|
430
|
+
* { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
|
|
431
|
+
* { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
|
|
432
|
+
* ])
|
|
433
|
+
* ```
|
|
434
|
+
*/
|
|
435
|
+
multiLookup(lookups) {
|
|
436
|
+
const stages = LookupBuilder.multiple(lookups);
|
|
437
|
+
this.pipeline.push(...stages);
|
|
438
|
+
return this;
|
|
439
|
+
}
|
|
440
|
+
// ============================================================
|
|
441
|
+
// ADVANCED OPERATORS (MongoDB 6+)
|
|
442
|
+
// ============================================================
|
|
443
|
+
/**
|
|
444
|
+
* $facet - Process multiple aggregation pipelines in a single stage
|
|
445
|
+
* Useful for computing multiple aggregations in parallel
|
|
446
|
+
*
|
|
447
|
+
* @example
|
|
448
|
+
* ```typescript
|
|
449
|
+
* .facet({
|
|
450
|
+
* totalCount: [{ $count: 'count' }],
|
|
451
|
+
* avgPrice: [{ $group: { _id: null, avg: { $avg: '$price' } } }],
|
|
452
|
+
* topProducts: [{ $sort: { sales: -1 } }, { $limit: 10 }]
|
|
453
|
+
* })
|
|
454
|
+
* ```
|
|
455
|
+
*/
|
|
456
|
+
facet(facets) {
|
|
457
|
+
this.pipeline.push({ $facet: facets });
|
|
458
|
+
return this;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* $bucket - Categorize documents into buckets
|
|
462
|
+
*
|
|
463
|
+
* @example
|
|
464
|
+
* ```typescript
|
|
465
|
+
* .bucket({
|
|
466
|
+
* groupBy: '$price',
|
|
467
|
+
* boundaries: [0, 50, 100, 200],
|
|
468
|
+
* default: 'Other',
|
|
469
|
+
* output: {
|
|
470
|
+
* count: { $sum: 1 },
|
|
471
|
+
* products: { $push: '$name' }
|
|
472
|
+
* }
|
|
473
|
+
* })
|
|
474
|
+
* ```
|
|
475
|
+
*/
|
|
476
|
+
bucket(options) {
|
|
477
|
+
this.pipeline.push({ $bucket: options });
|
|
478
|
+
return this;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* $bucketAuto - Automatically determine bucket boundaries
|
|
482
|
+
*/
|
|
483
|
+
bucketAuto(options) {
|
|
484
|
+
this.pipeline.push({ $bucketAuto: options });
|
|
485
|
+
return this;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* $setWindowFields - Perform window functions (MongoDB 5.0+)
|
|
489
|
+
* Useful for rankings, running totals, moving averages
|
|
490
|
+
*
|
|
491
|
+
* @example
|
|
492
|
+
* ```typescript
|
|
493
|
+
* .setWindowFields({
|
|
494
|
+
* partitionBy: '$department',
|
|
495
|
+
* sortBy: { salary: -1 },
|
|
496
|
+
* output: {
|
|
497
|
+
* rank: { $rank: {} },
|
|
498
|
+
* runningTotal: { $sum: '$salary', window: { documents: ['unbounded', 'current'] } }
|
|
499
|
+
* }
|
|
500
|
+
* })
|
|
501
|
+
* ```
|
|
502
|
+
*/
|
|
503
|
+
setWindowFields(options) {
|
|
504
|
+
const normalizedOptions = {
|
|
505
|
+
...options,
|
|
506
|
+
sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
|
|
507
|
+
};
|
|
508
|
+
this.pipeline.push({ $setWindowFields: normalizedOptions });
|
|
509
|
+
return this;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* $unionWith - Combine results from multiple collections (MongoDB 4.4+)
|
|
513
|
+
*
|
|
514
|
+
* @example
|
|
515
|
+
* ```typescript
|
|
516
|
+
* .unionWith({
|
|
517
|
+
* coll: 'archivedOrders',
|
|
518
|
+
* pipeline: [{ $match: { year: 2024 } }]
|
|
519
|
+
* })
|
|
520
|
+
* ```
|
|
521
|
+
*/
|
|
522
|
+
unionWith(options) {
|
|
523
|
+
this.pipeline.push({ $unionWith: options });
|
|
524
|
+
return this;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* $densify - Fill gaps in data (MongoDB 5.1+)
|
|
528
|
+
* Useful for time series data with missing points
|
|
529
|
+
*/
|
|
530
|
+
densify(options) {
|
|
531
|
+
this.pipeline.push({ $densify: options });
|
|
532
|
+
return this;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* $fill - Fill null or missing field values (MongoDB 5.3+)
|
|
536
|
+
*/
|
|
537
|
+
fill(options) {
|
|
538
|
+
const normalizedOptions = {
|
|
539
|
+
...options,
|
|
540
|
+
sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
|
|
541
|
+
};
|
|
542
|
+
this.pipeline.push({ $fill: normalizedOptions });
|
|
543
|
+
return this;
|
|
544
|
+
}
|
|
545
|
+
// ============================================================
|
|
546
|
+
// UTILITY METHODS
|
|
547
|
+
// ============================================================
|
|
548
|
+
/**
|
|
549
|
+
* Paginate - Add skip and limit for offset-based pagination
|
|
550
|
+
*/
|
|
551
|
+
paginate(page, limit) {
|
|
552
|
+
const skip = (page - 1) * limit;
|
|
553
|
+
return this.skip(skip).limit(limit);
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Count total documents (useful with $facet for pagination metadata)
|
|
557
|
+
*/
|
|
558
|
+
count(outputField = "count") {
|
|
559
|
+
this.pipeline.push({ $count: outputField });
|
|
560
|
+
return this;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Sample - Randomly select N documents
|
|
564
|
+
*/
|
|
565
|
+
sample(size) {
|
|
566
|
+
this.pipeline.push({ $sample: { size } });
|
|
567
|
+
return this;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Out - Write results to a collection
|
|
571
|
+
*/
|
|
572
|
+
out(collection) {
|
|
573
|
+
this.pipeline.push({ $out: collection });
|
|
574
|
+
return this;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Merge - Merge results into a collection
|
|
578
|
+
*/
|
|
579
|
+
merge(options) {
|
|
580
|
+
this.pipeline.push({
|
|
581
|
+
$merge: typeof options === "string" ? { into: options } : options
|
|
582
|
+
});
|
|
583
|
+
return this;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* GeoNear - Perform geospatial queries
|
|
587
|
+
*/
|
|
588
|
+
geoNear(options) {
|
|
589
|
+
this.pipeline.push({ $geoNear: options });
|
|
590
|
+
return this;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* GraphLookup - Perform recursive search (graph traversal)
|
|
594
|
+
*/
|
|
595
|
+
graphLookup(options) {
|
|
596
|
+
this.pipeline.push({ $graphLookup: options });
|
|
597
|
+
return this;
|
|
598
|
+
}
|
|
599
|
+
// ============================================================
|
|
600
|
+
// ATLAS SEARCH (MongoDB Atlas only)
|
|
601
|
+
// ============================================================
|
|
602
|
+
/**
|
|
603
|
+
* $search - Atlas Search full-text search (Atlas only)
|
|
604
|
+
*
|
|
605
|
+
* @example
|
|
606
|
+
* ```typescript
|
|
607
|
+
* .search({
|
|
608
|
+
* index: 'default',
|
|
609
|
+
* text: {
|
|
610
|
+
* query: 'laptop computer',
|
|
611
|
+
* path: ['title', 'description'],
|
|
612
|
+
* fuzzy: { maxEdits: 2 }
|
|
613
|
+
* }
|
|
614
|
+
* })
|
|
615
|
+
* ```
|
|
616
|
+
*/
|
|
617
|
+
search(options) {
|
|
618
|
+
this.pipeline.push({ $search: options });
|
|
619
|
+
return this;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* $searchMeta - Get Atlas Search metadata (Atlas only)
|
|
623
|
+
*/
|
|
624
|
+
searchMeta(options) {
|
|
625
|
+
this.pipeline.push({ $searchMeta: options });
|
|
626
|
+
return this;
|
|
627
|
+
}
|
|
628
|
+
// ============================================================
|
|
629
|
+
// HELPER FACTORY METHODS
|
|
630
|
+
// ============================================================
|
|
631
|
+
/**
|
|
632
|
+
* Create a builder from an existing pipeline
|
|
633
|
+
*/
|
|
634
|
+
static from(pipeline) {
|
|
635
|
+
const builder = new _AggregationBuilder();
|
|
636
|
+
builder.pipeline = [...pipeline];
|
|
637
|
+
return builder;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Create a builder with initial match stage
|
|
641
|
+
*/
|
|
642
|
+
static startWith(query) {
|
|
643
|
+
return new _AggregationBuilder().match(query);
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
// src/Repository.ts
|
|
648
|
+
var Repository = class {
|
|
649
|
+
Model;
|
|
650
|
+
model;
|
|
651
|
+
_hooks;
|
|
652
|
+
_pagination;
|
|
653
|
+
_hookMode;
|
|
654
|
+
constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
|
|
655
|
+
this.Model = Model;
|
|
656
|
+
this.model = Model.modelName;
|
|
657
|
+
this._hooks = /* @__PURE__ */ new Map();
|
|
658
|
+
this._pagination = new PaginationEngine(Model, paginationConfig);
|
|
659
|
+
this._hookMode = options.hooks ?? "async";
|
|
660
|
+
plugins.forEach((plugin) => this.use(plugin));
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Register a plugin
|
|
664
|
+
*/
|
|
665
|
+
use(plugin) {
|
|
666
|
+
if (typeof plugin === "function") {
|
|
667
|
+
plugin(this);
|
|
668
|
+
} else if (plugin && typeof plugin.apply === "function") {
|
|
669
|
+
plugin.apply(this);
|
|
670
|
+
}
|
|
671
|
+
return this;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Register event listener
|
|
675
|
+
*/
|
|
676
|
+
on(event, listener) {
|
|
677
|
+
if (!this._hooks.has(event)) {
|
|
678
|
+
this._hooks.set(event, []);
|
|
679
|
+
}
|
|
680
|
+
this._hooks.get(event).push(listener);
|
|
681
|
+
return this;
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Emit event (sync - for backwards compatibility)
|
|
685
|
+
*/
|
|
686
|
+
emit(event, data) {
|
|
687
|
+
const listeners = this._hooks.get(event) || [];
|
|
688
|
+
for (const listener of listeners) {
|
|
689
|
+
try {
|
|
690
|
+
const result = listener(data);
|
|
691
|
+
if (result && typeof result.then === "function") {
|
|
692
|
+
void result.catch((error) => {
|
|
693
|
+
if (event === "error:hook") return;
|
|
694
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
695
|
+
this.emit("error:hook", { event, error: err });
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
} catch (error) {
|
|
699
|
+
if (event === "error:hook") continue;
|
|
700
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
701
|
+
this.emit("error:hook", { event, error: err });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Emit event and await all async handlers
|
|
900
707
|
*/
|
|
901
708
|
async emitAsync(event, data) {
|
|
902
709
|
const listeners = this._hooks.get(event) || [];
|
|
@@ -965,7 +772,8 @@ var Repository = class {
|
|
|
965
772
|
if (context._cacheHit) {
|
|
966
773
|
return context._cachedResult;
|
|
967
774
|
}
|
|
968
|
-
const
|
|
775
|
+
const finalQuery = context.query || query;
|
|
776
|
+
const result = await getByQuery(this.Model, finalQuery, context);
|
|
969
777
|
await this._emitHook("after:getByQuery", { context, result });
|
|
970
778
|
return result;
|
|
971
779
|
}
|
|
@@ -999,8 +807,8 @@ var Repository = class {
|
|
|
999
807
|
}
|
|
1000
808
|
const hasPageParam = params.page !== void 0 || params.pagination;
|
|
1001
809
|
const hasCursorParam = "cursor" in params || "after" in params;
|
|
1002
|
-
const
|
|
1003
|
-
const useKeyset = !hasPageParam && (hasCursorParam ||
|
|
810
|
+
const hasSortParam = params.sort !== void 0;
|
|
811
|
+
const useKeyset = !hasPageParam && (hasCursorParam || hasSortParam);
|
|
1004
812
|
const filters = context.filters || params.filters || {};
|
|
1005
813
|
const search = params.search;
|
|
1006
814
|
const sort = params.sort || "-createdAt";
|
|
@@ -1106,1573 +914,246 @@ var Repository = class {
|
|
|
1106
914
|
return distinct(this.Model, field, query, options);
|
|
1107
915
|
}
|
|
1108
916
|
/**
|
|
1109
|
-
*
|
|
917
|
+
* Query with custom field lookups ($lookup)
|
|
918
|
+
* Best for: Joins on slugs, SKUs, codes, or other indexed custom fields
|
|
919
|
+
*
|
|
920
|
+
* @example
|
|
921
|
+
* ```typescript
|
|
922
|
+
* // Join employees with departments using slug instead of ObjectId
|
|
923
|
+
* const employees = await employeeRepo.lookupPopulate({
|
|
924
|
+
* filters: { status: 'active' },
|
|
925
|
+
* lookups: [
|
|
926
|
+
* {
|
|
927
|
+
* from: 'departments',
|
|
928
|
+
* localField: 'departmentSlug',
|
|
929
|
+
* foreignField: 'slug',
|
|
930
|
+
* as: 'department',
|
|
931
|
+
* single: true
|
|
932
|
+
* }
|
|
933
|
+
* ],
|
|
934
|
+
* sort: '-createdAt',
|
|
935
|
+
* page: 1,
|
|
936
|
+
* limit: 50
|
|
937
|
+
* });
|
|
938
|
+
* ```
|
|
1110
939
|
*/
|
|
1111
|
-
async
|
|
1112
|
-
const
|
|
1113
|
-
let started = false;
|
|
940
|
+
async lookupPopulate(options) {
|
|
941
|
+
const context = await this._buildContext("lookupPopulate", options);
|
|
1114
942
|
try {
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
await session.commitTransaction();
|
|
1119
|
-
return result;
|
|
1120
|
-
} catch (error) {
|
|
1121
|
-
const err = error;
|
|
1122
|
-
if (options.allowFallback && this._isTransactionUnsupported(err)) {
|
|
1123
|
-
if (typeof options.onFallback === "function") {
|
|
1124
|
-
options.onFallback(err);
|
|
1125
|
-
}
|
|
1126
|
-
if (started && session.inTransaction()) {
|
|
1127
|
-
try {
|
|
1128
|
-
await session.abortTransaction();
|
|
1129
|
-
} catch {
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
return await callback(null);
|
|
943
|
+
const builder = new AggregationBuilder();
|
|
944
|
+
if (options.filters && Object.keys(options.filters).length > 0) {
|
|
945
|
+
builder.match(options.filters);
|
|
1133
946
|
}
|
|
1134
|
-
|
|
1135
|
-
|
|
947
|
+
builder.multiLookup(options.lookups);
|
|
948
|
+
if (options.sort) {
|
|
949
|
+
builder.sort(this._parseSort(options.sort));
|
|
1136
950
|
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
}
|
|
1146
|
-
/**
|
|
1147
|
-
* Execute custom query with event emission
|
|
1148
|
-
*/
|
|
1149
|
-
async _executeQuery(buildQuery) {
|
|
1150
|
-
const operation = buildQuery.name || "custom";
|
|
1151
|
-
const context = await this._buildContext(operation, {});
|
|
1152
|
-
try {
|
|
1153
|
-
const result = await buildQuery(this.Model);
|
|
1154
|
-
await this._emitHook(`after:${operation}`, { context, result });
|
|
1155
|
-
return result;
|
|
1156
|
-
} catch (error) {
|
|
1157
|
-
await this._emitErrorHook(`error:${operation}`, { context, error });
|
|
1158
|
-
throw this._handleError(error);
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
/**
|
|
1162
|
-
* Build operation context and run before hooks
|
|
1163
|
-
*/
|
|
1164
|
-
async _buildContext(operation, options) {
|
|
1165
|
-
const context = { operation, model: this.model, ...options };
|
|
1166
|
-
const event = `before:${operation}`;
|
|
1167
|
-
const hooks = this._hooks.get(event) || [];
|
|
1168
|
-
for (const hook of hooks) {
|
|
1169
|
-
await hook(context);
|
|
1170
|
-
}
|
|
1171
|
-
return context;
|
|
1172
|
-
}
|
|
1173
|
-
/**
|
|
1174
|
-
* Parse sort string or object
|
|
1175
|
-
*/
|
|
1176
|
-
_parseSort(sort) {
|
|
1177
|
-
if (!sort) return { createdAt: -1 };
|
|
1178
|
-
if (typeof sort === "object") return sort;
|
|
1179
|
-
const sortOrder = sort.startsWith("-") ? -1 : 1;
|
|
1180
|
-
const sortField = sort.startsWith("-") ? sort.substring(1) : sort;
|
|
1181
|
-
return { [sortField]: sortOrder };
|
|
1182
|
-
}
|
|
1183
|
-
/**
|
|
1184
|
-
* Parse populate specification
|
|
1185
|
-
*/
|
|
1186
|
-
_parsePopulate(populate) {
|
|
1187
|
-
if (!populate) return [];
|
|
1188
|
-
if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
|
|
1189
|
-
if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
|
|
1190
|
-
return [populate];
|
|
1191
|
-
}
|
|
1192
|
-
/**
|
|
1193
|
-
* Handle errors with proper HTTP status codes
|
|
1194
|
-
*/
|
|
1195
|
-
_handleError(error) {
|
|
1196
|
-
if (error instanceof mongoose4.Error.ValidationError) {
|
|
1197
|
-
const messages = Object.values(error.errors).map((err) => err.message);
|
|
1198
|
-
return createError(400, `Validation Error: ${messages.join(", ")}`);
|
|
1199
|
-
}
|
|
1200
|
-
if (error instanceof mongoose4.Error.CastError) {
|
|
1201
|
-
return createError(400, `Invalid ${error.path}: ${error.value}`);
|
|
1202
|
-
}
|
|
1203
|
-
if (error.status && error.message) return error;
|
|
1204
|
-
return createError(500, error.message || "Internal Server Error");
|
|
1205
|
-
}
|
|
1206
|
-
};
|
|
1207
|
-
|
|
1208
|
-
// src/utils/field-selection.ts
|
|
1209
|
-
function getFieldsForUser(user, preset) {
|
|
1210
|
-
if (!preset) {
|
|
1211
|
-
throw new Error("Field preset is required");
|
|
1212
|
-
}
|
|
1213
|
-
const fields = [...preset.public || []];
|
|
1214
|
-
if (user) {
|
|
1215
|
-
fields.push(...preset.authenticated || []);
|
|
1216
|
-
const roles = Array.isArray(user.roles) ? user.roles : user.roles ? [user.roles] : [];
|
|
1217
|
-
if (roles.includes("admin") || roles.includes("superadmin")) {
|
|
1218
|
-
fields.push(...preset.admin || []);
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
return [...new Set(fields)];
|
|
1222
|
-
}
|
|
1223
|
-
function getMongooseProjection(user, preset) {
|
|
1224
|
-
const fields = getFieldsForUser(user, preset);
|
|
1225
|
-
return fields.join(" ");
|
|
1226
|
-
}
|
|
1227
|
-
function filterObject(obj, allowedFields) {
|
|
1228
|
-
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
1229
|
-
return obj;
|
|
1230
|
-
}
|
|
1231
|
-
const filtered = {};
|
|
1232
|
-
for (const field of allowedFields) {
|
|
1233
|
-
if (field in obj) {
|
|
1234
|
-
filtered[field] = obj[field];
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
return filtered;
|
|
1238
|
-
}
|
|
1239
|
-
function filterResponseData(data, preset, user = null) {
|
|
1240
|
-
const allowedFields = getFieldsForUser(user, preset);
|
|
1241
|
-
if (Array.isArray(data)) {
|
|
1242
|
-
return data.map((item) => filterObject(item, allowedFields));
|
|
1243
|
-
}
|
|
1244
|
-
return filterObject(data, allowedFields);
|
|
1245
|
-
}
|
|
1246
|
-
function createFieldPreset(config) {
|
|
1247
|
-
return {
|
|
1248
|
-
public: config.public || [],
|
|
1249
|
-
authenticated: config.authenticated || [],
|
|
1250
|
-
admin: config.admin || []
|
|
1251
|
-
};
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
// src/plugins/field-filter.plugin.ts
|
|
1255
|
-
function fieldFilterPlugin(fieldPreset) {
|
|
1256
|
-
return {
|
|
1257
|
-
name: "fieldFilter",
|
|
1258
|
-
apply(repo) {
|
|
1259
|
-
const applyFieldFiltering = (context) => {
|
|
1260
|
-
if (!fieldPreset) return;
|
|
1261
|
-
const user = context.context?.user || context.user;
|
|
1262
|
-
const fields = getFieldsForUser(user, fieldPreset);
|
|
1263
|
-
const presetSelect = fields.join(" ");
|
|
1264
|
-
if (context.select) {
|
|
1265
|
-
context.select = `${presetSelect} ${context.select}`;
|
|
1266
|
-
} else {
|
|
1267
|
-
context.select = presetSelect;
|
|
1268
|
-
}
|
|
1269
|
-
};
|
|
1270
|
-
repo.on("before:getAll", applyFieldFiltering);
|
|
1271
|
-
repo.on("before:getById", applyFieldFiltering);
|
|
1272
|
-
repo.on("before:getByQuery", applyFieldFiltering);
|
|
1273
|
-
}
|
|
1274
|
-
};
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
// src/plugins/timestamp.plugin.ts
|
|
1278
|
-
function timestampPlugin() {
|
|
1279
|
-
return {
|
|
1280
|
-
name: "timestamp",
|
|
1281
|
-
apply(repo) {
|
|
1282
|
-
repo.on("before:create", (context) => {
|
|
1283
|
-
if (!context.data) return;
|
|
1284
|
-
const now = /* @__PURE__ */ new Date();
|
|
1285
|
-
if (!context.data.createdAt) context.data.createdAt = now;
|
|
1286
|
-
if (!context.data.updatedAt) context.data.updatedAt = now;
|
|
1287
|
-
});
|
|
1288
|
-
repo.on("before:update", (context) => {
|
|
1289
|
-
if (!context.data) return;
|
|
1290
|
-
context.data.updatedAt = /* @__PURE__ */ new Date();
|
|
1291
|
-
});
|
|
1292
|
-
}
|
|
1293
|
-
};
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
// src/plugins/audit-log.plugin.ts
|
|
1297
|
-
function auditLogPlugin(logger) {
|
|
1298
|
-
return {
|
|
1299
|
-
name: "auditLog",
|
|
1300
|
-
apply(repo) {
|
|
1301
|
-
repo.on("after:create", ({ context, result }) => {
|
|
1302
|
-
logger?.info?.("Document created", {
|
|
1303
|
-
model: context.model || repo.model,
|
|
1304
|
-
id: result?._id,
|
|
1305
|
-
userId: context.user?._id || context.user?.id,
|
|
1306
|
-
organizationId: context.organizationId
|
|
1307
|
-
});
|
|
1308
|
-
});
|
|
1309
|
-
repo.on("after:update", ({ context, result }) => {
|
|
1310
|
-
logger?.info?.("Document updated", {
|
|
1311
|
-
model: context.model || repo.model,
|
|
1312
|
-
id: context.id || result?._id,
|
|
1313
|
-
userId: context.user?._id || context.user?.id,
|
|
1314
|
-
organizationId: context.organizationId
|
|
1315
|
-
});
|
|
1316
|
-
});
|
|
1317
|
-
repo.on("after:delete", ({ context }) => {
|
|
1318
|
-
logger?.info?.("Document deleted", {
|
|
1319
|
-
model: context.model || repo.model,
|
|
1320
|
-
id: context.id,
|
|
1321
|
-
userId: context.user?._id || context.user?.id,
|
|
1322
|
-
organizationId: context.organizationId
|
|
1323
|
-
});
|
|
1324
|
-
});
|
|
1325
|
-
repo.on("error:create", ({ context, error }) => {
|
|
1326
|
-
logger?.error?.("Create failed", {
|
|
1327
|
-
model: context.model || repo.model,
|
|
1328
|
-
error: error.message,
|
|
1329
|
-
userId: context.user?._id || context.user?.id
|
|
1330
|
-
});
|
|
1331
|
-
});
|
|
1332
|
-
repo.on("error:update", ({ context, error }) => {
|
|
1333
|
-
logger?.error?.("Update failed", {
|
|
1334
|
-
model: context.model || repo.model,
|
|
1335
|
-
id: context.id,
|
|
1336
|
-
error: error.message,
|
|
1337
|
-
userId: context.user?._id || context.user?.id
|
|
1338
|
-
});
|
|
1339
|
-
});
|
|
1340
|
-
repo.on("error:delete", ({ context, error }) => {
|
|
1341
|
-
logger?.error?.("Delete failed", {
|
|
1342
|
-
model: context.model || repo.model,
|
|
1343
|
-
id: context.id,
|
|
1344
|
-
error: error.message,
|
|
1345
|
-
userId: context.user?._id || context.user?.id
|
|
1346
|
-
});
|
|
1347
|
-
});
|
|
1348
|
-
}
|
|
1349
|
-
};
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
// src/plugins/soft-delete.plugin.ts
|
|
1353
|
-
function buildDeletedFilter(deletedField, filterMode, includeDeleted) {
|
|
1354
|
-
if (includeDeleted) {
|
|
1355
|
-
return {};
|
|
1356
|
-
}
|
|
1357
|
-
if (filterMode === "exists") {
|
|
1358
|
-
return { [deletedField]: { $exists: false } };
|
|
1359
|
-
}
|
|
1360
|
-
return { [deletedField]: null };
|
|
1361
|
-
}
|
|
1362
|
-
function buildGetDeletedFilter(deletedField, filterMode) {
|
|
1363
|
-
if (filterMode === "exists") {
|
|
1364
|
-
return { [deletedField]: { $exists: true, $ne: null } };
|
|
1365
|
-
}
|
|
1366
|
-
return { [deletedField]: { $ne: null } };
|
|
1367
|
-
}
|
|
1368
|
-
function softDeletePlugin(options = {}) {
|
|
1369
|
-
const deletedField = options.deletedField || "deletedAt";
|
|
1370
|
-
const deletedByField = options.deletedByField || "deletedBy";
|
|
1371
|
-
const filterMode = options.filterMode || "null";
|
|
1372
|
-
const addRestoreMethod = options.addRestoreMethod !== false;
|
|
1373
|
-
const addGetDeletedMethod = options.addGetDeletedMethod !== false;
|
|
1374
|
-
const ttlDays = options.ttlDays;
|
|
1375
|
-
return {
|
|
1376
|
-
name: "softDelete",
|
|
1377
|
-
apply(repo) {
|
|
1378
|
-
if (ttlDays !== void 0 && ttlDays > 0) {
|
|
1379
|
-
const ttlSeconds = ttlDays * 24 * 60 * 60;
|
|
1380
|
-
repo.Model.collection.createIndex(
|
|
1381
|
-
{ [deletedField]: 1 },
|
|
1382
|
-
{
|
|
1383
|
-
expireAfterSeconds: ttlSeconds,
|
|
1384
|
-
partialFilterExpression: { [deletedField]: { $type: "date" } }
|
|
1385
|
-
}
|
|
1386
|
-
).catch((err) => {
|
|
1387
|
-
if (!err.message.includes("already exists")) {
|
|
1388
|
-
console.warn(`[softDeletePlugin] Failed to create TTL index: ${err.message}`);
|
|
1389
|
-
}
|
|
1390
|
-
});
|
|
1391
|
-
}
|
|
1392
|
-
repo.on("before:delete", async (context) => {
|
|
1393
|
-
if (options.soft !== false) {
|
|
1394
|
-
const updateData = {
|
|
1395
|
-
[deletedField]: /* @__PURE__ */ new Date()
|
|
1396
|
-
};
|
|
1397
|
-
if (context.user) {
|
|
1398
|
-
updateData[deletedByField] = context.user._id || context.user.id;
|
|
1399
|
-
}
|
|
1400
|
-
await repo.Model.findByIdAndUpdate(context.id, updateData, { session: context.session });
|
|
1401
|
-
context.softDeleted = true;
|
|
1402
|
-
}
|
|
1403
|
-
});
|
|
1404
|
-
repo.on("before:getAll", (context) => {
|
|
1405
|
-
if (options.soft !== false) {
|
|
1406
|
-
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
1407
|
-
if (Object.keys(deleteFilter).length > 0) {
|
|
1408
|
-
const existingFilters = context.filters || {};
|
|
1409
|
-
context.filters = {
|
|
1410
|
-
...existingFilters,
|
|
1411
|
-
...deleteFilter
|
|
1412
|
-
};
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
});
|
|
1416
|
-
repo.on("before:getById", (context) => {
|
|
1417
|
-
if (options.soft !== false) {
|
|
1418
|
-
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
1419
|
-
if (Object.keys(deleteFilter).length > 0) {
|
|
1420
|
-
context.query = {
|
|
1421
|
-
...context.query || {},
|
|
1422
|
-
...deleteFilter
|
|
1423
|
-
};
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
|
-
});
|
|
1427
|
-
repo.on("before:getByQuery", (context) => {
|
|
1428
|
-
if (options.soft !== false) {
|
|
1429
|
-
const deleteFilter = buildDeletedFilter(deletedField, filterMode, !!context.includeDeleted);
|
|
1430
|
-
if (Object.keys(deleteFilter).length > 0) {
|
|
1431
|
-
context.query = {
|
|
1432
|
-
...context.query || {},
|
|
1433
|
-
...deleteFilter
|
|
1434
|
-
};
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
});
|
|
1438
|
-
if (addRestoreMethod) {
|
|
1439
|
-
const restoreMethod = async function(id, restoreOptions = {}) {
|
|
1440
|
-
const updateData = {
|
|
1441
|
-
[deletedField]: null,
|
|
1442
|
-
[deletedByField]: null
|
|
1443
|
-
};
|
|
1444
|
-
const result = await this.Model.findByIdAndUpdate(id, { $set: updateData }, {
|
|
1445
|
-
new: true,
|
|
1446
|
-
session: restoreOptions.session
|
|
1447
|
-
});
|
|
1448
|
-
if (!result) {
|
|
1449
|
-
const error = new Error(`Document with id '${id}' not found`);
|
|
1450
|
-
error.status = 404;
|
|
1451
|
-
throw error;
|
|
1452
|
-
}
|
|
1453
|
-
await this.emitAsync("after:restore", { id, result });
|
|
1454
|
-
return result;
|
|
1455
|
-
};
|
|
1456
|
-
if (typeof repo.registerMethod === "function") {
|
|
1457
|
-
repo.registerMethod("restore", restoreMethod);
|
|
1458
|
-
} else {
|
|
1459
|
-
repo.restore = restoreMethod.bind(repo);
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
if (addGetDeletedMethod) {
|
|
1463
|
-
const getDeletedMethod = async function(params = {}, getDeletedOptions = {}) {
|
|
1464
|
-
const deletedFilter = buildGetDeletedFilter(deletedField, filterMode);
|
|
1465
|
-
const combinedFilters = {
|
|
1466
|
-
...params.filters || {},
|
|
1467
|
-
...deletedFilter
|
|
1468
|
-
};
|
|
1469
|
-
const page = params.page || 1;
|
|
1470
|
-
const limit = params.limit || 20;
|
|
1471
|
-
const skip = (page - 1) * limit;
|
|
1472
|
-
let sortSpec = { [deletedField]: -1 };
|
|
1473
|
-
if (params.sort) {
|
|
1474
|
-
if (typeof params.sort === "string") {
|
|
1475
|
-
const sortOrder = params.sort.startsWith("-") ? -1 : 1;
|
|
1476
|
-
const sortField = params.sort.startsWith("-") ? params.sort.substring(1) : params.sort;
|
|
1477
|
-
sortSpec = { [sortField]: sortOrder };
|
|
1478
|
-
} else {
|
|
1479
|
-
sortSpec = params.sort;
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
let query = this.Model.find(combinedFilters).sort(sortSpec).skip(skip).limit(limit);
|
|
1483
|
-
if (getDeletedOptions.session) {
|
|
1484
|
-
query = query.session(getDeletedOptions.session);
|
|
1485
|
-
}
|
|
1486
|
-
if (getDeletedOptions.select) {
|
|
1487
|
-
const selectValue = Array.isArray(getDeletedOptions.select) ? getDeletedOptions.select.join(" ") : getDeletedOptions.select;
|
|
1488
|
-
query = query.select(selectValue);
|
|
1489
|
-
}
|
|
1490
|
-
if (getDeletedOptions.populate) {
|
|
1491
|
-
const populateSpec = getDeletedOptions.populate;
|
|
1492
|
-
if (typeof populateSpec === "string") {
|
|
1493
|
-
query = query.populate(populateSpec.split(",").map((p) => p.trim()));
|
|
1494
|
-
} else if (Array.isArray(populateSpec)) {
|
|
1495
|
-
query = query.populate(populateSpec);
|
|
1496
|
-
} else {
|
|
1497
|
-
query = query.populate(populateSpec);
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
if (getDeletedOptions.lean !== false) {
|
|
1501
|
-
query = query.lean();
|
|
1502
|
-
}
|
|
1503
|
-
const [docs, total] = await Promise.all([
|
|
1504
|
-
query.exec(),
|
|
1505
|
-
this.Model.countDocuments(combinedFilters)
|
|
1506
|
-
]);
|
|
1507
|
-
const pages = Math.ceil(total / limit);
|
|
1508
|
-
return {
|
|
1509
|
-
method: "offset",
|
|
1510
|
-
docs,
|
|
1511
|
-
page,
|
|
1512
|
-
limit,
|
|
1513
|
-
total,
|
|
1514
|
-
pages,
|
|
1515
|
-
hasNext: page < pages,
|
|
1516
|
-
hasPrev: page > 1
|
|
1517
|
-
};
|
|
1518
|
-
};
|
|
1519
|
-
if (typeof repo.registerMethod === "function") {
|
|
1520
|
-
repo.registerMethod("getDeleted", getDeletedMethod);
|
|
1521
|
-
} else {
|
|
1522
|
-
repo.getDeleted = getDeletedMethod.bind(repo);
|
|
1523
|
-
}
|
|
1524
|
-
}
|
|
1525
|
-
}
|
|
1526
|
-
};
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
// src/plugins/method-registry.plugin.ts
|
|
1530
|
-
function methodRegistryPlugin() {
|
|
1531
|
-
return {
|
|
1532
|
-
name: "method-registry",
|
|
1533
|
-
apply(repo) {
|
|
1534
|
-
const registeredMethods = [];
|
|
1535
|
-
repo.registerMethod = function(name, fn) {
|
|
1536
|
-
if (repo[name]) {
|
|
1537
|
-
throw new Error(
|
|
1538
|
-
`Cannot register method '${name}': Method already exists on repository. Choose a different name or use a plugin that doesn't conflict.`
|
|
1539
|
-
);
|
|
1540
|
-
}
|
|
1541
|
-
if (!name || typeof name !== "string") {
|
|
1542
|
-
throw new Error("Method name must be a non-empty string");
|
|
1543
|
-
}
|
|
1544
|
-
if (typeof fn !== "function") {
|
|
1545
|
-
throw new Error(`Method '${name}' must be a function`);
|
|
1546
|
-
}
|
|
1547
|
-
repo[name] = fn.bind(repo);
|
|
1548
|
-
registeredMethods.push(name);
|
|
1549
|
-
repo.emit("method:registered", { name, fn });
|
|
1550
|
-
};
|
|
1551
|
-
repo.hasMethod = function(name) {
|
|
1552
|
-
return typeof repo[name] === "function";
|
|
1553
|
-
};
|
|
1554
|
-
repo.getRegisteredMethods = function() {
|
|
1555
|
-
return [...registeredMethods];
|
|
1556
|
-
};
|
|
1557
|
-
}
|
|
1558
|
-
};
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
// src/plugins/validation-chain.plugin.ts
|
|
1562
|
-
function validationChainPlugin(validators = [], options = {}) {
|
|
1563
|
-
const { stopOnFirstError = true } = options;
|
|
1564
|
-
validators.forEach((v, idx) => {
|
|
1565
|
-
if (!v.name || typeof v.name !== "string") {
|
|
1566
|
-
throw new Error(`Validator at index ${idx} missing 'name' (string)`);
|
|
1567
|
-
}
|
|
1568
|
-
if (typeof v.validate !== "function") {
|
|
1569
|
-
throw new Error(`Validator '${v.name}' missing 'validate' function`);
|
|
1570
|
-
}
|
|
1571
|
-
});
|
|
1572
|
-
const validatorsByOperation = {
|
|
1573
|
-
create: [],
|
|
1574
|
-
update: [],
|
|
1575
|
-
delete: [],
|
|
1576
|
-
createMany: []
|
|
1577
|
-
};
|
|
1578
|
-
const allOperationsValidators = [];
|
|
1579
|
-
validators.forEach((v) => {
|
|
1580
|
-
if (!v.operations || v.operations.length === 0) {
|
|
1581
|
-
allOperationsValidators.push(v);
|
|
1582
|
-
} else {
|
|
1583
|
-
v.operations.forEach((op) => {
|
|
1584
|
-
if (validatorsByOperation[op]) {
|
|
1585
|
-
validatorsByOperation[op].push(v);
|
|
1586
|
-
}
|
|
1587
|
-
});
|
|
1588
|
-
}
|
|
1589
|
-
});
|
|
1590
|
-
return {
|
|
1591
|
-
name: "validation-chain",
|
|
1592
|
-
apply(repo) {
|
|
1593
|
-
const getValidatorsForOperation = (operation) => {
|
|
1594
|
-
const specific = validatorsByOperation[operation] || [];
|
|
1595
|
-
return [...allOperationsValidators, ...specific];
|
|
1596
|
-
};
|
|
1597
|
-
const runValidators = async (operation, context) => {
|
|
1598
|
-
const operationValidators = getValidatorsForOperation(operation);
|
|
1599
|
-
const errors = [];
|
|
1600
|
-
for (const validator of operationValidators) {
|
|
1601
|
-
try {
|
|
1602
|
-
await validator.validate(context, repo);
|
|
1603
|
-
} catch (error) {
|
|
1604
|
-
if (stopOnFirstError) {
|
|
1605
|
-
throw error;
|
|
1606
|
-
}
|
|
1607
|
-
errors.push({
|
|
1608
|
-
validator: validator.name,
|
|
1609
|
-
error: error.message || String(error)
|
|
1610
|
-
});
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
if (errors.length > 0) {
|
|
1614
|
-
const err = createError(
|
|
1615
|
-
400,
|
|
1616
|
-
`Validation failed: ${errors.map((e) => `[${e.validator}] ${e.error}`).join("; ")}`
|
|
1617
|
-
);
|
|
1618
|
-
err.validationErrors = errors;
|
|
1619
|
-
throw err;
|
|
1620
|
-
}
|
|
1621
|
-
};
|
|
1622
|
-
repo.on("before:create", async (context) => runValidators("create", context));
|
|
1623
|
-
repo.on("before:createMany", async (context) => runValidators("createMany", context));
|
|
1624
|
-
repo.on("before:update", async (context) => runValidators("update", context));
|
|
1625
|
-
repo.on("before:delete", async (context) => runValidators("delete", context));
|
|
1626
|
-
}
|
|
1627
|
-
};
|
|
1628
|
-
}
|
|
1629
|
-
function blockIf(name, operations, condition, errorMessage) {
|
|
1630
|
-
return {
|
|
1631
|
-
name,
|
|
1632
|
-
operations,
|
|
1633
|
-
validate: (context) => {
|
|
1634
|
-
if (condition(context)) {
|
|
1635
|
-
throw createError(403, errorMessage);
|
|
1636
|
-
}
|
|
1637
|
-
}
|
|
1638
|
-
};
|
|
1639
|
-
}
|
|
1640
|
-
function requireField(field, operations = ["create"]) {
|
|
1641
|
-
return {
|
|
1642
|
-
name: `require-${field}`,
|
|
1643
|
-
operations,
|
|
1644
|
-
validate: (context) => {
|
|
1645
|
-
if (!context.data || context.data[field] === void 0 || context.data[field] === null) {
|
|
1646
|
-
throw createError(400, `Field '${field}' is required`);
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
};
|
|
1650
|
-
}
|
|
1651
|
-
function autoInject(field, getter, operations = ["create"]) {
|
|
1652
|
-
return {
|
|
1653
|
-
name: `auto-inject-${field}`,
|
|
1654
|
-
operations,
|
|
1655
|
-
validate: (context) => {
|
|
1656
|
-
if (context.data && !(field in context.data)) {
|
|
1657
|
-
const value = getter(context);
|
|
1658
|
-
if (value !== null && value !== void 0) {
|
|
1659
|
-
context.data[field] = value;
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1662
|
-
}
|
|
1663
|
-
};
|
|
1664
|
-
}
|
|
1665
|
-
function immutableField(field) {
|
|
1666
|
-
return {
|
|
1667
|
-
name: `immutable-${field}`,
|
|
1668
|
-
operations: ["update"],
|
|
1669
|
-
validate: (context) => {
|
|
1670
|
-
if (context.data && field in context.data) {
|
|
1671
|
-
throw createError(400, `Field '${field}' cannot be modified`);
|
|
1672
|
-
}
|
|
1673
|
-
}
|
|
1674
|
-
};
|
|
1675
|
-
}
|
|
1676
|
-
function uniqueField(field, errorMessage) {
|
|
1677
|
-
return {
|
|
1678
|
-
name: `unique-${field}`,
|
|
1679
|
-
operations: ["create", "update"],
|
|
1680
|
-
validate: async (context, repo) => {
|
|
1681
|
-
if (!context.data || !context.data[field] || !repo) return;
|
|
1682
|
-
const query = { [field]: context.data[field] };
|
|
1683
|
-
const getByQuery2 = repo.getByQuery;
|
|
1684
|
-
if (typeof getByQuery2 !== "function") return;
|
|
1685
|
-
const existing = await getByQuery2.call(repo, query, {
|
|
1686
|
-
select: "_id",
|
|
1687
|
-
lean: true,
|
|
1688
|
-
throwOnNotFound: false
|
|
1689
|
-
});
|
|
1690
|
-
if (existing && String(existing._id) !== String(context.id)) {
|
|
1691
|
-
throw createError(409, errorMessage || `${field} already exists`);
|
|
1692
|
-
}
|
|
1693
|
-
}
|
|
1694
|
-
};
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
// src/plugins/mongo-operations.plugin.ts
|
|
1698
|
-
function mongoOperationsPlugin() {
|
|
1699
|
-
return {
|
|
1700
|
-
name: "mongo-operations",
|
|
1701
|
-
apply(repo) {
|
|
1702
|
-
if (!repo.registerMethod) {
|
|
1703
|
-
throw new Error(
|
|
1704
|
-
"mongoOperationsPlugin requires methodRegistryPlugin. Add methodRegistryPlugin() before mongoOperationsPlugin() in plugins array."
|
|
951
|
+
const page = options.page || 1;
|
|
952
|
+
const limit = options.limit || this._pagination.config.defaultLimit || 20;
|
|
953
|
+
const skip = (page - 1) * limit;
|
|
954
|
+
const SAFE_LIMIT = 1e3;
|
|
955
|
+
const SAFE_MAX_OFFSET = 1e4;
|
|
956
|
+
if (limit > SAFE_LIMIT) {
|
|
957
|
+
console.warn(
|
|
958
|
+
`[mongokit] Large limit (${limit}) in lookupPopulate. $facet results must be <16MB. Consider using smaller limits or stream-based pagination for large datasets.`
|
|
1705
959
|
);
|
|
1706
960
|
}
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
if (typeof value !== "number") {
|
|
1712
|
-
throw createError(400, `${operationName} value must be a number`);
|
|
1713
|
-
}
|
|
1714
|
-
return this.update(id, { [operator]: { [field]: value } }, options);
|
|
1715
|
-
};
|
|
1716
|
-
repo.registerMethod("increment", async function(id, field, value = 1, options = {}) {
|
|
1717
|
-
return validateAndUpdateNumeric.call(this, id, field, value, "$inc", "Increment", options);
|
|
1718
|
-
});
|
|
1719
|
-
repo.registerMethod("decrement", async function(id, field, value = 1, options = {}) {
|
|
1720
|
-
return validateAndUpdateNumeric.call(this, id, field, -value, "$inc", "Decrement", options);
|
|
1721
|
-
});
|
|
1722
|
-
const applyOperator = function(id, field, value, operator, options) {
|
|
1723
|
-
return this.update(id, { [operator]: { [field]: value } }, options);
|
|
1724
|
-
};
|
|
1725
|
-
repo.registerMethod("pushToArray", async function(id, field, value, options = {}) {
|
|
1726
|
-
return applyOperator.call(this, id, field, value, "$push", options);
|
|
1727
|
-
});
|
|
1728
|
-
repo.registerMethod("pullFromArray", async function(id, field, value, options = {}) {
|
|
1729
|
-
return applyOperator.call(this, id, field, value, "$pull", options);
|
|
1730
|
-
});
|
|
1731
|
-
repo.registerMethod("addToSet", async function(id, field, value, options = {}) {
|
|
1732
|
-
return applyOperator.call(this, id, field, value, "$addToSet", options);
|
|
1733
|
-
});
|
|
1734
|
-
repo.registerMethod("setField", async function(id, field, value, options = {}) {
|
|
1735
|
-
return applyOperator.call(this, id, field, value, "$set", options);
|
|
1736
|
-
});
|
|
1737
|
-
repo.registerMethod("unsetField", async function(id, fields, options = {}) {
|
|
1738
|
-
const fieldArray = Array.isArray(fields) ? fields : [fields];
|
|
1739
|
-
const unsetObj = fieldArray.reduce((acc, field) => {
|
|
1740
|
-
acc[field] = "";
|
|
1741
|
-
return acc;
|
|
1742
|
-
}, {});
|
|
1743
|
-
return this.update(id, { $unset: unsetObj }, options);
|
|
1744
|
-
});
|
|
1745
|
-
repo.registerMethod("renameField", async function(id, oldName, newName, options = {}) {
|
|
1746
|
-
return this.update(id, { $rename: { [oldName]: newName } }, options);
|
|
1747
|
-
});
|
|
1748
|
-
repo.registerMethod("multiplyField", async function(id, field, multiplier, options = {}) {
|
|
1749
|
-
return validateAndUpdateNumeric.call(this, id, field, multiplier, "$mul", "Multiplier", options);
|
|
1750
|
-
});
|
|
1751
|
-
repo.registerMethod("setMin", async function(id, field, value, options = {}) {
|
|
1752
|
-
return applyOperator.call(this, id, field, value, "$min", options);
|
|
1753
|
-
});
|
|
1754
|
-
repo.registerMethod("setMax", async function(id, field, value, options = {}) {
|
|
1755
|
-
return applyOperator.call(this, id, field, value, "$max", options);
|
|
1756
|
-
});
|
|
1757
|
-
}
|
|
1758
|
-
};
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
// src/plugins/batch-operations.plugin.ts
|
|
1762
|
-
function batchOperationsPlugin() {
|
|
1763
|
-
return {
|
|
1764
|
-
name: "batch-operations",
|
|
1765
|
-
apply(repo) {
|
|
1766
|
-
if (!repo.registerMethod) {
|
|
1767
|
-
throw new Error("batchOperationsPlugin requires methodRegistryPlugin");
|
|
1768
|
-
}
|
|
1769
|
-
repo.registerMethod("updateMany", async function(query, data, options = {}) {
|
|
1770
|
-
const _buildContext = this._buildContext;
|
|
1771
|
-
const context = await _buildContext.call(this, "updateMany", { query, data, options });
|
|
1772
|
-
try {
|
|
1773
|
-
this.emit("before:updateMany", context);
|
|
1774
|
-
if (Array.isArray(data) && options.updatePipeline !== true) {
|
|
1775
|
-
throw createError(
|
|
1776
|
-
400,
|
|
1777
|
-
"Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates."
|
|
1778
|
-
);
|
|
1779
|
-
}
|
|
1780
|
-
const result = await this.Model.updateMany(query, data, {
|
|
1781
|
-
runValidators: true,
|
|
1782
|
-
session: options.session,
|
|
1783
|
-
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
1784
|
-
}).exec();
|
|
1785
|
-
this.emit("after:updateMany", { context, result });
|
|
1786
|
-
return result;
|
|
1787
|
-
} catch (error) {
|
|
1788
|
-
this.emit("error:updateMany", { context, error });
|
|
1789
|
-
const _handleError = this._handleError;
|
|
1790
|
-
throw _handleError.call(this, error);
|
|
1791
|
-
}
|
|
1792
|
-
});
|
|
1793
|
-
repo.registerMethod("deleteMany", async function(query, options = {}) {
|
|
1794
|
-
const _buildContext = this._buildContext;
|
|
1795
|
-
const context = await _buildContext.call(this, "deleteMany", { query, options });
|
|
1796
|
-
try {
|
|
1797
|
-
this.emit("before:deleteMany", context);
|
|
1798
|
-
const result = await this.Model.deleteMany(query, {
|
|
1799
|
-
session: options.session
|
|
1800
|
-
}).exec();
|
|
1801
|
-
this.emit("after:deleteMany", { context, result });
|
|
1802
|
-
return result;
|
|
1803
|
-
} catch (error) {
|
|
1804
|
-
this.emit("error:deleteMany", { context, error });
|
|
1805
|
-
const _handleError = this._handleError;
|
|
1806
|
-
throw _handleError.call(this, error);
|
|
1807
|
-
}
|
|
1808
|
-
});
|
|
1809
|
-
}
|
|
1810
|
-
};
|
|
1811
|
-
}
|
|
1812
|
-
|
|
1813
|
-
// src/plugins/aggregate-helpers.plugin.ts
|
|
1814
|
-
function aggregateHelpersPlugin() {
|
|
1815
|
-
return {
|
|
1816
|
-
name: "aggregate-helpers",
|
|
1817
|
-
apply(repo) {
|
|
1818
|
-
if (!repo.registerMethod) {
|
|
1819
|
-
throw new Error("aggregateHelpersPlugin requires methodRegistryPlugin");
|
|
1820
|
-
}
|
|
1821
|
-
repo.registerMethod("groupBy", async function(field, options = {}) {
|
|
1822
|
-
const pipeline = [
|
|
1823
|
-
{ $group: { _id: `$${field}`, count: { $sum: 1 } } },
|
|
1824
|
-
{ $sort: { count: -1 } }
|
|
1825
|
-
];
|
|
1826
|
-
if (options.limit) {
|
|
1827
|
-
pipeline.push({ $limit: options.limit });
|
|
1828
|
-
}
|
|
1829
|
-
const aggregate2 = this.aggregate;
|
|
1830
|
-
return aggregate2.call(this, pipeline, options);
|
|
1831
|
-
});
|
|
1832
|
-
const aggregateOperation = async function(field, operator, resultKey, query = {}, options = {}) {
|
|
1833
|
-
const pipeline = [
|
|
1834
|
-
{ $match: query },
|
|
1835
|
-
{ $group: { _id: null, [resultKey]: { [operator]: `$${field}` } } }
|
|
1836
|
-
];
|
|
1837
|
-
const aggregate2 = this.aggregate;
|
|
1838
|
-
const result = await aggregate2.call(this, pipeline, options);
|
|
1839
|
-
return result[0]?.[resultKey] || 0;
|
|
1840
|
-
};
|
|
1841
|
-
repo.registerMethod("sum", async function(field, query = {}, options = {}) {
|
|
1842
|
-
return aggregateOperation.call(this, field, "$sum", "total", query, options);
|
|
1843
|
-
});
|
|
1844
|
-
repo.registerMethod("average", async function(field, query = {}, options = {}) {
|
|
1845
|
-
return aggregateOperation.call(this, field, "$avg", "avg", query, options);
|
|
1846
|
-
});
|
|
1847
|
-
repo.registerMethod("min", async function(field, query = {}, options = {}) {
|
|
1848
|
-
return aggregateOperation.call(this, field, "$min", "min", query, options);
|
|
1849
|
-
});
|
|
1850
|
-
repo.registerMethod("max", async function(field, query = {}, options = {}) {
|
|
1851
|
-
return aggregateOperation.call(this, field, "$max", "max", query, options);
|
|
1852
|
-
});
|
|
1853
|
-
}
|
|
1854
|
-
};
|
|
1855
|
-
}
|
|
1856
|
-
|
|
1857
|
-
// src/plugins/subdocument.plugin.ts
|
|
1858
|
-
function subdocumentPlugin() {
|
|
1859
|
-
return {
|
|
1860
|
-
name: "subdocument",
|
|
1861
|
-
apply(repo) {
|
|
1862
|
-
if (!repo.registerMethod) {
|
|
1863
|
-
throw new Error("subdocumentPlugin requires methodRegistryPlugin");
|
|
1864
|
-
}
|
|
1865
|
-
repo.registerMethod("addSubdocument", async function(parentId, arrayPath, subData, options = {}) {
|
|
1866
|
-
const update2 = this.update;
|
|
1867
|
-
return update2.call(this, parentId, { $push: { [arrayPath]: subData } }, options);
|
|
1868
|
-
});
|
|
1869
|
-
repo.registerMethod("getSubdocument", async function(parentId, arrayPath, subId, options = {}) {
|
|
1870
|
-
const _executeQuery = this._executeQuery;
|
|
1871
|
-
return _executeQuery.call(this, async (Model) => {
|
|
1872
|
-
const parent = await Model.findById(parentId).session(options.session).exec();
|
|
1873
|
-
if (!parent) throw createError(404, "Parent not found");
|
|
1874
|
-
const parentObj = parent;
|
|
1875
|
-
const arrayField = parentObj[arrayPath];
|
|
1876
|
-
if (!arrayField || typeof arrayField.id !== "function") {
|
|
1877
|
-
throw createError(404, "Array field not found");
|
|
1878
|
-
}
|
|
1879
|
-
const sub = arrayField.id(subId);
|
|
1880
|
-
if (!sub) throw createError(404, "Subdocument not found");
|
|
1881
|
-
return options.lean && typeof sub.toObject === "function" ? sub.toObject() : sub;
|
|
1882
|
-
});
|
|
1883
|
-
});
|
|
1884
|
-
repo.registerMethod("updateSubdocument", async function(parentId, arrayPath, subId, updateData, options = {}) {
|
|
1885
|
-
const _executeQuery = this._executeQuery;
|
|
1886
|
-
return _executeQuery.call(this, async (Model) => {
|
|
1887
|
-
const query = { _id: parentId, [`${arrayPath}._id`]: subId };
|
|
1888
|
-
const update2 = { $set: { [`${arrayPath}.$`]: { ...updateData, _id: subId } } };
|
|
1889
|
-
const result = await Model.findOneAndUpdate(query, update2, {
|
|
1890
|
-
new: true,
|
|
1891
|
-
runValidators: true,
|
|
1892
|
-
session: options.session
|
|
1893
|
-
}).exec();
|
|
1894
|
-
if (!result) throw createError(404, "Parent or subdocument not found");
|
|
1895
|
-
return result;
|
|
1896
|
-
});
|
|
1897
|
-
});
|
|
1898
|
-
repo.registerMethod("deleteSubdocument", async function(parentId, arrayPath, subId, options = {}) {
|
|
1899
|
-
const update2 = this.update;
|
|
1900
|
-
return update2.call(this, parentId, { $pull: { [arrayPath]: { _id: subId } } }, options);
|
|
1901
|
-
});
|
|
1902
|
-
}
|
|
1903
|
-
};
|
|
1904
|
-
}
|
|
1905
|
-
|
|
1906
|
-
// src/utils/cache-keys.ts
|
|
1907
|
-
function hashString(str) {
|
|
1908
|
-
let hash = 5381;
|
|
1909
|
-
for (let i = 0; i < str.length; i++) {
|
|
1910
|
-
hash = (hash << 5) + hash ^ str.charCodeAt(i);
|
|
1911
|
-
}
|
|
1912
|
-
return (hash >>> 0).toString(16);
|
|
1913
|
-
}
|
|
1914
|
-
function stableStringify(obj) {
|
|
1915
|
-
if (obj === null || obj === void 0) return "";
|
|
1916
|
-
if (typeof obj !== "object") return String(obj);
|
|
1917
|
-
if (Array.isArray(obj)) {
|
|
1918
|
-
return "[" + obj.map(stableStringify).join(",") + "]";
|
|
1919
|
-
}
|
|
1920
|
-
const sorted = Object.keys(obj).sort().map((key) => `${key}:${stableStringify(obj[key])}`);
|
|
1921
|
-
return "{" + sorted.join(",") + "}";
|
|
1922
|
-
}
|
|
1923
|
-
function byIdKey(prefix, model, id) {
|
|
1924
|
-
return `${prefix}:id:${model}:${id}`;
|
|
1925
|
-
}
|
|
1926
|
-
function byQueryKey(prefix, model, query, options) {
|
|
1927
|
-
const hashInput = stableStringify({ q: query, s: options?.select, p: options?.populate });
|
|
1928
|
-
return `${prefix}:one:${model}:${hashString(hashInput)}`;
|
|
1929
|
-
}
|
|
1930
|
-
function listQueryKey(prefix, model, version, params) {
|
|
1931
|
-
const hashInput = stableStringify({
|
|
1932
|
-
f: params.filters,
|
|
1933
|
-
s: params.sort,
|
|
1934
|
-
pg: params.page,
|
|
1935
|
-
lm: params.limit,
|
|
1936
|
-
af: params.after,
|
|
1937
|
-
sl: params.select,
|
|
1938
|
-
pp: params.populate
|
|
1939
|
-
});
|
|
1940
|
-
return `${prefix}:list:${model}:${version}:${hashString(hashInput)}`;
|
|
1941
|
-
}
|
|
1942
|
-
function versionKey(prefix, model) {
|
|
1943
|
-
return `${prefix}:ver:${model}`;
|
|
1944
|
-
}
|
|
1945
|
-
function modelPattern(prefix, model) {
|
|
1946
|
-
return `${prefix}:*:${model}:*`;
|
|
1947
|
-
}
|
|
1948
|
-
|
|
1949
|
-
// src/plugins/cache.plugin.ts
|
|
1950
|
-
function cachePlugin(options) {
|
|
1951
|
-
const config = {
|
|
1952
|
-
adapter: options.adapter,
|
|
1953
|
-
ttl: options.ttl ?? 60,
|
|
1954
|
-
byIdTtl: options.byIdTtl ?? options.ttl ?? 60,
|
|
1955
|
-
queryTtl: options.queryTtl ?? options.ttl ?? 60,
|
|
1956
|
-
prefix: options.prefix ?? "mk",
|
|
1957
|
-
debug: options.debug ?? false,
|
|
1958
|
-
skipIfLargeLimit: options.skipIf?.largeLimit ?? 100
|
|
1959
|
-
};
|
|
1960
|
-
const stats = {
|
|
1961
|
-
hits: 0,
|
|
1962
|
-
misses: 0,
|
|
1963
|
-
sets: 0,
|
|
1964
|
-
invalidations: 0
|
|
1965
|
-
};
|
|
1966
|
-
let collectionVersion = 0;
|
|
1967
|
-
const log = (msg, data) => {
|
|
1968
|
-
if (config.debug) {
|
|
1969
|
-
console.log(`[mongokit:cache] ${msg}`, data ?? "");
|
|
1970
|
-
}
|
|
1971
|
-
};
|
|
1972
|
-
return {
|
|
1973
|
-
name: "cache",
|
|
1974
|
-
apply(repo) {
|
|
1975
|
-
const model = repo.model;
|
|
1976
|
-
(async () => {
|
|
1977
|
-
try {
|
|
1978
|
-
const cached = await config.adapter.get(versionKey(config.prefix, model));
|
|
1979
|
-
if (cached !== null) {
|
|
1980
|
-
collectionVersion = cached;
|
|
1981
|
-
log(`Initialized version for ${model}:`, collectionVersion);
|
|
1982
|
-
}
|
|
1983
|
-
} catch (e) {
|
|
1984
|
-
log(`Failed to initialize version for ${model}:`, e);
|
|
1985
|
-
}
|
|
1986
|
-
})();
|
|
1987
|
-
async function bumpVersion() {
|
|
1988
|
-
collectionVersion++;
|
|
1989
|
-
try {
|
|
1990
|
-
await config.adapter.set(versionKey(config.prefix, model), collectionVersion, config.ttl * 10);
|
|
1991
|
-
stats.invalidations++;
|
|
1992
|
-
log(`Bumped version for ${model} to:`, collectionVersion);
|
|
1993
|
-
} catch (e) {
|
|
1994
|
-
log(`Failed to bump version for ${model}:`, e);
|
|
1995
|
-
}
|
|
1996
|
-
}
|
|
1997
|
-
async function invalidateById(id) {
|
|
1998
|
-
const key = byIdKey(config.prefix, model, id);
|
|
1999
|
-
try {
|
|
2000
|
-
await config.adapter.del(key);
|
|
2001
|
-
stats.invalidations++;
|
|
2002
|
-
log(`Invalidated byId cache:`, key);
|
|
2003
|
-
} catch (e) {
|
|
2004
|
-
log(`Failed to invalidate byId cache:`, e);
|
|
2005
|
-
}
|
|
961
|
+
if (skip > SAFE_MAX_OFFSET) {
|
|
962
|
+
console.warn(
|
|
963
|
+
`[mongokit] Large offset (${skip}) in lookupPopulate. $facet with high offsets can exceed 16MB. For deep pagination, consider using keyset/cursor-based pagination instead.`
|
|
964
|
+
);
|
|
2006
965
|
}
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
const
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
context._cacheHit = true;
|
|
2020
|
-
context._cachedResult = cached;
|
|
2021
|
-
} else {
|
|
2022
|
-
stats.misses++;
|
|
2023
|
-
log(`Cache MISS for getById:`, key);
|
|
2024
|
-
}
|
|
2025
|
-
} catch (e) {
|
|
2026
|
-
log(`Cache error for getById:`, e);
|
|
2027
|
-
stats.misses++;
|
|
2028
|
-
}
|
|
2029
|
-
});
|
|
2030
|
-
repo.on("before:getByQuery", async (context) => {
|
|
2031
|
-
if (context.skipCache) {
|
|
2032
|
-
log(`Skipping cache for getByQuery`);
|
|
2033
|
-
return;
|
|
2034
|
-
}
|
|
2035
|
-
const query = context.query || {};
|
|
2036
|
-
const key = byQueryKey(config.prefix, model, query, {
|
|
2037
|
-
select: context.select,
|
|
2038
|
-
populate: context.populate
|
|
2039
|
-
});
|
|
2040
|
-
try {
|
|
2041
|
-
const cached = await config.adapter.get(key);
|
|
2042
|
-
if (cached !== null) {
|
|
2043
|
-
stats.hits++;
|
|
2044
|
-
log(`Cache HIT for getByQuery:`, key);
|
|
2045
|
-
context._cacheHit = true;
|
|
2046
|
-
context._cachedResult = cached;
|
|
2047
|
-
} else {
|
|
2048
|
-
stats.misses++;
|
|
2049
|
-
log(`Cache MISS for getByQuery:`, key);
|
|
2050
|
-
}
|
|
2051
|
-
} catch (e) {
|
|
2052
|
-
log(`Cache error for getByQuery:`, e);
|
|
2053
|
-
stats.misses++;
|
|
2054
|
-
}
|
|
2055
|
-
});
|
|
2056
|
-
repo.on("before:getAll", async (context) => {
|
|
2057
|
-
if (context.skipCache) {
|
|
2058
|
-
log(`Skipping cache for getAll`);
|
|
2059
|
-
return;
|
|
2060
|
-
}
|
|
2061
|
-
const limit = context.limit;
|
|
2062
|
-
if (limit && limit > config.skipIfLargeLimit) {
|
|
2063
|
-
log(`Skipping cache for large query (limit: ${limit})`);
|
|
2064
|
-
return;
|
|
2065
|
-
}
|
|
2066
|
-
const params = {
|
|
2067
|
-
filters: context.filters,
|
|
2068
|
-
sort: context.sort,
|
|
2069
|
-
page: context.page,
|
|
2070
|
-
limit,
|
|
2071
|
-
after: context.after,
|
|
2072
|
-
select: context.select,
|
|
2073
|
-
populate: context.populate
|
|
2074
|
-
};
|
|
2075
|
-
const key = listQueryKey(config.prefix, model, collectionVersion, params);
|
|
2076
|
-
try {
|
|
2077
|
-
const cached = await config.adapter.get(key);
|
|
2078
|
-
if (cached !== null) {
|
|
2079
|
-
stats.hits++;
|
|
2080
|
-
log(`Cache HIT for getAll:`, key);
|
|
2081
|
-
context._cacheHit = true;
|
|
2082
|
-
context._cachedResult = cached;
|
|
2083
|
-
} else {
|
|
2084
|
-
stats.misses++;
|
|
2085
|
-
log(`Cache MISS for getAll:`, key);
|
|
2086
|
-
}
|
|
2087
|
-
} catch (e) {
|
|
2088
|
-
log(`Cache error for getAll:`, e);
|
|
2089
|
-
stats.misses++;
|
|
2090
|
-
}
|
|
2091
|
-
});
|
|
2092
|
-
repo.on("after:getById", async (payload) => {
|
|
2093
|
-
const { context, result } = payload;
|
|
2094
|
-
if (context._cacheHit) return;
|
|
2095
|
-
if (context.skipCache) return;
|
|
2096
|
-
if (result === null) return;
|
|
2097
|
-
const id = String(context.id);
|
|
2098
|
-
const key = byIdKey(config.prefix, model, id);
|
|
2099
|
-
const ttl = context.cacheTtl ?? config.byIdTtl;
|
|
2100
|
-
try {
|
|
2101
|
-
await config.adapter.set(key, result, ttl);
|
|
2102
|
-
stats.sets++;
|
|
2103
|
-
log(`Cached getById result:`, key);
|
|
2104
|
-
} catch (e) {
|
|
2105
|
-
log(`Failed to cache getById:`, e);
|
|
2106
|
-
}
|
|
2107
|
-
});
|
|
2108
|
-
repo.on("after:getByQuery", async (payload) => {
|
|
2109
|
-
const { context, result } = payload;
|
|
2110
|
-
if (context._cacheHit) return;
|
|
2111
|
-
if (context.skipCache) return;
|
|
2112
|
-
if (result === null) return;
|
|
2113
|
-
const query = context.query || {};
|
|
2114
|
-
const key = byQueryKey(config.prefix, model, query, {
|
|
2115
|
-
select: context.select,
|
|
2116
|
-
populate: context.populate
|
|
2117
|
-
});
|
|
2118
|
-
const ttl = context.cacheTtl ?? config.queryTtl;
|
|
2119
|
-
try {
|
|
2120
|
-
await config.adapter.set(key, result, ttl);
|
|
2121
|
-
stats.sets++;
|
|
2122
|
-
log(`Cached getByQuery result:`, key);
|
|
2123
|
-
} catch (e) {
|
|
2124
|
-
log(`Failed to cache getByQuery:`, e);
|
|
2125
|
-
}
|
|
2126
|
-
});
|
|
2127
|
-
repo.on("after:getAll", async (payload) => {
|
|
2128
|
-
const { context, result } = payload;
|
|
2129
|
-
if (context._cacheHit) return;
|
|
2130
|
-
if (context.skipCache) return;
|
|
2131
|
-
const limit = context.limit;
|
|
2132
|
-
if (limit && limit > config.skipIfLargeLimit) return;
|
|
2133
|
-
const params = {
|
|
2134
|
-
filters: context.filters,
|
|
2135
|
-
sort: context.sort,
|
|
2136
|
-
page: context.page,
|
|
2137
|
-
limit,
|
|
2138
|
-
after: context.after,
|
|
2139
|
-
select: context.select,
|
|
2140
|
-
populate: context.populate
|
|
2141
|
-
};
|
|
2142
|
-
const key = listQueryKey(config.prefix, model, collectionVersion, params);
|
|
2143
|
-
const ttl = context.cacheTtl ?? config.queryTtl;
|
|
2144
|
-
try {
|
|
2145
|
-
await config.adapter.set(key, result, ttl);
|
|
2146
|
-
stats.sets++;
|
|
2147
|
-
log(`Cached getAll result:`, key);
|
|
2148
|
-
} catch (e) {
|
|
2149
|
-
log(`Failed to cache getAll:`, e);
|
|
2150
|
-
}
|
|
2151
|
-
});
|
|
2152
|
-
repo.on("after:create", async () => {
|
|
2153
|
-
await bumpVersion();
|
|
2154
|
-
});
|
|
2155
|
-
repo.on("after:createMany", async () => {
|
|
2156
|
-
await bumpVersion();
|
|
2157
|
-
});
|
|
2158
|
-
repo.on("after:update", async (payload) => {
|
|
2159
|
-
const { context } = payload;
|
|
2160
|
-
const id = String(context.id);
|
|
2161
|
-
await Promise.all([
|
|
2162
|
-
invalidateById(id),
|
|
2163
|
-
bumpVersion()
|
|
2164
|
-
]);
|
|
2165
|
-
});
|
|
2166
|
-
repo.on("after:updateMany", async () => {
|
|
2167
|
-
await bumpVersion();
|
|
2168
|
-
});
|
|
2169
|
-
repo.on("after:delete", async (payload) => {
|
|
2170
|
-
const { context } = payload;
|
|
2171
|
-
const id = String(context.id);
|
|
2172
|
-
await Promise.all([
|
|
2173
|
-
invalidateById(id),
|
|
2174
|
-
bumpVersion()
|
|
2175
|
-
]);
|
|
2176
|
-
});
|
|
2177
|
-
repo.on("after:deleteMany", async () => {
|
|
2178
|
-
await bumpVersion();
|
|
2179
|
-
});
|
|
2180
|
-
repo.invalidateCache = async (id) => {
|
|
2181
|
-
await invalidateById(id);
|
|
2182
|
-
log(`Manual invalidation for ID:`, id);
|
|
2183
|
-
};
|
|
2184
|
-
repo.invalidateListCache = async () => {
|
|
2185
|
-
await bumpVersion();
|
|
2186
|
-
log(`Manual list cache invalidation for ${model}`);
|
|
2187
|
-
};
|
|
2188
|
-
repo.invalidateAllCache = async () => {
|
|
2189
|
-
if (config.adapter.clear) {
|
|
2190
|
-
try {
|
|
2191
|
-
await config.adapter.clear(modelPattern(config.prefix, model));
|
|
2192
|
-
stats.invalidations++;
|
|
2193
|
-
log(`Full cache invalidation for ${model}`);
|
|
2194
|
-
} catch (e) {
|
|
2195
|
-
log(`Failed full cache invalidation for ${model}:`, e);
|
|
2196
|
-
}
|
|
2197
|
-
} else {
|
|
2198
|
-
await bumpVersion();
|
|
2199
|
-
log(`Partial cache invalidation for ${model} (adapter.clear not available)`);
|
|
2200
|
-
}
|
|
2201
|
-
};
|
|
2202
|
-
repo.getCacheStats = () => ({ ...stats });
|
|
2203
|
-
repo.resetCacheStats = () => {
|
|
2204
|
-
stats.hits = 0;
|
|
2205
|
-
stats.misses = 0;
|
|
2206
|
-
stats.sets = 0;
|
|
2207
|
-
stats.invalidations = 0;
|
|
2208
|
-
};
|
|
2209
|
-
}
|
|
2210
|
-
};
|
|
2211
|
-
}
|
|
2212
|
-
function cascadePlugin(options) {
|
|
2213
|
-
const { relations, parallel = true, logger } = options;
|
|
2214
|
-
if (!relations || relations.length === 0) {
|
|
2215
|
-
throw new Error("cascadePlugin requires at least one relation");
|
|
2216
|
-
}
|
|
2217
|
-
return {
|
|
2218
|
-
name: "cascade",
|
|
2219
|
-
apply(repo) {
|
|
2220
|
-
repo.on("after:delete", async (payload) => {
|
|
2221
|
-
const { context } = payload;
|
|
2222
|
-
const deletedId = context.id;
|
|
2223
|
-
if (!deletedId) {
|
|
2224
|
-
logger?.warn?.("Cascade delete skipped: no document ID in context", {
|
|
2225
|
-
model: context.model
|
|
2226
|
-
});
|
|
2227
|
-
return;
|
|
2228
|
-
}
|
|
2229
|
-
const isSoftDelete = context.softDeleted === true;
|
|
2230
|
-
const cascadeDelete = async (relation) => {
|
|
2231
|
-
const RelatedModel = mongoose4.models[relation.model];
|
|
2232
|
-
if (!RelatedModel) {
|
|
2233
|
-
logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`, {
|
|
2234
|
-
parentModel: context.model,
|
|
2235
|
-
parentId: String(deletedId)
|
|
2236
|
-
});
|
|
2237
|
-
return;
|
|
2238
|
-
}
|
|
2239
|
-
const query = { [relation.foreignKey]: deletedId };
|
|
2240
|
-
try {
|
|
2241
|
-
const shouldSoftDelete = relation.softDelete ?? isSoftDelete;
|
|
2242
|
-
if (shouldSoftDelete) {
|
|
2243
|
-
const updateResult = await RelatedModel.updateMany(
|
|
2244
|
-
query,
|
|
2245
|
-
{
|
|
2246
|
-
deletedAt: /* @__PURE__ */ new Date(),
|
|
2247
|
-
...context.user ? { deletedBy: context.user._id || context.user.id } : {}
|
|
2248
|
-
},
|
|
2249
|
-
{ session: context.session }
|
|
2250
|
-
);
|
|
2251
|
-
logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents`, {
|
|
2252
|
-
parentModel: context.model,
|
|
2253
|
-
parentId: String(deletedId),
|
|
2254
|
-
relatedModel: relation.model,
|
|
2255
|
-
foreignKey: relation.foreignKey,
|
|
2256
|
-
count: updateResult.modifiedCount
|
|
2257
|
-
});
|
|
966
|
+
const dataStages = [
|
|
967
|
+
{ $skip: skip },
|
|
968
|
+
{ $limit: limit }
|
|
969
|
+
];
|
|
970
|
+
if (options.select) {
|
|
971
|
+
let projection;
|
|
972
|
+
if (typeof options.select === "string") {
|
|
973
|
+
projection = {};
|
|
974
|
+
const fields = options.select.split(",").map((f) => f.trim());
|
|
975
|
+
for (const field of fields) {
|
|
976
|
+
if (field.startsWith("-")) {
|
|
977
|
+
projection[field.substring(1)] = 0;
|
|
2258
978
|
} else {
|
|
2259
|
-
|
|
2260
|
-
session: context.session
|
|
2261
|
-
});
|
|
2262
|
-
logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents`, {
|
|
2263
|
-
parentModel: context.model,
|
|
2264
|
-
parentId: String(deletedId),
|
|
2265
|
-
relatedModel: relation.model,
|
|
2266
|
-
foreignKey: relation.foreignKey,
|
|
2267
|
-
count: deleteResult.deletedCount
|
|
2268
|
-
});
|
|
979
|
+
projection[field] = 1;
|
|
2269
980
|
}
|
|
2270
|
-
} catch (error) {
|
|
2271
|
-
logger?.error?.(`Cascade delete failed for model '${relation.model}'`, {
|
|
2272
|
-
parentModel: context.model,
|
|
2273
|
-
parentId: String(deletedId),
|
|
2274
|
-
relatedModel: relation.model,
|
|
2275
|
-
foreignKey: relation.foreignKey,
|
|
2276
|
-
error: error.message
|
|
2277
|
-
});
|
|
2278
|
-
throw error;
|
|
2279
|
-
}
|
|
2280
|
-
};
|
|
2281
|
-
if (parallel) {
|
|
2282
|
-
await Promise.all(relations.map(cascadeDelete));
|
|
2283
|
-
} else {
|
|
2284
|
-
for (const relation of relations) {
|
|
2285
|
-
await cascadeDelete(relation);
|
|
2286
981
|
}
|
|
2287
|
-
}
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
model: context.model
|
|
2295
|
-
});
|
|
2296
|
-
return;
|
|
2297
|
-
}
|
|
2298
|
-
logger?.warn?.("Cascade deleteMany: use before:deleteMany hook for complete cascade support", {
|
|
2299
|
-
model: context.model
|
|
2300
|
-
});
|
|
2301
|
-
});
|
|
2302
|
-
repo.on("before:deleteMany", async (context) => {
|
|
2303
|
-
const query = context.query;
|
|
2304
|
-
if (!query || Object.keys(query).length === 0) {
|
|
2305
|
-
return;
|
|
2306
|
-
}
|
|
2307
|
-
const docs = await repo.Model.find(query, { _id: 1 }).lean().session(context.session ?? null);
|
|
2308
|
-
const ids = docs.map((doc) => doc._id);
|
|
2309
|
-
context._cascadeIds = ids;
|
|
2310
|
-
});
|
|
2311
|
-
const originalAfterDeleteMany = repo._hooks.get("after:deleteMany") || [];
|
|
2312
|
-
repo._hooks.set("after:deleteMany", [
|
|
2313
|
-
...originalAfterDeleteMany,
|
|
2314
|
-
async (payload) => {
|
|
2315
|
-
const { context } = payload;
|
|
2316
|
-
const ids = context._cascadeIds;
|
|
2317
|
-
if (!ids || ids.length === 0) {
|
|
2318
|
-
return;
|
|
2319
|
-
}
|
|
2320
|
-
const isSoftDelete = context.softDeleted === true;
|
|
2321
|
-
const cascadeDeleteMany = async (relation) => {
|
|
2322
|
-
const RelatedModel = mongoose4.models[relation.model];
|
|
2323
|
-
if (!RelatedModel) {
|
|
2324
|
-
logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`, {
|
|
2325
|
-
parentModel: context.model
|
|
2326
|
-
});
|
|
2327
|
-
return;
|
|
2328
|
-
}
|
|
2329
|
-
const query = { [relation.foreignKey]: { $in: ids } };
|
|
2330
|
-
const shouldSoftDelete = relation.softDelete ?? isSoftDelete;
|
|
2331
|
-
try {
|
|
2332
|
-
if (shouldSoftDelete) {
|
|
2333
|
-
const updateResult = await RelatedModel.updateMany(
|
|
2334
|
-
query,
|
|
2335
|
-
{
|
|
2336
|
-
deletedAt: /* @__PURE__ */ new Date(),
|
|
2337
|
-
...context.user ? { deletedBy: context.user._id || context.user.id } : {}
|
|
2338
|
-
},
|
|
2339
|
-
{ session: context.session }
|
|
2340
|
-
);
|
|
2341
|
-
logger?.info?.(`Cascade soft-deleted ${updateResult.modifiedCount} documents (bulk)`, {
|
|
2342
|
-
parentModel: context.model,
|
|
2343
|
-
parentCount: ids.length,
|
|
2344
|
-
relatedModel: relation.model,
|
|
2345
|
-
foreignKey: relation.foreignKey,
|
|
2346
|
-
count: updateResult.modifiedCount
|
|
2347
|
-
});
|
|
2348
|
-
} else {
|
|
2349
|
-
const deleteResult = await RelatedModel.deleteMany(query, {
|
|
2350
|
-
session: context.session
|
|
2351
|
-
});
|
|
2352
|
-
logger?.info?.(`Cascade deleted ${deleteResult.deletedCount} documents (bulk)`, {
|
|
2353
|
-
parentModel: context.model,
|
|
2354
|
-
parentCount: ids.length,
|
|
2355
|
-
relatedModel: relation.model,
|
|
2356
|
-
foreignKey: relation.foreignKey,
|
|
2357
|
-
count: deleteResult.deletedCount
|
|
2358
|
-
});
|
|
2359
|
-
}
|
|
2360
|
-
} catch (error) {
|
|
2361
|
-
logger?.error?.(`Cascade deleteMany failed for model '${relation.model}'`, {
|
|
2362
|
-
parentModel: context.model,
|
|
2363
|
-
relatedModel: relation.model,
|
|
2364
|
-
foreignKey: relation.foreignKey,
|
|
2365
|
-
error: error.message
|
|
2366
|
-
});
|
|
2367
|
-
throw error;
|
|
2368
|
-
}
|
|
2369
|
-
};
|
|
2370
|
-
if (parallel) {
|
|
2371
|
-
await Promise.all(relations.map(cascadeDeleteMany));
|
|
2372
|
-
} else {
|
|
2373
|
-
for (const relation of relations) {
|
|
2374
|
-
await cascadeDeleteMany(relation);
|
|
982
|
+
} else if (Array.isArray(options.select)) {
|
|
983
|
+
projection = {};
|
|
984
|
+
for (const field of options.select) {
|
|
985
|
+
if (field.startsWith("-")) {
|
|
986
|
+
projection[field.substring(1)] = 0;
|
|
987
|
+
} else {
|
|
988
|
+
projection[field] = 1;
|
|
2375
989
|
}
|
|
2376
990
|
}
|
|
991
|
+
} else {
|
|
992
|
+
projection = options.select;
|
|
2377
993
|
}
|
|
2378
|
-
|
|
2379
|
-
}
|
|
2380
|
-
};
|
|
2381
|
-
}
|
|
2382
|
-
|
|
2383
|
-
// src/utils/memory-cache.ts
|
|
2384
|
-
function createMemoryCache(maxEntries = 1e3) {
|
|
2385
|
-
const cache = /* @__PURE__ */ new Map();
|
|
2386
|
-
function cleanup() {
|
|
2387
|
-
const now = Date.now();
|
|
2388
|
-
for (const [key, entry] of cache) {
|
|
2389
|
-
if (entry.expiresAt < now) {
|
|
2390
|
-
cache.delete(key);
|
|
2391
|
-
}
|
|
2392
|
-
}
|
|
2393
|
-
}
|
|
2394
|
-
function evictOldest() {
|
|
2395
|
-
if (cache.size >= maxEntries) {
|
|
2396
|
-
const firstKey = cache.keys().next().value;
|
|
2397
|
-
if (firstKey) cache.delete(firstKey);
|
|
2398
|
-
}
|
|
2399
|
-
}
|
|
2400
|
-
return {
|
|
2401
|
-
async get(key) {
|
|
2402
|
-
cleanup();
|
|
2403
|
-
const entry = cache.get(key);
|
|
2404
|
-
if (!entry) return null;
|
|
2405
|
-
if (entry.expiresAt < Date.now()) {
|
|
2406
|
-
cache.delete(key);
|
|
2407
|
-
return null;
|
|
2408
|
-
}
|
|
2409
|
-
return entry.value;
|
|
2410
|
-
},
|
|
2411
|
-
async set(key, value, ttl) {
|
|
2412
|
-
cleanup();
|
|
2413
|
-
evictOldest();
|
|
2414
|
-
cache.set(key, {
|
|
2415
|
-
value,
|
|
2416
|
-
expiresAt: Date.now() + ttl * 1e3
|
|
2417
|
-
});
|
|
2418
|
-
},
|
|
2419
|
-
async del(key) {
|
|
2420
|
-
cache.delete(key);
|
|
2421
|
-
},
|
|
2422
|
-
async clear(pattern) {
|
|
2423
|
-
if (!pattern) {
|
|
2424
|
-
cache.clear();
|
|
2425
|
-
return;
|
|
2426
|
-
}
|
|
2427
|
-
const regex = new RegExp(
|
|
2428
|
-
"^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$"
|
|
2429
|
-
);
|
|
2430
|
-
for (const key of cache.keys()) {
|
|
2431
|
-
if (regex.test(key)) {
|
|
2432
|
-
cache.delete(key);
|
|
2433
|
-
}
|
|
994
|
+
dataStages.push({ $project: projection });
|
|
2434
995
|
}
|
|
996
|
+
builder.facet({
|
|
997
|
+
metadata: [{ $count: "total" }],
|
|
998
|
+
data: dataStages
|
|
999
|
+
});
|
|
1000
|
+
const pipeline = builder.build();
|
|
1001
|
+
const results = await this.Model.aggregate(pipeline).session(options.session || null);
|
|
1002
|
+
const result = results[0] || { metadata: [], data: [] };
|
|
1003
|
+
const total = result.metadata[0]?.total || 0;
|
|
1004
|
+
const data = result.data || [];
|
|
1005
|
+
await this._emitHook("after:lookupPopulate", { context, result: data });
|
|
1006
|
+
return {
|
|
1007
|
+
data,
|
|
1008
|
+
total,
|
|
1009
|
+
page,
|
|
1010
|
+
limit
|
|
1011
|
+
};
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
await this._emitErrorHook("error:lookupPopulate", { context, error });
|
|
1014
|
+
throw this._handleError(error);
|
|
2435
1015
|
}
|
|
2436
|
-
};
|
|
2437
|
-
}
|
|
2438
|
-
function isMongooseSchema(value) {
|
|
2439
|
-
return value instanceof mongoose4.Schema;
|
|
2440
|
-
}
|
|
2441
|
-
function isPlainObject(value) {
|
|
2442
|
-
return Object.prototype.toString.call(value) === "[object Object]";
|
|
2443
|
-
}
|
|
2444
|
-
function isObjectIdType(t) {
|
|
2445
|
-
return t === mongoose4.Schema.Types.ObjectId || t === mongoose4.Types.ObjectId;
|
|
2446
|
-
}
|
|
2447
|
-
function buildCrudSchemasFromMongooseSchema(mongooseSchema, options = {}) {
|
|
2448
|
-
const tree = mongooseSchema?.obj || {};
|
|
2449
|
-
const jsonCreate = buildJsonSchemaForCreate(tree, options);
|
|
2450
|
-
const jsonUpdate = buildJsonSchemaForUpdate(jsonCreate, options);
|
|
2451
|
-
const jsonParams = {
|
|
2452
|
-
type: "object",
|
|
2453
|
-
properties: { id: { type: "string", pattern: "^[0-9a-fA-F]{24}$" } },
|
|
2454
|
-
required: ["id"]
|
|
2455
|
-
};
|
|
2456
|
-
const jsonQuery = buildJsonSchemaForQuery(tree, options);
|
|
2457
|
-
return { createBody: jsonCreate, updateBody: jsonUpdate, params: jsonParams, listQuery: jsonQuery };
|
|
2458
|
-
}
|
|
2459
|
-
function buildCrudSchemasFromModel(mongooseModel, options = {}) {
|
|
2460
|
-
if (!mongooseModel || !mongooseModel.schema) {
|
|
2461
|
-
throw new Error("Invalid mongoose model");
|
|
2462
1016
|
}
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
Object.entries(fieldRules).forEach(([field, rules]) => {
|
|
2482
|
-
if (rules.systemManaged) {
|
|
2483
|
-
systemManaged.push(field);
|
|
2484
|
-
}
|
|
2485
|
-
});
|
|
2486
|
-
return systemManaged;
|
|
2487
|
-
}
|
|
2488
|
-
function isFieldUpdateAllowed(fieldName, options = {}) {
|
|
2489
|
-
const immutableFields = getImmutableFields(options);
|
|
2490
|
-
const systemManagedFields = getSystemManagedFields(options);
|
|
2491
|
-
return !immutableFields.includes(fieldName) && !systemManagedFields.includes(fieldName);
|
|
2492
|
-
}
|
|
2493
|
-
function validateUpdateBody(body = {}, options = {}) {
|
|
2494
|
-
const violations = [];
|
|
2495
|
-
const immutableFields = getImmutableFields(options);
|
|
2496
|
-
const systemManagedFields = getSystemManagedFields(options);
|
|
2497
|
-
Object.keys(body).forEach((field) => {
|
|
2498
|
-
if (immutableFields.includes(field)) {
|
|
2499
|
-
violations.push({ field, reason: "Field is immutable" });
|
|
2500
|
-
} else if (systemManagedFields.includes(field)) {
|
|
2501
|
-
violations.push({ field, reason: "Field is system-managed" });
|
|
2502
|
-
}
|
|
2503
|
-
});
|
|
2504
|
-
return {
|
|
2505
|
-
valid: violations.length === 0,
|
|
2506
|
-
violations
|
|
2507
|
-
};
|
|
2508
|
-
}
|
|
2509
|
-
function jsonTypeFor(def, options, seen) {
|
|
2510
|
-
if (Array.isArray(def)) {
|
|
2511
|
-
if (def[0] === mongoose4.Schema.Types.Mixed) {
|
|
2512
|
-
return { type: "array", items: { type: "object", additionalProperties: true } };
|
|
2513
|
-
}
|
|
2514
|
-
return { type: "array", items: jsonTypeFor(def[0] ?? String, options, seen) };
|
|
1017
|
+
/**
|
|
1018
|
+
* Create an aggregation builder for this model
|
|
1019
|
+
* Useful for building complex custom aggregations
|
|
1020
|
+
*
|
|
1021
|
+
* @example
|
|
1022
|
+
* ```typescript
|
|
1023
|
+
* const pipeline = repo.buildAggregation()
|
|
1024
|
+
* .match({ status: 'active' })
|
|
1025
|
+
* .lookup('departments', 'deptSlug', 'slug', 'department', true)
|
|
1026
|
+
* .group({ _id: '$department', count: { $sum: 1 } })
|
|
1027
|
+
* .sort({ count: -1 })
|
|
1028
|
+
* .build();
|
|
1029
|
+
*
|
|
1030
|
+
* const results = await repo.Model.aggregate(pipeline);
|
|
1031
|
+
* ```
|
|
1032
|
+
*/
|
|
1033
|
+
buildAggregation() {
|
|
1034
|
+
return new AggregationBuilder();
|
|
2515
1035
|
}
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
1036
|
+
/**
|
|
1037
|
+
* Create a lookup builder
|
|
1038
|
+
* Useful for building $lookup stages independently
|
|
1039
|
+
*
|
|
1040
|
+
* @example
|
|
1041
|
+
* ```typescript
|
|
1042
|
+
* const lookupStages = repo.buildLookup('departments')
|
|
1043
|
+
* .localField('deptSlug')
|
|
1044
|
+
* .foreignField('slug')
|
|
1045
|
+
* .as('department')
|
|
1046
|
+
* .single()
|
|
1047
|
+
* .build();
|
|
1048
|
+
*
|
|
1049
|
+
* const pipeline = [
|
|
1050
|
+
* { $match: { status: 'active' } },
|
|
1051
|
+
* ...lookupStages
|
|
1052
|
+
* ];
|
|
1053
|
+
* ```
|
|
1054
|
+
*/
|
|
1055
|
+
buildLookup(from) {
|
|
1056
|
+
return new LookupBuilder(from);
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Execute callback within a transaction
|
|
1060
|
+
*/
|
|
1061
|
+
async withTransaction(callback, options = {}) {
|
|
1062
|
+
const session = await mongoose.startSession();
|
|
1063
|
+
let started = false;
|
|
1064
|
+
try {
|
|
1065
|
+
session.startTransaction();
|
|
1066
|
+
started = true;
|
|
1067
|
+
const result = await callback(session);
|
|
1068
|
+
await session.commitTransaction();
|
|
1069
|
+
return result;
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
const err = error;
|
|
1072
|
+
if (options.allowFallback && this._isTransactionUnsupported(err)) {
|
|
1073
|
+
if (typeof options.onFallback === "function") {
|
|
1074
|
+
options.onFallback(err);
|
|
1075
|
+
}
|
|
1076
|
+
if (started && session.inTransaction()) {
|
|
1077
|
+
try {
|
|
1078
|
+
await session.abortTransaction();
|
|
1079
|
+
} catch {
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return await callback(null);
|
|
2525
1083
|
}
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
if (typedDef.type === String) return { type: "string" };
|
|
2529
|
-
if (typedDef.type === Number) return { type: "number" };
|
|
2530
|
-
if (typedDef.type === Boolean) return { type: "boolean" };
|
|
2531
|
-
if (typedDef.type === Date) {
|
|
2532
|
-
const mode = options?.dateAs || "datetime";
|
|
2533
|
-
return mode === "date" ? { type: "string", format: "date" } : { type: "string", format: "date-time" };
|
|
2534
|
-
}
|
|
2535
|
-
if (typedDef.type === Map || typedDef.type === mongoose4.Schema.Types.Map) {
|
|
2536
|
-
const ofSchema = jsonTypeFor(typedDef.of || String, options, seen);
|
|
2537
|
-
return { type: "object", additionalProperties: ofSchema };
|
|
2538
|
-
}
|
|
2539
|
-
if (typedDef.type === mongoose4.Schema.Types.Mixed) {
|
|
2540
|
-
return { type: "object", additionalProperties: true };
|
|
2541
|
-
}
|
|
2542
|
-
if (isObjectIdType(typedDef.type)) {
|
|
2543
|
-
return { type: "string", pattern: "^[0-9a-fA-F]{24}$" };
|
|
2544
|
-
}
|
|
2545
|
-
if (isMongooseSchema(typedDef.type)) {
|
|
2546
|
-
const obj = typedDef.type.obj;
|
|
2547
|
-
if (obj && typeof obj === "object") {
|
|
2548
|
-
if (seen.has(obj)) return { type: "object", additionalProperties: true };
|
|
2549
|
-
seen.add(obj);
|
|
2550
|
-
return convertTreeToJsonSchema(obj, options, seen);
|
|
1084
|
+
if (started && session.inTransaction()) {
|
|
1085
|
+
await session.abortTransaction();
|
|
2551
1086
|
}
|
|
1087
|
+
throw err;
|
|
1088
|
+
} finally {
|
|
1089
|
+
session.endSession();
|
|
2552
1090
|
}
|
|
2553
1091
|
}
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
if (def === Date) {
|
|
2558
|
-
const mode = options?.dateAs || "datetime";
|
|
2559
|
-
return mode === "date" ? { type: "string", format: "date" } : { type: "string", format: "date-time" };
|
|
2560
|
-
}
|
|
2561
|
-
if (isObjectIdType(def)) return { type: "string", pattern: "^[0-9a-fA-F]{24}$" };
|
|
2562
|
-
if (isPlainObject(def)) {
|
|
2563
|
-
if (seen.has(def)) return { type: "object", additionalProperties: true };
|
|
2564
|
-
seen.add(def);
|
|
2565
|
-
return convertTreeToJsonSchema(def, options, seen);
|
|
1092
|
+
_isTransactionUnsupported(error) {
|
|
1093
|
+
const message = (error.message || "").toLowerCase();
|
|
1094
|
+
return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("replica set") || message.includes("mongos");
|
|
2566
1095
|
}
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
if (key === "__v" || key === "_id" || key === "id") continue;
|
|
2581
|
-
const cfg = isPlainObject(val) && "type" in val ? val : { };
|
|
2582
|
-
properties[key] = jsonTypeFor(val, options, seen);
|
|
2583
|
-
if (cfg.required === true) required.push(key);
|
|
2584
|
-
}
|
|
2585
|
-
const schema = { type: "object", properties };
|
|
2586
|
-
if (required.length) schema.required = required;
|
|
2587
|
-
return schema;
|
|
2588
|
-
}
|
|
2589
|
-
function buildJsonSchemaForCreate(tree, options) {
|
|
2590
|
-
const base = convertTreeToJsonSchema(tree, options, /* @__PURE__ */ new WeakSet());
|
|
2591
|
-
const fieldsToOmit = /* @__PURE__ */ new Set(["createdAt", "updatedAt", "__v"]);
|
|
2592
|
-
(options?.create?.omitFields || []).forEach((f) => fieldsToOmit.add(f));
|
|
2593
|
-
const fieldRules = options?.fieldRules || {};
|
|
2594
|
-
Object.entries(fieldRules).forEach(([field, rules]) => {
|
|
2595
|
-
if (rules.systemManaged) {
|
|
2596
|
-
fieldsToOmit.add(field);
|
|
2597
|
-
}
|
|
2598
|
-
});
|
|
2599
|
-
fieldsToOmit.forEach((field) => {
|
|
2600
|
-
if (base.properties?.[field]) {
|
|
2601
|
-
delete base.properties[field];
|
|
2602
|
-
}
|
|
2603
|
-
if (base.required) {
|
|
2604
|
-
base.required = base.required.filter((k) => k !== field);
|
|
2605
|
-
}
|
|
2606
|
-
});
|
|
2607
|
-
const reqOv = options?.create?.requiredOverrides || {};
|
|
2608
|
-
const optOv = options?.create?.optionalOverrides || {};
|
|
2609
|
-
base.required = base.required || [];
|
|
2610
|
-
for (const [k, v] of Object.entries(reqOv)) {
|
|
2611
|
-
if (v && !base.required.includes(k)) base.required.push(k);
|
|
2612
|
-
}
|
|
2613
|
-
for (const [k, v] of Object.entries(optOv)) {
|
|
2614
|
-
if (v && base.required) base.required = base.required.filter((x) => x !== k);
|
|
2615
|
-
}
|
|
2616
|
-
Object.entries(fieldRules).forEach(([field, rules]) => {
|
|
2617
|
-
if (rules.optional && base.required) {
|
|
2618
|
-
base.required = base.required.filter((x) => x !== field);
|
|
1096
|
+
/**
|
|
1097
|
+
* Execute custom query with event emission
|
|
1098
|
+
*/
|
|
1099
|
+
async _executeQuery(buildQuery) {
|
|
1100
|
+
const operation = buildQuery.name || "custom";
|
|
1101
|
+
const context = await this._buildContext(operation, {});
|
|
1102
|
+
try {
|
|
1103
|
+
const result = await buildQuery(this.Model);
|
|
1104
|
+
await this._emitHook(`after:${operation}`, { context, result });
|
|
1105
|
+
return result;
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
await this._emitErrorHook(`error:${operation}`, { context, error });
|
|
1108
|
+
throw this._handleError(error);
|
|
2619
1109
|
}
|
|
2620
|
-
}
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Build operation context and run before hooks
|
|
1113
|
+
*/
|
|
1114
|
+
async _buildContext(operation, options) {
|
|
1115
|
+
const context = { operation, model: this.model, ...options };
|
|
1116
|
+
const event = `before:${operation}`;
|
|
1117
|
+
const hooks = this._hooks.get(event) || [];
|
|
1118
|
+
for (const hook of hooks) {
|
|
1119
|
+
await hook(context);
|
|
2625
1120
|
}
|
|
1121
|
+
return context;
|
|
2626
1122
|
}
|
|
2627
|
-
|
|
2628
|
-
|
|
1123
|
+
/**
|
|
1124
|
+
* Parse sort string or object
|
|
1125
|
+
*/
|
|
1126
|
+
_parseSort(sort) {
|
|
1127
|
+
if (!sort) return { createdAt: -1 };
|
|
1128
|
+
if (typeof sort === "object") return sort;
|
|
1129
|
+
const sortOrder = sort.startsWith("-") ? -1 : 1;
|
|
1130
|
+
const sortField = sort.startsWith("-") ? sort.substring(1) : sort;
|
|
1131
|
+
return { [sortField]: sortOrder };
|
|
2629
1132
|
}
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
Object.entries(fieldRules).forEach(([field, rules]) => {
|
|
2639
|
-
if (rules.immutable || rules.immutableAfterCreate) {
|
|
2640
|
-
fieldsToOmit.add(field);
|
|
2641
|
-
}
|
|
2642
|
-
});
|
|
2643
|
-
fieldsToOmit.forEach((field) => {
|
|
2644
|
-
if (clone.properties?.[field]) {
|
|
2645
|
-
delete clone.properties[field];
|
|
2646
|
-
}
|
|
2647
|
-
});
|
|
2648
|
-
if (options?.strictAdditionalProperties === true) {
|
|
2649
|
-
clone.additionalProperties = false;
|
|
1133
|
+
/**
|
|
1134
|
+
* Parse populate specification
|
|
1135
|
+
*/
|
|
1136
|
+
_parsePopulate(populate) {
|
|
1137
|
+
if (!populate) return [];
|
|
1138
|
+
if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
|
|
1139
|
+
if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
|
|
1140
|
+
return [populate];
|
|
2650
1141
|
}
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
search: { type: "string" },
|
|
2662
|
-
select: { type: "string" },
|
|
2663
|
-
lean: { type: "string" },
|
|
2664
|
-
includeDeleted: { type: "string" }
|
|
2665
|
-
},
|
|
2666
|
-
additionalProperties: true
|
|
2667
|
-
};
|
|
2668
|
-
const filterable = options?.query?.filterableFields || {};
|
|
2669
|
-
for (const [k, v] of Object.entries(filterable)) {
|
|
2670
|
-
if (basePagination.properties) {
|
|
2671
|
-
basePagination.properties[k] = v && typeof v === "object" && "type" in v ? v : { type: "string" };
|
|
1142
|
+
/**
|
|
1143
|
+
* Handle errors with proper HTTP status codes
|
|
1144
|
+
*/
|
|
1145
|
+
_handleError(error) {
|
|
1146
|
+
if (error instanceof mongoose.Error.ValidationError) {
|
|
1147
|
+
const messages = Object.values(error.errors).map((err) => err.message);
|
|
1148
|
+
return createError(400, `Validation Error: ${messages.join(", ")}`);
|
|
1149
|
+
}
|
|
1150
|
+
if (error instanceof mongoose.Error.CastError) {
|
|
1151
|
+
return createError(400, `Invalid ${error.path}: ${error.value}`);
|
|
2672
1152
|
}
|
|
1153
|
+
if (error.status && error.message) return error;
|
|
1154
|
+
return createError(500, error.message || "Internal Server Error");
|
|
2673
1155
|
}
|
|
2674
|
-
|
|
2675
|
-
}
|
|
1156
|
+
};
|
|
2676
1157
|
var QueryParser = class {
|
|
2677
1158
|
options;
|
|
2678
1159
|
operators = {
|
|
@@ -2691,21 +1172,26 @@ var QueryParser = class {
|
|
|
2691
1172
|
size: "$size",
|
|
2692
1173
|
type: "$type"
|
|
2693
1174
|
};
|
|
2694
|
-
/**
|
|
2695
|
-
* Dangerous MongoDB operators that should never be accepted from user input
|
|
2696
|
-
* Security: Prevent NoSQL injection attacks
|
|
2697
|
-
*/
|
|
2698
1175
|
dangerousOperators;
|
|
2699
1176
|
/**
|
|
2700
|
-
* Regex
|
|
1177
|
+
* Regex patterns that can cause catastrophic backtracking (ReDoS attacks)
|
|
1178
|
+
* Detects:
|
|
1179
|
+
* - Quantifiers: {n,m}
|
|
1180
|
+
* - Possessive quantifiers: *+, ++, ?+
|
|
1181
|
+
* - Nested quantifiers: (a+)+, (a*)*
|
|
1182
|
+
* - Backreferences: \1, \2, etc.
|
|
1183
|
+
* - Complex character classes: [...]...[...]
|
|
2701
1184
|
*/
|
|
2702
|
-
dangerousRegexPatterns = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(
|
|
1185
|
+
dangerousRegexPatterns = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?\:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
|
|
2703
1186
|
constructor(options = {}) {
|
|
2704
1187
|
this.options = {
|
|
2705
1188
|
maxRegexLength: options.maxRegexLength ?? 500,
|
|
2706
1189
|
maxSearchLength: options.maxSearchLength ?? 200,
|
|
2707
1190
|
maxFilterDepth: options.maxFilterDepth ?? 10,
|
|
2708
|
-
|
|
1191
|
+
maxLimit: options.maxLimit ?? 1e3,
|
|
1192
|
+
additionalDangerousOperators: options.additionalDangerousOperators ?? [],
|
|
1193
|
+
enableLookups: options.enableLookups ?? true,
|
|
1194
|
+
enableAggregations: options.enableAggregations ?? false
|
|
2709
1195
|
};
|
|
2710
1196
|
this.dangerousOperators = [
|
|
2711
1197
|
"$where",
|
|
@@ -2716,9 +1202,16 @@ var QueryParser = class {
|
|
|
2716
1202
|
];
|
|
2717
1203
|
}
|
|
2718
1204
|
/**
|
|
2719
|
-
* Parse query parameters into MongoDB query format
|
|
1205
|
+
* Parse URL query parameters into MongoDB query format
|
|
1206
|
+
*
|
|
1207
|
+
* @example
|
|
1208
|
+
* ```typescript
|
|
1209
|
+
* // URL: ?status=active&lookup[department][foreignField]=slug&sort=-createdAt&page=1
|
|
1210
|
+
* const query = parser.parse(req.query);
|
|
1211
|
+
* // Returns: { filters: {...}, lookups: [...], sort: {...}, page: 1 }
|
|
1212
|
+
* ```
|
|
2720
1213
|
*/
|
|
2721
|
-
|
|
1214
|
+
parse(query) {
|
|
2722
1215
|
const {
|
|
2723
1216
|
page,
|
|
2724
1217
|
limit = 20,
|
|
@@ -2727,15 +1220,35 @@ var QueryParser = class {
|
|
|
2727
1220
|
search,
|
|
2728
1221
|
after,
|
|
2729
1222
|
cursor,
|
|
1223
|
+
select,
|
|
1224
|
+
lookup,
|
|
1225
|
+
aggregate: aggregate2,
|
|
2730
1226
|
...filters
|
|
2731
1227
|
} = query || {};
|
|
1228
|
+
let parsedLimit = parseInt(String(limit), 10);
|
|
1229
|
+
if (isNaN(parsedLimit) || parsedLimit < 1) {
|
|
1230
|
+
parsedLimit = 20;
|
|
1231
|
+
}
|
|
1232
|
+
if (parsedLimit > this.options.maxLimit) {
|
|
1233
|
+
console.warn(`[mongokit] Limit ${parsedLimit} exceeds maximum ${this.options.maxLimit}, capping to max`);
|
|
1234
|
+
parsedLimit = this.options.maxLimit;
|
|
1235
|
+
}
|
|
2732
1236
|
const parsed = {
|
|
2733
1237
|
filters: this._parseFilters(filters),
|
|
2734
|
-
limit:
|
|
1238
|
+
limit: parsedLimit,
|
|
2735
1239
|
sort: this._parseSort(sort),
|
|
2736
1240
|
populate,
|
|
2737
1241
|
search: this._sanitizeSearch(search)
|
|
2738
1242
|
};
|
|
1243
|
+
if (select) {
|
|
1244
|
+
parsed.select = this._parseSelect(select);
|
|
1245
|
+
}
|
|
1246
|
+
if (this.options.enableLookups && lookup) {
|
|
1247
|
+
parsed.lookups = this._parseLookups(lookup);
|
|
1248
|
+
}
|
|
1249
|
+
if (this.options.enableAggregations && aggregate2) {
|
|
1250
|
+
parsed.aggregation = this._parseAggregation(aggregate2);
|
|
1251
|
+
}
|
|
2739
1252
|
if (after || cursor) {
|
|
2740
1253
|
parsed.after = after || cursor;
|
|
2741
1254
|
} else if (page !== void 0) {
|
|
@@ -2750,29 +1263,161 @@ var QueryParser = class {
|
|
|
2750
1263
|
parsed.filters = this._enhanceWithBetween(parsed.filters);
|
|
2751
1264
|
return parsed;
|
|
2752
1265
|
}
|
|
1266
|
+
// ============================================================
|
|
1267
|
+
// LOOKUP PARSING (NEW)
|
|
1268
|
+
// ============================================================
|
|
2753
1269
|
/**
|
|
2754
|
-
* Parse
|
|
2755
|
-
*
|
|
2756
|
-
*
|
|
1270
|
+
* Parse lookup configurations from URL parameters
|
|
1271
|
+
*
|
|
1272
|
+
* Supported formats:
|
|
1273
|
+
* 1. Simple: ?lookup[department]=slug
|
|
1274
|
+
* → Join with 'departments' collection on slug field
|
|
1275
|
+
*
|
|
1276
|
+
* 2. Detailed: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug
|
|
1277
|
+
* → Full control over join configuration
|
|
1278
|
+
*
|
|
1279
|
+
* 3. Multiple: ?lookup[department]=slug&lookup[category]=categorySlug
|
|
1280
|
+
* → Multiple lookups
|
|
1281
|
+
*
|
|
1282
|
+
* @example
|
|
1283
|
+
* ```typescript
|
|
1284
|
+
* // URL: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug&lookup[department][single]=true
|
|
1285
|
+
* const lookups = parser._parseLookups({
|
|
1286
|
+
* department: { localField: 'deptSlug', foreignField: 'slug', single: 'true' }
|
|
1287
|
+
* });
|
|
1288
|
+
* // Returns: [{ from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true }]
|
|
1289
|
+
* ```
|
|
2757
1290
|
*/
|
|
2758
|
-
|
|
2759
|
-
if (!
|
|
2760
|
-
|
|
2761
|
-
const
|
|
2762
|
-
const
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
1291
|
+
_parseLookups(lookup) {
|
|
1292
|
+
if (!lookup || typeof lookup !== "object") return [];
|
|
1293
|
+
const lookups = [];
|
|
1294
|
+
const lookupObj = lookup;
|
|
1295
|
+
for (const [collectionName, config] of Object.entries(lookupObj)) {
|
|
1296
|
+
try {
|
|
1297
|
+
const lookupConfig = this._parseSingleLookup(collectionName, config);
|
|
1298
|
+
if (lookupConfig) {
|
|
1299
|
+
lookups.push(lookupConfig);
|
|
1300
|
+
}
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
console.warn(`[mongokit] Invalid lookup config for ${collectionName}:`, error);
|
|
2768
1303
|
}
|
|
2769
1304
|
}
|
|
2770
|
-
return
|
|
1305
|
+
return lookups;
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Parse a single lookup configuration
|
|
1309
|
+
*/
|
|
1310
|
+
_parseSingleLookup(collectionName, config) {
|
|
1311
|
+
if (!config) return null;
|
|
1312
|
+
if (typeof config === "string") {
|
|
1313
|
+
return {
|
|
1314
|
+
from: this._pluralize(collectionName),
|
|
1315
|
+
localField: `${collectionName}${this._capitalize(config)}`,
|
|
1316
|
+
foreignField: config,
|
|
1317
|
+
as: collectionName,
|
|
1318
|
+
single: true
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
if (typeof config === "object" && config !== null) {
|
|
1322
|
+
const opts = config;
|
|
1323
|
+
const from = opts.from || this._pluralize(collectionName);
|
|
1324
|
+
const localField = opts.localField;
|
|
1325
|
+
const foreignField = opts.foreignField;
|
|
1326
|
+
if (!localField || !foreignField) {
|
|
1327
|
+
console.warn(`[mongokit] Lookup requires localField and foreignField for ${collectionName}`);
|
|
1328
|
+
return null;
|
|
1329
|
+
}
|
|
1330
|
+
return {
|
|
1331
|
+
from,
|
|
1332
|
+
localField,
|
|
1333
|
+
foreignField,
|
|
1334
|
+
as: opts.as || collectionName,
|
|
1335
|
+
single: opts.single === true || opts.single === "true",
|
|
1336
|
+
...opts.pipeline && Array.isArray(opts.pipeline) ? { pipeline: opts.pipeline } : {}
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
return null;
|
|
1340
|
+
}
|
|
1341
|
+
// ============================================================
|
|
1342
|
+
// AGGREGATION PARSING (ADVANCED)
|
|
1343
|
+
// ============================================================
|
|
1344
|
+
/**
|
|
1345
|
+
* Parse aggregation pipeline from URL (advanced feature)
|
|
1346
|
+
*
|
|
1347
|
+
* @example
|
|
1348
|
+
* ```typescript
|
|
1349
|
+
* // URL: ?aggregate[group][_id]=$status&aggregate[group][count]=$sum:1
|
|
1350
|
+
* const pipeline = parser._parseAggregation({
|
|
1351
|
+
* group: { _id: '$status', count: '$sum:1' }
|
|
1352
|
+
* });
|
|
1353
|
+
* ```
|
|
1354
|
+
*/
|
|
1355
|
+
_parseAggregation(aggregate2) {
|
|
1356
|
+
if (!aggregate2 || typeof aggregate2 !== "object") return void 0;
|
|
1357
|
+
const pipeline = [];
|
|
1358
|
+
const aggObj = aggregate2;
|
|
1359
|
+
for (const [stage, config] of Object.entries(aggObj)) {
|
|
1360
|
+
try {
|
|
1361
|
+
if (stage === "group" && typeof config === "object") {
|
|
1362
|
+
pipeline.push({ $group: config });
|
|
1363
|
+
} else if (stage === "match" && typeof config === "object") {
|
|
1364
|
+
const sanitizedMatch = this._sanitizeMatchConfig(config);
|
|
1365
|
+
if (Object.keys(sanitizedMatch).length > 0) {
|
|
1366
|
+
pipeline.push({ $match: sanitizedMatch });
|
|
1367
|
+
}
|
|
1368
|
+
} else if (stage === "sort" && typeof config === "object") {
|
|
1369
|
+
pipeline.push({ $sort: config });
|
|
1370
|
+
} else if (stage === "project" && typeof config === "object") {
|
|
1371
|
+
pipeline.push({ $project: config });
|
|
1372
|
+
}
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
console.warn(`[mongokit] Invalid aggregation stage ${stage}:`, error);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
return pipeline.length > 0 ? pipeline : void 0;
|
|
1378
|
+
}
|
|
1379
|
+
// ============================================================
|
|
1380
|
+
// SELECT/PROJECT PARSING
|
|
1381
|
+
// ============================================================
|
|
1382
|
+
/**
|
|
1383
|
+
* Parse select/project fields
|
|
1384
|
+
*
|
|
1385
|
+
* @example
|
|
1386
|
+
* ```typescript
|
|
1387
|
+
* // URL: ?select=name,email,-password
|
|
1388
|
+
* // Returns: { name: 1, email: 1, password: 0 }
|
|
1389
|
+
* ```
|
|
1390
|
+
*/
|
|
1391
|
+
_parseSelect(select) {
|
|
1392
|
+
if (!select) return void 0;
|
|
1393
|
+
if (typeof select === "string") {
|
|
1394
|
+
const projection = {};
|
|
1395
|
+
const fields = select.split(",").map((f) => f.trim());
|
|
1396
|
+
for (const field of fields) {
|
|
1397
|
+
if (field.startsWith("-")) {
|
|
1398
|
+
projection[field.substring(1)] = 0;
|
|
1399
|
+
} else {
|
|
1400
|
+
projection[field] = 1;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
return projection;
|
|
1404
|
+
}
|
|
1405
|
+
if (typeof select === "object" && select !== null) {
|
|
1406
|
+
return select;
|
|
1407
|
+
}
|
|
1408
|
+
return void 0;
|
|
2771
1409
|
}
|
|
1410
|
+
// ============================================================
|
|
1411
|
+
// FILTER PARSING (Enhanced from original)
|
|
1412
|
+
// ============================================================
|
|
2772
1413
|
/**
|
|
2773
|
-
* Parse
|
|
1414
|
+
* Parse filter parameters
|
|
2774
1415
|
*/
|
|
2775
|
-
_parseFilters(filters) {
|
|
1416
|
+
_parseFilters(filters, depth = 0) {
|
|
1417
|
+
if (depth > this.options.maxFilterDepth) {
|
|
1418
|
+
console.warn(`[mongokit] Filter depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating`);
|
|
1419
|
+
return {};
|
|
1420
|
+
}
|
|
2776
1421
|
const parsedFilters = {};
|
|
2777
1422
|
const regexFields = {};
|
|
2778
1423
|
for (const [key, value] of Object.entries(filters)) {
|
|
@@ -2780,7 +1425,7 @@ var QueryParser = class {
|
|
|
2780
1425
|
console.warn(`[mongokit] Blocked dangerous operator: ${key}`);
|
|
2781
1426
|
continue;
|
|
2782
1427
|
}
|
|
2783
|
-
if (["page", "limit", "sort", "populate", "search", "select", "lean", "includeDeleted"].includes(key)) {
|
|
1428
|
+
if (["page", "limit", "sort", "populate", "search", "select", "lean", "includeDeleted", "lookup", "aggregate"].includes(key)) {
|
|
2784
1429
|
continue;
|
|
2785
1430
|
}
|
|
2786
1431
|
const operatorMatch = key.match(/^(.+)\[(.+)\]$/);
|
|
@@ -2794,7 +1439,7 @@ var QueryParser = class {
|
|
|
2794
1439
|
continue;
|
|
2795
1440
|
}
|
|
2796
1441
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
2797
|
-
this._handleBracketSyntax(key, value, parsedFilters);
|
|
1442
|
+
this._handleBracketSyntax(key, value, parsedFilters, depth + 1);
|
|
2798
1443
|
} else {
|
|
2799
1444
|
parsedFilters[key] = this._convertValue(value);
|
|
2800
1445
|
}
|
|
@@ -2806,6 +1451,9 @@ var QueryParser = class {
|
|
|
2806
1451
|
*/
|
|
2807
1452
|
_handleOperatorSyntax(filters, regexFields, operatorMatch, value) {
|
|
2808
1453
|
const [, field, operator] = operatorMatch;
|
|
1454
|
+
if (value === "" || value === null || value === void 0) {
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
2809
1457
|
if (operator.toLowerCase() === "options" && regexFields[field]) {
|
|
2810
1458
|
const fieldValue = filters[field];
|
|
2811
1459
|
if (typeof fieldValue === "object" && fieldValue !== null && "$regex" in fieldValue) {
|
|
@@ -2823,18 +1471,18 @@ var QueryParser = class {
|
|
|
2823
1471
|
}
|
|
2824
1472
|
const mongoOperator = this._toMongoOperator(operator);
|
|
2825
1473
|
if (this.dangerousOperators.includes(mongoOperator)) {
|
|
2826
|
-
console.warn(`[mongokit] Blocked dangerous operator
|
|
1474
|
+
console.warn(`[mongokit] Blocked dangerous operator: ${mongoOperator}`);
|
|
2827
1475
|
return;
|
|
2828
1476
|
}
|
|
2829
1477
|
if (mongoOperator === "$eq") {
|
|
2830
1478
|
filters[field] = value;
|
|
2831
1479
|
} else if (mongoOperator === "$regex") {
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
filters[field] = {};
|
|
1480
|
+
const safeRegex = this._createSafeRegex(value);
|
|
1481
|
+
if (safeRegex) {
|
|
1482
|
+
filters[field] = { $regex: safeRegex };
|
|
1483
|
+
regexFields[field] = true;
|
|
2837
1484
|
}
|
|
1485
|
+
} else {
|
|
2838
1486
|
let processedValue;
|
|
2839
1487
|
const op = operator.toLowerCase();
|
|
2840
1488
|
if (["gt", "gte", "lt", "lte", "size"].includes(op)) {
|
|
@@ -2845,17 +1493,25 @@ var QueryParser = class {
|
|
|
2845
1493
|
} else {
|
|
2846
1494
|
processedValue = this._convertValue(value);
|
|
2847
1495
|
}
|
|
1496
|
+
if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) {
|
|
1497
|
+
filters[field] = {};
|
|
1498
|
+
}
|
|
2848
1499
|
filters[field][mongoOperator] = processedValue;
|
|
2849
1500
|
}
|
|
2850
1501
|
}
|
|
2851
1502
|
/**
|
|
2852
1503
|
* Handle bracket syntax with object value
|
|
2853
1504
|
*/
|
|
2854
|
-
_handleBracketSyntax(field, operators, parsedFilters) {
|
|
1505
|
+
_handleBracketSyntax(field, operators, parsedFilters, depth = 0) {
|
|
1506
|
+
if (depth > this.options.maxFilterDepth) {
|
|
1507
|
+
console.warn(`[mongokit] Nested filter depth exceeds maximum, skipping field: ${field}`);
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
2855
1510
|
if (!parsedFilters[field]) {
|
|
2856
1511
|
parsedFilters[field] = {};
|
|
2857
1512
|
}
|
|
2858
1513
|
for (const [operator, value] of Object.entries(operators)) {
|
|
1514
|
+
if (value === "" || value === null || value === void 0) continue;
|
|
2859
1515
|
if (operator === "between") {
|
|
2860
1516
|
parsedFilters[field].between = value;
|
|
2861
1517
|
continue;
|
|
@@ -2868,7 +1524,7 @@ var QueryParser = class {
|
|
|
2868
1524
|
if (isNaN(processedValue)) continue;
|
|
2869
1525
|
} else if (operator === "in" || operator === "nin") {
|
|
2870
1526
|
processedValue = Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim());
|
|
2871
|
-
} else if (operator === "like" || operator === "contains") {
|
|
1527
|
+
} else if (operator === "like" || operator === "contains" || operator === "regex") {
|
|
2872
1528
|
const safeRegex = this._createSafeRegex(value);
|
|
2873
1529
|
if (!safeRegex) continue;
|
|
2874
1530
|
processedValue = safeRegex;
|
|
@@ -2878,31 +1534,40 @@ var QueryParser = class {
|
|
|
2878
1534
|
parsedFilters[field][mongoOperator] = processedValue;
|
|
2879
1535
|
}
|
|
2880
1536
|
}
|
|
1537
|
+
if (typeof parsedFilters[field] === "object" && Object.keys(parsedFilters[field]).length === 0) {
|
|
1538
|
+
delete parsedFilters[field];
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
// ============================================================
|
|
1542
|
+
// UTILITY METHODS
|
|
1543
|
+
// ============================================================
|
|
1544
|
+
_parseSort(sort) {
|
|
1545
|
+
if (!sort) return void 0;
|
|
1546
|
+
if (typeof sort === "object") return sort;
|
|
1547
|
+
const sortObj = {};
|
|
1548
|
+
const fields = sort.split(",").map((s) => s.trim());
|
|
1549
|
+
for (const field of fields) {
|
|
1550
|
+
if (field.startsWith("-")) {
|
|
1551
|
+
sortObj[field.substring(1)] = -1;
|
|
1552
|
+
} else {
|
|
1553
|
+
sortObj[field] = 1;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
return sortObj;
|
|
2881
1557
|
}
|
|
2882
|
-
/**
|
|
2883
|
-
* Convert operator to MongoDB format
|
|
2884
|
-
*/
|
|
2885
1558
|
_toMongoOperator(operator) {
|
|
2886
1559
|
const op = operator.toLowerCase();
|
|
2887
1560
|
return op.startsWith("$") ? op : "$" + op;
|
|
2888
1561
|
}
|
|
2889
|
-
/**
|
|
2890
|
-
* Create a safe regex pattern with protection against ReDoS attacks
|
|
2891
|
-
* @param pattern - The pattern string from user input
|
|
2892
|
-
* @param flags - Regex flags (default: 'i' for case-insensitive)
|
|
2893
|
-
* @returns A safe RegExp or null if pattern is invalid/dangerous
|
|
2894
|
-
*/
|
|
2895
1562
|
_createSafeRegex(pattern, flags = "i") {
|
|
2896
|
-
if (pattern === null || pattern === void 0)
|
|
2897
|
-
return null;
|
|
2898
|
-
}
|
|
1563
|
+
if (pattern === null || pattern === void 0) return null;
|
|
2899
1564
|
const patternStr = String(pattern);
|
|
2900
1565
|
if (patternStr.length > this.options.maxRegexLength) {
|
|
2901
|
-
console.warn(`[mongokit] Regex pattern too long
|
|
1566
|
+
console.warn(`[mongokit] Regex pattern too long, truncating`);
|
|
2902
1567
|
return new RegExp(this._escapeRegex(patternStr.substring(0, this.options.maxRegexLength)), flags);
|
|
2903
1568
|
}
|
|
2904
1569
|
if (this.dangerousRegexPatterns.test(patternStr)) {
|
|
2905
|
-
console.warn("[mongokit] Potentially dangerous regex pattern
|
|
1570
|
+
console.warn("[mongokit] Potentially dangerous regex pattern, escaping");
|
|
2906
1571
|
return new RegExp(this._escapeRegex(patternStr), flags);
|
|
2907
1572
|
}
|
|
2908
1573
|
try {
|
|
@@ -2911,34 +1576,45 @@ var QueryParser = class {
|
|
|
2911
1576
|
return new RegExp(this._escapeRegex(patternStr), flags);
|
|
2912
1577
|
}
|
|
2913
1578
|
}
|
|
2914
|
-
/**
|
|
2915
|
-
* Escape special regex characters for literal matching
|
|
2916
|
-
*/
|
|
2917
1579
|
_escapeRegex(str) {
|
|
2918
1580
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2919
1581
|
}
|
|
2920
1582
|
/**
|
|
2921
|
-
* Sanitize
|
|
2922
|
-
*
|
|
2923
|
-
* @returns Sanitized search string or undefined
|
|
1583
|
+
* Sanitize $match configuration to prevent dangerous operators
|
|
1584
|
+
* Recursively filters out operators like $where, $function, $accumulator
|
|
2924
1585
|
*/
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
1586
|
+
_sanitizeMatchConfig(config) {
|
|
1587
|
+
const sanitized = {};
|
|
1588
|
+
for (const [key, value] of Object.entries(config)) {
|
|
1589
|
+
if (this.dangerousOperators.includes(key)) {
|
|
1590
|
+
console.warn(`[mongokit] Blocked dangerous operator in aggregation: ${key}`);
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
1594
|
+
sanitized[key] = this._sanitizeMatchConfig(value);
|
|
1595
|
+
} else if (Array.isArray(value)) {
|
|
1596
|
+
sanitized[key] = value.map((item) => {
|
|
1597
|
+
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
1598
|
+
return this._sanitizeMatchConfig(item);
|
|
1599
|
+
}
|
|
1600
|
+
return item;
|
|
1601
|
+
});
|
|
1602
|
+
} else {
|
|
1603
|
+
sanitized[key] = value;
|
|
1604
|
+
}
|
|
2928
1605
|
}
|
|
1606
|
+
return sanitized;
|
|
1607
|
+
}
|
|
1608
|
+
_sanitizeSearch(search) {
|
|
1609
|
+
if (search === null || search === void 0 || search === "") return void 0;
|
|
2929
1610
|
let searchStr = String(search).trim();
|
|
2930
|
-
if (!searchStr)
|
|
2931
|
-
return void 0;
|
|
2932
|
-
}
|
|
1611
|
+
if (!searchStr) return void 0;
|
|
2933
1612
|
if (searchStr.length > this.options.maxSearchLength) {
|
|
2934
|
-
console.warn(`[mongokit] Search query too long
|
|
1613
|
+
console.warn(`[mongokit] Search query too long, truncating`);
|
|
2935
1614
|
searchStr = searchStr.substring(0, this.options.maxSearchLength);
|
|
2936
1615
|
}
|
|
2937
1616
|
return searchStr;
|
|
2938
1617
|
}
|
|
2939
|
-
/**
|
|
2940
|
-
* Convert values based on operator type
|
|
2941
|
-
*/
|
|
2942
1618
|
_convertValue(value) {
|
|
2943
1619
|
if (value === null || value === void 0) return value;
|
|
2944
1620
|
if (Array.isArray(value)) return value.map((v) => this._convertValue(v));
|
|
@@ -2946,14 +1622,11 @@ var QueryParser = class {
|
|
|
2946
1622
|
const stringValue = String(value);
|
|
2947
1623
|
if (stringValue === "true") return true;
|
|
2948
1624
|
if (stringValue === "false") return false;
|
|
2949
|
-
if (
|
|
1625
|
+
if (mongoose.Types.ObjectId.isValid(stringValue) && stringValue.length === 24) {
|
|
2950
1626
|
return stringValue;
|
|
2951
1627
|
}
|
|
2952
1628
|
return stringValue;
|
|
2953
1629
|
}
|
|
2954
|
-
/**
|
|
2955
|
-
* Parse $or conditions
|
|
2956
|
-
*/
|
|
2957
1630
|
_parseOr(query) {
|
|
2958
1631
|
const orArray = [];
|
|
2959
1632
|
const raw = query?.or || query?.OR || query?.$or;
|
|
@@ -2961,14 +1634,11 @@ var QueryParser = class {
|
|
|
2961
1634
|
const items = Array.isArray(raw) ? raw : typeof raw === "object" ? Object.values(raw) : [];
|
|
2962
1635
|
for (const item of items) {
|
|
2963
1636
|
if (typeof item === "object" && item) {
|
|
2964
|
-
orArray.push(this._parseFilters(item));
|
|
1637
|
+
orArray.push(this._parseFilters(item, 1));
|
|
2965
1638
|
}
|
|
2966
1639
|
}
|
|
2967
1640
|
return orArray.length ? orArray : void 0;
|
|
2968
1641
|
}
|
|
2969
|
-
/**
|
|
2970
|
-
* Enhance filters with between operator
|
|
2971
|
-
*/
|
|
2972
1642
|
_enhanceWithBetween(filters) {
|
|
2973
1643
|
const output = { ...filters };
|
|
2974
1644
|
for (const [key, value] of Object.entries(filters || {})) {
|
|
@@ -2985,19 +1655,16 @@ var QueryParser = class {
|
|
|
2985
1655
|
}
|
|
2986
1656
|
return output;
|
|
2987
1657
|
}
|
|
1658
|
+
// String helpers
|
|
1659
|
+
_pluralize(str) {
|
|
1660
|
+
if (str.endsWith("y")) return str.slice(0, -1) + "ies";
|
|
1661
|
+
if (str.endsWith("s")) return str;
|
|
1662
|
+
return str + "s";
|
|
1663
|
+
}
|
|
1664
|
+
_capitalize(str) {
|
|
1665
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
1666
|
+
}
|
|
2988
1667
|
};
|
|
2989
|
-
var defaultQueryParser = new QueryParser();
|
|
2990
|
-
var queryParser_default = defaultQueryParser;
|
|
2991
|
-
|
|
2992
|
-
// src/actions/index.ts
|
|
2993
|
-
var actions_exports = {};
|
|
2994
|
-
__export(actions_exports, {
|
|
2995
|
-
aggregate: () => aggregate_exports,
|
|
2996
|
-
create: () => create_exports,
|
|
2997
|
-
deleteActions: () => delete_exports,
|
|
2998
|
-
read: () => read_exports,
|
|
2999
|
-
update: () => update_exports
|
|
3000
|
-
});
|
|
3001
1668
|
|
|
3002
1669
|
// src/index.ts
|
|
3003
1670
|
function createRepository(Model, plugins = [], paginationConfig = {}, options = {}) {
|
|
@@ -3011,7 +1678,7 @@ var index_default = Repository;
|
|
|
3011
1678
|
* smart pagination, events, and plugins.
|
|
3012
1679
|
*
|
|
3013
1680
|
* @module @classytic/mongokit
|
|
3014
|
-
* @author
|
|
1681
|
+
* @author Classytic (https://github.com/classytic)
|
|
3015
1682
|
* @license MIT
|
|
3016
1683
|
*
|
|
3017
1684
|
* @example
|
|
@@ -3043,4 +1710,4 @@ var index_default = Repository;
|
|
|
3043
1710
|
* ```
|
|
3044
1711
|
*/
|
|
3045
1712
|
|
|
3046
|
-
export {
|
|
1713
|
+
export { AggregationBuilder, LookupBuilder, QueryParser, Repository, createRepository, index_default as default };
|