@classytic/mongokit 3.2.0 → 3.2.2
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 +470 -193
- package/dist/actions/index.d.mts +9 -0
- package/dist/actions/index.mjs +15 -0
- package/dist/aggregate-BAi4Do-X.mjs +767 -0
- package/dist/aggregate-CCHI7F51.d.mts +269 -0
- package/dist/ai/index.d.mts +125 -0
- package/dist/ai/index.mjs +203 -0
- package/dist/cache-keys-C8Z9B5sw.mjs +204 -0
- package/dist/chunk-DQk6qfdC.mjs +18 -0
- package/dist/create-BuO6xt0v.mjs +55 -0
- package/dist/custom-id.plugin-B_zIs6gE.mjs +1818 -0
- package/dist/custom-id.plugin-BzZI4gnE.d.mts +893 -0
- package/dist/index.d.mts +1012 -0
- package/dist/index.mjs +1906 -0
- package/dist/limits-DsNeCx4D.mjs +299 -0
- package/dist/logger-D8ily-PP.mjs +51 -0
- package/dist/mongooseToJsonSchema-COdDEkIJ.mjs +317 -0
- package/dist/{mongooseToJsonSchema-CaRF_bCN.d.ts → mongooseToJsonSchema-Wbvjfwkn.d.mts} +16 -89
- package/dist/pagination/PaginationEngine.d.mts +93 -0
- package/dist/pagination/PaginationEngine.mjs +196 -0
- package/dist/plugins/index.d.mts +3 -0
- package/dist/plugins/index.mjs +3 -0
- package/dist/types-D-gploPr.d.mts +1241 -0
- package/dist/utils/{index.d.ts → index.d.mts} +14 -21
- package/dist/utils/index.mjs +5 -0
- package/package.json +21 -21
- package/dist/actions/index.d.ts +0 -3
- package/dist/actions/index.js +0 -5
- package/dist/ai/index.d.ts +0 -175
- package/dist/ai/index.js +0 -206
- package/dist/chunks/chunk-2ZN65ZOP.js +0 -93
- package/dist/chunks/chunk-44KXLGPO.js +0 -388
- package/dist/chunks/chunk-DEVXDBRL.js +0 -1226
- package/dist/chunks/chunk-I7CWNAJB.js +0 -46
- package/dist/chunks/chunk-JWUAVZ3L.js +0 -8
- package/dist/chunks/chunk-UE2IEXZJ.js +0 -306
- package/dist/chunks/chunk-URLJFIR7.js +0 -22
- package/dist/chunks/chunk-VWKIKZYF.js +0 -737
- package/dist/chunks/chunk-WSFCRVEQ.js +0 -7
- package/dist/index-BDn5fSTE.d.ts +0 -516
- package/dist/index.d.ts +0 -1422
- package/dist/index.js +0 -1893
- package/dist/pagination/PaginationEngine.d.ts +0 -117
- package/dist/pagination/PaginationEngine.js +0 -3
- package/dist/plugins/index.d.ts +0 -922
- package/dist/plugins/index.js +0 -6
- package/dist/types-Jni1KgkP.d.ts +0 -780
- package/dist/utils/index.js +0 -5
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1906 @@
|
|
|
1
|
+
import { i as createError, r as warn, t as configureLogger } from "./logger-D8ily-PP.mjs";
|
|
2
|
+
import { n as createMany, t as create } from "./create-BuO6xt0v.mjs";
|
|
3
|
+
import { a as deleteById, d as getById, f as getByQuery, i as LookupBuilder, l as count, p as getOrCreate, r as distinct, s as update, t as aggregate, u as exists } from "./aggregate-BAi4Do-X.mjs";
|
|
4
|
+
import { PaginationEngine } from "./pagination/PaginationEngine.mjs";
|
|
5
|
+
import { c as filterResponseData, l as getFieldsForUser, s as createFieldPreset, u as getMongooseProjection } from "./cache-keys-C8Z9B5sw.mjs";
|
|
6
|
+
import { C as auditLogPlugin, S as softDeletePlugin, T as fieldFilterPlugin, _ as immutableField, a as sequentialId, b as validationChainPlugin, c as multiTenantPlugin, d as subdocumentPlugin, f as aggregateHelpersPlugin, g as blockIf, h as autoInject, i as prefixedId, l as cascadePlugin, m as mongoOperationsPlugin, n as dateSequentialId, o as elasticSearchPlugin, p as batchOperationsPlugin, r as getNextSequence, s as observabilityPlugin, t as customIdPlugin, u as cachePlugin, v as requireField, w as timestampPlugin, x as methodRegistryPlugin, y as uniqueField } from "./custom-id.plugin-B_zIs6gE.mjs";
|
|
7
|
+
import { a as isFieldUpdateAllowed, i as getSystemManagedFields, n as buildCrudSchemasFromMongooseSchema, o as validateUpdateBody, r as getImmutableFields, s as createMemoryCache, t as buildCrudSchemasFromModel } from "./mongooseToJsonSchema-COdDEkIJ.mjs";
|
|
8
|
+
import { t as actions_exports } from "./actions/index.mjs";
|
|
9
|
+
import mongoose from "mongoose";
|
|
10
|
+
|
|
11
|
+
//#region src/query/AggregationBuilder.ts
|
|
12
|
+
/**
|
|
13
|
+
* Normalize SortSpec to MongoDB's strict format (1 | -1)
|
|
14
|
+
* Converts 'asc' -> 1, 'desc' -> -1
|
|
15
|
+
*/
|
|
16
|
+
function normalizeSortSpec(sortSpec) {
|
|
17
|
+
const normalized = {};
|
|
18
|
+
for (const [field, order] of Object.entries(sortSpec)) if (order === "asc") normalized[field] = 1;
|
|
19
|
+
else if (order === "desc") normalized[field] = -1;
|
|
20
|
+
else normalized[field] = order;
|
|
21
|
+
return normalized;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Fluent builder for MongoDB aggregation pipelines
|
|
25
|
+
* Optimized for complex queries at scale
|
|
26
|
+
*/
|
|
27
|
+
var AggregationBuilder = class AggregationBuilder {
|
|
28
|
+
pipeline = [];
|
|
29
|
+
_diskUse = false;
|
|
30
|
+
/**
|
|
31
|
+
* Get the current pipeline
|
|
32
|
+
*/
|
|
33
|
+
get() {
|
|
34
|
+
return [...this.pipeline];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Build and return the final pipeline
|
|
38
|
+
*/
|
|
39
|
+
build() {
|
|
40
|
+
return this.get();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build pipeline with execution options (allowDiskUse, etc.)
|
|
44
|
+
*/
|
|
45
|
+
plan() {
|
|
46
|
+
return {
|
|
47
|
+
pipeline: this.get(),
|
|
48
|
+
allowDiskUse: this._diskUse
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build and execute the pipeline against a model
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```typescript
|
|
56
|
+
* const results = await new AggregationBuilder()
|
|
57
|
+
* .match({ status: 'active' })
|
|
58
|
+
* .allowDiskUse()
|
|
59
|
+
* .exec(MyModel);
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
async exec(model, session) {
|
|
63
|
+
const agg = model.aggregate(this.build());
|
|
64
|
+
if (this._diskUse) agg.allowDiskUse(true);
|
|
65
|
+
if (session) agg.session(session);
|
|
66
|
+
return agg.exec();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Reset the pipeline
|
|
70
|
+
*/
|
|
71
|
+
reset() {
|
|
72
|
+
this.pipeline = [];
|
|
73
|
+
this._diskUse = false;
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Add a raw pipeline stage
|
|
78
|
+
*/
|
|
79
|
+
addStage(stage) {
|
|
80
|
+
this.pipeline.push(stage);
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Add multiple raw pipeline stages
|
|
85
|
+
*/
|
|
86
|
+
addStages(stages) {
|
|
87
|
+
this.pipeline.push(...stages);
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* $match - Filter documents
|
|
92
|
+
* IMPORTANT: Place $match as early as possible for performance
|
|
93
|
+
*/
|
|
94
|
+
match(query) {
|
|
95
|
+
this.pipeline.push({ $match: query });
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* $project - Include/exclude fields or compute new fields
|
|
100
|
+
*/
|
|
101
|
+
project(projection) {
|
|
102
|
+
this.pipeline.push({ $project: projection });
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* $group - Group documents and compute aggregations
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```typescript
|
|
110
|
+
* .group({
|
|
111
|
+
* _id: '$department',
|
|
112
|
+
* count: { $sum: 1 },
|
|
113
|
+
* avgSalary: { $avg: '$salary' }
|
|
114
|
+
* })
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
group(groupSpec) {
|
|
118
|
+
this.pipeline.push({ $group: groupSpec });
|
|
119
|
+
return this;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* $sort - Sort documents
|
|
123
|
+
*/
|
|
124
|
+
sort(sortSpec) {
|
|
125
|
+
if (typeof sortSpec === "string") {
|
|
126
|
+
const order = sortSpec.startsWith("-") ? -1 : 1;
|
|
127
|
+
const field = sortSpec.startsWith("-") ? sortSpec.substring(1) : sortSpec;
|
|
128
|
+
this.pipeline.push({ $sort: { [field]: order } });
|
|
129
|
+
} else this.pipeline.push({ $sort: normalizeSortSpec(sortSpec) });
|
|
130
|
+
return this;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* $limit - Limit number of documents
|
|
134
|
+
*/
|
|
135
|
+
limit(count) {
|
|
136
|
+
this.pipeline.push({ $limit: count });
|
|
137
|
+
return this;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* $skip - Skip documents
|
|
141
|
+
*/
|
|
142
|
+
skip(count) {
|
|
143
|
+
this.pipeline.push({ $skip: count });
|
|
144
|
+
return this;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* $unwind - Deconstruct array field
|
|
148
|
+
*/
|
|
149
|
+
unwind(path, preserveNullAndEmptyArrays = false) {
|
|
150
|
+
this.pipeline.push({ $unwind: {
|
|
151
|
+
path: path.startsWith("$") ? path : `$${path}`,
|
|
152
|
+
preserveNullAndEmptyArrays
|
|
153
|
+
} });
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* $addFields - Add new fields or replace existing fields
|
|
158
|
+
*/
|
|
159
|
+
addFields(fields) {
|
|
160
|
+
this.pipeline.push({ $addFields: fields });
|
|
161
|
+
return this;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* $set - Alias for $addFields
|
|
165
|
+
*/
|
|
166
|
+
set(fields) {
|
|
167
|
+
return this.addFields(fields);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* $unset - Remove fields
|
|
171
|
+
*/
|
|
172
|
+
unset(fields) {
|
|
173
|
+
this.pipeline.push({ $unset: fields });
|
|
174
|
+
return this;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* $replaceRoot - Replace the root document
|
|
178
|
+
*/
|
|
179
|
+
replaceRoot(newRoot) {
|
|
180
|
+
this.pipeline.push({ $replaceRoot: { newRoot: typeof newRoot === "string" ? `$${newRoot}` : newRoot } });
|
|
181
|
+
return this;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* $lookup - Join with another collection (simple form)
|
|
185
|
+
*
|
|
186
|
+
* @param from - Collection to join with
|
|
187
|
+
* @param localField - Field from source collection
|
|
188
|
+
* @param foreignField - Field from target collection
|
|
189
|
+
* @param as - Output field name
|
|
190
|
+
* @param single - Unwrap array to single object
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* ```typescript
|
|
194
|
+
* // Join employees with departments by slug
|
|
195
|
+
* .lookup('departments', 'deptSlug', 'slug', 'department', true)
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
lookup(from, localField, foreignField, as, single) {
|
|
199
|
+
const stages = new LookupBuilder(from).localField(localField).foreignField(foreignField).as(as || from).single(single || false).build();
|
|
200
|
+
this.pipeline.push(...stages);
|
|
201
|
+
return this;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* $lookup - Join with another collection (advanced form with pipeline)
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* ```typescript
|
|
208
|
+
* .lookupWithPipeline({
|
|
209
|
+
* from: 'products',
|
|
210
|
+
* localField: 'productIds',
|
|
211
|
+
* foreignField: 'sku',
|
|
212
|
+
* as: 'products',
|
|
213
|
+
* pipeline: [
|
|
214
|
+
* { $match: { status: 'active' } },
|
|
215
|
+
* { $project: { name: 1, price: 1 } }
|
|
216
|
+
* ]
|
|
217
|
+
* })
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
lookupWithPipeline(options) {
|
|
221
|
+
const builder = new LookupBuilder(options.from).localField(options.localField).foreignField(options.foreignField);
|
|
222
|
+
if (options.as) builder.as(options.as);
|
|
223
|
+
if (options.single) builder.single(options.single);
|
|
224
|
+
if (options.pipeline) builder.pipeline(options.pipeline);
|
|
225
|
+
if (options.let) builder.let(options.let);
|
|
226
|
+
this.pipeline.push(...builder.build());
|
|
227
|
+
return this;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Multiple lookups at once
|
|
231
|
+
*
|
|
232
|
+
* @example
|
|
233
|
+
* ```typescript
|
|
234
|
+
* .multiLookup([
|
|
235
|
+
* { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
|
|
236
|
+
* { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
|
|
237
|
+
* ])
|
|
238
|
+
* ```
|
|
239
|
+
*/
|
|
240
|
+
multiLookup(lookups) {
|
|
241
|
+
const stages = LookupBuilder.multiple(lookups);
|
|
242
|
+
this.pipeline.push(...stages);
|
|
243
|
+
return this;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* $facet - Process multiple aggregation pipelines in a single stage
|
|
247
|
+
* Useful for computing multiple aggregations in parallel
|
|
248
|
+
*
|
|
249
|
+
* @example
|
|
250
|
+
* ```typescript
|
|
251
|
+
* .facet({
|
|
252
|
+
* totalCount: [{ $count: 'count' }],
|
|
253
|
+
* avgPrice: [{ $group: { _id: null, avg: { $avg: '$price' } } }],
|
|
254
|
+
* topProducts: [{ $sort: { sales: -1 } }, { $limit: 10 }]
|
|
255
|
+
* })
|
|
256
|
+
* ```
|
|
257
|
+
*/
|
|
258
|
+
facet(facets) {
|
|
259
|
+
this.pipeline.push({ $facet: facets });
|
|
260
|
+
return this;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* $bucket - Categorize documents into buckets
|
|
264
|
+
*
|
|
265
|
+
* @example
|
|
266
|
+
* ```typescript
|
|
267
|
+
* .bucket({
|
|
268
|
+
* groupBy: '$price',
|
|
269
|
+
* boundaries: [0, 50, 100, 200],
|
|
270
|
+
* default: 'Other',
|
|
271
|
+
* output: {
|
|
272
|
+
* count: { $sum: 1 },
|
|
273
|
+
* products: { $push: '$name' }
|
|
274
|
+
* }
|
|
275
|
+
* })
|
|
276
|
+
* ```
|
|
277
|
+
*/
|
|
278
|
+
bucket(options) {
|
|
279
|
+
this.pipeline.push({ $bucket: options });
|
|
280
|
+
return this;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* $bucketAuto - Automatically determine bucket boundaries
|
|
284
|
+
*/
|
|
285
|
+
bucketAuto(options) {
|
|
286
|
+
this.pipeline.push({ $bucketAuto: options });
|
|
287
|
+
return this;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* $setWindowFields - Perform window functions (MongoDB 5.0+)
|
|
291
|
+
* Useful for rankings, running totals, moving averages
|
|
292
|
+
*
|
|
293
|
+
* @example
|
|
294
|
+
* ```typescript
|
|
295
|
+
* .setWindowFields({
|
|
296
|
+
* partitionBy: '$department',
|
|
297
|
+
* sortBy: { salary: -1 },
|
|
298
|
+
* output: {
|
|
299
|
+
* rank: { $rank: {} },
|
|
300
|
+
* runningTotal: { $sum: '$salary', window: { documents: ['unbounded', 'current'] } }
|
|
301
|
+
* }
|
|
302
|
+
* })
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
setWindowFields(options) {
|
|
306
|
+
const normalizedOptions = {
|
|
307
|
+
...options,
|
|
308
|
+
sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
|
|
309
|
+
};
|
|
310
|
+
this.pipeline.push({ $setWindowFields: normalizedOptions });
|
|
311
|
+
return this;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* $unionWith - Combine results from multiple collections (MongoDB 4.4+)
|
|
315
|
+
*
|
|
316
|
+
* @example
|
|
317
|
+
* ```typescript
|
|
318
|
+
* .unionWith({
|
|
319
|
+
* coll: 'archivedOrders',
|
|
320
|
+
* pipeline: [{ $match: { year: 2024 } }]
|
|
321
|
+
* })
|
|
322
|
+
* ```
|
|
323
|
+
*/
|
|
324
|
+
unionWith(options) {
|
|
325
|
+
this.pipeline.push({ $unionWith: options });
|
|
326
|
+
return this;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* $densify - Fill gaps in data (MongoDB 5.1+)
|
|
330
|
+
* Useful for time series data with missing points
|
|
331
|
+
*/
|
|
332
|
+
densify(options) {
|
|
333
|
+
this.pipeline.push({ $densify: options });
|
|
334
|
+
return this;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* $fill - Fill null or missing field values (MongoDB 5.3+)
|
|
338
|
+
*/
|
|
339
|
+
fill(options) {
|
|
340
|
+
const normalizedOptions = {
|
|
341
|
+
...options,
|
|
342
|
+
sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
|
|
343
|
+
};
|
|
344
|
+
this.pipeline.push({ $fill: normalizedOptions });
|
|
345
|
+
return this;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Enable allowDiskUse for large aggregations that exceed 100MB memory limit
|
|
349
|
+
*
|
|
350
|
+
* @example
|
|
351
|
+
* ```typescript
|
|
352
|
+
* const results = await new AggregationBuilder()
|
|
353
|
+
* .match({ status: 'active' })
|
|
354
|
+
* .group({ _id: '$category', total: { $sum: '$amount' } })
|
|
355
|
+
* .allowDiskUse()
|
|
356
|
+
* .exec(Model);
|
|
357
|
+
* ```
|
|
358
|
+
*/
|
|
359
|
+
allowDiskUse(enable = true) {
|
|
360
|
+
this._diskUse = enable;
|
|
361
|
+
return this;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Paginate - Add skip and limit for offset-based pagination
|
|
365
|
+
*/
|
|
366
|
+
paginate(page, limit) {
|
|
367
|
+
const skip = (page - 1) * limit;
|
|
368
|
+
return this.skip(skip).limit(limit);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Count total documents (useful with $facet for pagination metadata)
|
|
372
|
+
*/
|
|
373
|
+
count(outputField = "count") {
|
|
374
|
+
this.pipeline.push({ $count: outputField });
|
|
375
|
+
return this;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Sample - Randomly select N documents
|
|
379
|
+
*/
|
|
380
|
+
sample(size) {
|
|
381
|
+
this.pipeline.push({ $sample: { size } });
|
|
382
|
+
return this;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Out - Write results to a collection
|
|
386
|
+
*/
|
|
387
|
+
out(collection) {
|
|
388
|
+
this.pipeline.push({ $out: collection });
|
|
389
|
+
return this;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Merge - Merge results into a collection
|
|
393
|
+
*/
|
|
394
|
+
merge(options) {
|
|
395
|
+
this.pipeline.push({ $merge: typeof options === "string" ? { into: options } : options });
|
|
396
|
+
return this;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* GeoNear - Perform geospatial queries
|
|
400
|
+
*/
|
|
401
|
+
geoNear(options) {
|
|
402
|
+
this.pipeline.push({ $geoNear: options });
|
|
403
|
+
return this;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* GraphLookup - Perform recursive search (graph traversal)
|
|
407
|
+
*/
|
|
408
|
+
graphLookup(options) {
|
|
409
|
+
this.pipeline.push({ $graphLookup: options });
|
|
410
|
+
return this;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* $search - Atlas Search full-text search (Atlas only)
|
|
414
|
+
*
|
|
415
|
+
* @example
|
|
416
|
+
* ```typescript
|
|
417
|
+
* .search({
|
|
418
|
+
* index: 'default',
|
|
419
|
+
* text: {
|
|
420
|
+
* query: 'laptop computer',
|
|
421
|
+
* path: ['title', 'description'],
|
|
422
|
+
* fuzzy: { maxEdits: 2 }
|
|
423
|
+
* }
|
|
424
|
+
* })
|
|
425
|
+
* ```
|
|
426
|
+
*/
|
|
427
|
+
search(options) {
|
|
428
|
+
this.pipeline.push({ $search: options });
|
|
429
|
+
return this;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* $searchMeta - Get Atlas Search metadata (Atlas only)
|
|
433
|
+
*/
|
|
434
|
+
searchMeta(options) {
|
|
435
|
+
this.pipeline.push({ $searchMeta: options });
|
|
436
|
+
return this;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* $vectorSearch - Semantic similarity search using vector embeddings (Atlas only)
|
|
440
|
+
*
|
|
441
|
+
* Requires an Atlas Vector Search index on the target field.
|
|
442
|
+
* Must be the first stage in the pipeline.
|
|
443
|
+
*
|
|
444
|
+
* @example
|
|
445
|
+
* ```typescript
|
|
446
|
+
* const results = await new AggregationBuilder()
|
|
447
|
+
* .vectorSearch({
|
|
448
|
+
* index: 'vector_index',
|
|
449
|
+
* path: 'embedding',
|
|
450
|
+
* queryVector: await getEmbedding('running shoes'),
|
|
451
|
+
* limit: 10,
|
|
452
|
+
* numCandidates: 100,
|
|
453
|
+
* filter: { category: 'footwear' }
|
|
454
|
+
* })
|
|
455
|
+
* .project({ embedding: 0, score: { $meta: 'vectorSearchScore' } })
|
|
456
|
+
* .exec(ProductModel);
|
|
457
|
+
* ```
|
|
458
|
+
*/
|
|
459
|
+
vectorSearch(options) {
|
|
460
|
+
if (this.pipeline.length > 0) throw new Error("[mongokit] $vectorSearch must be the first stage in the pipeline");
|
|
461
|
+
const rawCandidates = options.numCandidates ?? Math.max(options.limit * 10, 100);
|
|
462
|
+
const numCandidates = Math.min(Math.max(rawCandidates, options.limit), 1e4);
|
|
463
|
+
this.pipeline.push({ $vectorSearch: {
|
|
464
|
+
index: options.index,
|
|
465
|
+
path: options.path,
|
|
466
|
+
queryVector: options.queryVector,
|
|
467
|
+
numCandidates,
|
|
468
|
+
limit: options.limit,
|
|
469
|
+
...options.filter && { filter: options.filter },
|
|
470
|
+
...options.exact && { exact: options.exact }
|
|
471
|
+
} });
|
|
472
|
+
return this;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Add vectorSearchScore as a field after $vectorSearch
|
|
476
|
+
* Convenience for `.addFields({ score: { $meta: 'vectorSearchScore' } })`
|
|
477
|
+
*/
|
|
478
|
+
withVectorScore(fieldName = "score") {
|
|
479
|
+
return this.addFields({ [fieldName]: { $meta: "vectorSearchScore" } });
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Create a builder from an existing pipeline
|
|
483
|
+
*/
|
|
484
|
+
static from(pipeline) {
|
|
485
|
+
const builder = new AggregationBuilder();
|
|
486
|
+
builder.pipeline = [...pipeline];
|
|
487
|
+
return builder;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Create a builder with initial match stage
|
|
491
|
+
*/
|
|
492
|
+
static startWith(query) {
|
|
493
|
+
return new AggregationBuilder().match(query);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
//#endregion
|
|
498
|
+
//#region src/Repository.ts
|
|
499
|
+
/**
|
|
500
|
+
* Repository Pattern - Data Access Layer
|
|
501
|
+
*
|
|
502
|
+
* Event-driven, plugin-based abstraction for MongoDB operations
|
|
503
|
+
* Inspired by Meta & Stripe's repository patterns
|
|
504
|
+
*
|
|
505
|
+
* @example
|
|
506
|
+
* ```typescript
|
|
507
|
+
* const userRepo = new Repository(UserModel, [
|
|
508
|
+
* timestampPlugin(),
|
|
509
|
+
* softDeletePlugin(),
|
|
510
|
+
* ]);
|
|
511
|
+
*
|
|
512
|
+
* // Create
|
|
513
|
+
* const user = await userRepo.create({ name: 'John', email: 'john@example.com' });
|
|
514
|
+
*
|
|
515
|
+
* // Read with pagination
|
|
516
|
+
* const users = await userRepo.getAll({ page: 1, limit: 20, filters: { status: 'active' } });
|
|
517
|
+
*
|
|
518
|
+
* // Update
|
|
519
|
+
* const updated = await userRepo.update(user._id, { name: 'John Doe' });
|
|
520
|
+
*
|
|
521
|
+
* // Delete
|
|
522
|
+
* await userRepo.delete(user._id);
|
|
523
|
+
* ```
|
|
524
|
+
*/
|
|
525
|
+
/**
|
|
526
|
+
* Production-grade repository for MongoDB
|
|
527
|
+
* Event-driven, plugin-based, with smart pagination
|
|
528
|
+
*/
|
|
529
|
+
var Repository = class {
|
|
530
|
+
Model;
|
|
531
|
+
model;
|
|
532
|
+
_hooks;
|
|
533
|
+
_pagination;
|
|
534
|
+
_hookMode;
|
|
535
|
+
_hasTextIndex = null;
|
|
536
|
+
constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
|
|
537
|
+
this.Model = Model;
|
|
538
|
+
this.model = Model.modelName;
|
|
539
|
+
this._hooks = /* @__PURE__ */ new Map();
|
|
540
|
+
this._pagination = new PaginationEngine(Model, paginationConfig);
|
|
541
|
+
this._hookMode = options.hooks ?? "async";
|
|
542
|
+
plugins.forEach((plugin) => this.use(plugin));
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Register a plugin
|
|
546
|
+
*/
|
|
547
|
+
use(plugin) {
|
|
548
|
+
if (typeof plugin === "function") plugin(this);
|
|
549
|
+
else if (plugin && typeof plugin.apply === "function") plugin.apply(this);
|
|
550
|
+
return this;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Register event listener
|
|
554
|
+
*/
|
|
555
|
+
on(event, listener) {
|
|
556
|
+
if (!this._hooks.has(event)) this._hooks.set(event, []);
|
|
557
|
+
this._hooks.get(event).push(listener);
|
|
558
|
+
return this;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Remove a specific event listener
|
|
562
|
+
*/
|
|
563
|
+
off(event, listener) {
|
|
564
|
+
const listeners = this._hooks.get(event);
|
|
565
|
+
if (listeners) {
|
|
566
|
+
const idx = listeners.indexOf(listener);
|
|
567
|
+
if (idx !== -1) listeners.splice(idx, 1);
|
|
568
|
+
}
|
|
569
|
+
return this;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Remove all listeners for an event, or all listeners entirely
|
|
573
|
+
*/
|
|
574
|
+
removeAllListeners(event) {
|
|
575
|
+
if (event) this._hooks.delete(event);
|
|
576
|
+
else this._hooks.clear();
|
|
577
|
+
return this;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Emit event (sync - for backwards compatibility)
|
|
581
|
+
*/
|
|
582
|
+
emit(event, data) {
|
|
583
|
+
const listeners = this._hooks.get(event) || [];
|
|
584
|
+
for (const listener of listeners) try {
|
|
585
|
+
const result = listener(data);
|
|
586
|
+
if (result && typeof result.then === "function") result.catch((error) => {
|
|
587
|
+
if (event === "error:hook") return;
|
|
588
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
589
|
+
this.emit("error:hook", {
|
|
590
|
+
event,
|
|
591
|
+
error: err
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
} catch (error) {
|
|
595
|
+
if (event === "error:hook") continue;
|
|
596
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
597
|
+
this.emit("error:hook", {
|
|
598
|
+
event,
|
|
599
|
+
error: err
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Emit event and await all async handlers
|
|
605
|
+
*/
|
|
606
|
+
async emitAsync(event, data) {
|
|
607
|
+
const listeners = this._hooks.get(event) || [];
|
|
608
|
+
for (const listener of listeners) await listener(data);
|
|
609
|
+
}
|
|
610
|
+
async _emitHook(event, data) {
|
|
611
|
+
if (this._hookMode === "async") {
|
|
612
|
+
await this.emitAsync(event, data);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
this.emit(event, data);
|
|
616
|
+
}
|
|
617
|
+
async _emitErrorHook(event, data) {
|
|
618
|
+
try {
|
|
619
|
+
await this._emitHook(event, data);
|
|
620
|
+
} catch (hookError) {
|
|
621
|
+
warn(`[${this.model}] Error hook '${event}' threw: ${hookError instanceof Error ? hookError.message : String(hookError)}`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Create single document
|
|
626
|
+
*/
|
|
627
|
+
async create(data, options = {}) {
|
|
628
|
+
const context = await this._buildContext("create", {
|
|
629
|
+
data,
|
|
630
|
+
...options
|
|
631
|
+
});
|
|
632
|
+
try {
|
|
633
|
+
const result = await create(this.Model, context.data || data, options);
|
|
634
|
+
await this._emitHook("after:create", {
|
|
635
|
+
context,
|
|
636
|
+
result
|
|
637
|
+
});
|
|
638
|
+
return result;
|
|
639
|
+
} catch (error) {
|
|
640
|
+
await this._emitErrorHook("error:create", {
|
|
641
|
+
context,
|
|
642
|
+
error
|
|
643
|
+
});
|
|
644
|
+
throw this._handleError(error);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Create multiple documents
|
|
649
|
+
*/
|
|
650
|
+
async createMany(dataArray, options = {}) {
|
|
651
|
+
const context = await this._buildContext("createMany", {
|
|
652
|
+
dataArray,
|
|
653
|
+
...options
|
|
654
|
+
});
|
|
655
|
+
try {
|
|
656
|
+
const result = await createMany(this.Model, context.dataArray || dataArray, options);
|
|
657
|
+
await this._emitHook("after:createMany", {
|
|
658
|
+
context,
|
|
659
|
+
result
|
|
660
|
+
});
|
|
661
|
+
return result;
|
|
662
|
+
} catch (error) {
|
|
663
|
+
await this._emitErrorHook("error:createMany", {
|
|
664
|
+
context,
|
|
665
|
+
error
|
|
666
|
+
});
|
|
667
|
+
throw this._handleError(error);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Get document by ID
|
|
672
|
+
*/
|
|
673
|
+
async getById(id, options = {}) {
|
|
674
|
+
const populateSpec = options.populateOptions || options.populate;
|
|
675
|
+
const context = await this._buildContext("getById", {
|
|
676
|
+
id,
|
|
677
|
+
...options,
|
|
678
|
+
populate: populateSpec
|
|
679
|
+
});
|
|
680
|
+
if (context._cacheHit) return context._cachedResult;
|
|
681
|
+
try {
|
|
682
|
+
const result = await getById(this.Model, id, context);
|
|
683
|
+
await this._emitHook("after:getById", {
|
|
684
|
+
context,
|
|
685
|
+
result
|
|
686
|
+
});
|
|
687
|
+
return result;
|
|
688
|
+
} catch (error) {
|
|
689
|
+
await this._emitErrorHook("error:getById", {
|
|
690
|
+
context,
|
|
691
|
+
error
|
|
692
|
+
});
|
|
693
|
+
throw this._handleError(error);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Get single document by query
|
|
698
|
+
*/
|
|
699
|
+
async getByQuery(query, options = {}) {
|
|
700
|
+
const populateSpec = options.populateOptions || options.populate;
|
|
701
|
+
const context = await this._buildContext("getByQuery", {
|
|
702
|
+
query,
|
|
703
|
+
...options,
|
|
704
|
+
populate: populateSpec
|
|
705
|
+
});
|
|
706
|
+
if (context._cacheHit) return context._cachedResult;
|
|
707
|
+
const finalQuery = context.query || query;
|
|
708
|
+
try {
|
|
709
|
+
const result = await getByQuery(this.Model, finalQuery, context);
|
|
710
|
+
await this._emitHook("after:getByQuery", {
|
|
711
|
+
context,
|
|
712
|
+
result
|
|
713
|
+
});
|
|
714
|
+
return result;
|
|
715
|
+
} catch (error) {
|
|
716
|
+
await this._emitErrorHook("error:getByQuery", {
|
|
717
|
+
context,
|
|
718
|
+
error
|
|
719
|
+
});
|
|
720
|
+
throw this._handleError(error);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Unified pagination - auto-detects offset vs keyset based on params
|
|
725
|
+
*
|
|
726
|
+
* Auto-detection logic:
|
|
727
|
+
* - If params has 'cursor' or 'after' → uses keyset pagination (stream)
|
|
728
|
+
* - If params has 'pagination' or 'page' → uses offset pagination (paginate)
|
|
729
|
+
* - Else → defaults to offset pagination with page=1
|
|
730
|
+
*
|
|
731
|
+
* @example
|
|
732
|
+
* // Offset pagination (page-based)
|
|
733
|
+
* await repo.getAll({ page: 1, limit: 50, filters: { status: 'active' } });
|
|
734
|
+
* await repo.getAll({ pagination: { page: 2, limit: 20 } });
|
|
735
|
+
*
|
|
736
|
+
* // Keyset pagination (cursor-based)
|
|
737
|
+
* await repo.getAll({ cursor: 'eyJ2Ij...', limit: 50 });
|
|
738
|
+
* await repo.getAll({ after: 'eyJ2Ij...', sort: { createdAt: -1 } });
|
|
739
|
+
*
|
|
740
|
+
* // Simple query (defaults to page 1)
|
|
741
|
+
* await repo.getAll({ filters: { status: 'active' } });
|
|
742
|
+
*
|
|
743
|
+
* // Skip cache for fresh data
|
|
744
|
+
* await repo.getAll({ filters: { status: 'active' } }, { skipCache: true });
|
|
745
|
+
*/
|
|
746
|
+
async getAll(params = {}, options = {}) {
|
|
747
|
+
const context = await this._buildContext("getAll", {
|
|
748
|
+
...params,
|
|
749
|
+
...options
|
|
750
|
+
});
|
|
751
|
+
if (context._cacheHit) return context._cachedResult;
|
|
752
|
+
const filters = context.filters ?? params.filters ?? {};
|
|
753
|
+
const search = context.search ?? params.search;
|
|
754
|
+
const sort = context.sort ?? params.sort ?? "-createdAt";
|
|
755
|
+
const limit = context.limit ?? params.limit ?? params.pagination?.limit ?? this._pagination.config.defaultLimit;
|
|
756
|
+
const page = context.page ?? params.pagination?.page ?? params.page;
|
|
757
|
+
const after = context.after ?? params.cursor ?? params.after;
|
|
758
|
+
const mode = context.mode ?? params.mode;
|
|
759
|
+
let useKeyset = false;
|
|
760
|
+
if (mode) useKeyset = mode === "keyset";
|
|
761
|
+
else useKeyset = !page && !!(after || sort !== "-createdAt" && (context.sort ?? params.sort));
|
|
762
|
+
let query = { ...filters };
|
|
763
|
+
if (search) {
|
|
764
|
+
if (this._hasTextIndex === null) this._hasTextIndex = this.Model.schema.indexes().some((idx) => idx[0] && Object.values(idx[0]).includes("text"));
|
|
765
|
+
if (this._hasTextIndex) query.$text = { $search: search };
|
|
766
|
+
else throw createError(400, `No text index found for ${this.model}. Cannot perform text search.`);
|
|
767
|
+
}
|
|
768
|
+
const populateSpec = options.populateOptions || params.populateOptions || context.populate || options.populate;
|
|
769
|
+
const paginationOptions = {
|
|
770
|
+
filters: query,
|
|
771
|
+
sort: this._parseSort(sort),
|
|
772
|
+
limit,
|
|
773
|
+
populate: this._parsePopulate(populateSpec),
|
|
774
|
+
select: context.select || options.select,
|
|
775
|
+
lean: context.lean ?? options.lean ?? true,
|
|
776
|
+
session: options.session,
|
|
777
|
+
hint: context.hint ?? params.hint,
|
|
778
|
+
maxTimeMS: context.maxTimeMS ?? params.maxTimeMS,
|
|
779
|
+
readPreference: context.readPreference ?? options.readPreference ?? params.readPreference
|
|
780
|
+
};
|
|
781
|
+
try {
|
|
782
|
+
let result;
|
|
783
|
+
if (useKeyset) result = await this._pagination.stream({
|
|
784
|
+
...paginationOptions,
|
|
785
|
+
sort: paginationOptions.sort,
|
|
786
|
+
after
|
|
787
|
+
});
|
|
788
|
+
else result = await this._pagination.paginate({
|
|
789
|
+
...paginationOptions,
|
|
790
|
+
page: page || 1,
|
|
791
|
+
countStrategy: context.countStrategy ?? params.countStrategy
|
|
792
|
+
});
|
|
793
|
+
await this._emitHook("after:getAll", {
|
|
794
|
+
context,
|
|
795
|
+
result
|
|
796
|
+
});
|
|
797
|
+
return result;
|
|
798
|
+
} catch (error) {
|
|
799
|
+
await this._emitErrorHook("error:getAll", {
|
|
800
|
+
context,
|
|
801
|
+
error
|
|
802
|
+
});
|
|
803
|
+
throw this._handleError(error);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Get or create document
|
|
808
|
+
*/
|
|
809
|
+
async getOrCreate(query, createData, options = {}) {
|
|
810
|
+
return getOrCreate(this.Model, query, createData, options);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Count documents
|
|
814
|
+
*/
|
|
815
|
+
async count(query = {}, options = {}) {
|
|
816
|
+
return count(this.Model, query, options);
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Check if document exists
|
|
820
|
+
*/
|
|
821
|
+
async exists(query, options = {}) {
|
|
822
|
+
return exists(this.Model, query, options);
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Update document by ID
|
|
826
|
+
*/
|
|
827
|
+
async update(id, data, options = {}) {
|
|
828
|
+
const context = await this._buildContext("update", {
|
|
829
|
+
id,
|
|
830
|
+
data,
|
|
831
|
+
...options
|
|
832
|
+
});
|
|
833
|
+
try {
|
|
834
|
+
const result = await update(this.Model, id, context.data || data, context);
|
|
835
|
+
await this._emitHook("after:update", {
|
|
836
|
+
context,
|
|
837
|
+
result
|
|
838
|
+
});
|
|
839
|
+
return result;
|
|
840
|
+
} catch (error) {
|
|
841
|
+
await this._emitErrorHook("error:update", {
|
|
842
|
+
context,
|
|
843
|
+
error
|
|
844
|
+
});
|
|
845
|
+
throw this._handleError(error);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Delete document by ID
|
|
850
|
+
*/
|
|
851
|
+
async delete(id, options = {}) {
|
|
852
|
+
const context = await this._buildContext("delete", {
|
|
853
|
+
id,
|
|
854
|
+
...options
|
|
855
|
+
});
|
|
856
|
+
try {
|
|
857
|
+
if (context.softDeleted) {
|
|
858
|
+
const result = {
|
|
859
|
+
success: true,
|
|
860
|
+
message: "Soft deleted successfully"
|
|
861
|
+
};
|
|
862
|
+
await this._emitHook("after:delete", {
|
|
863
|
+
context,
|
|
864
|
+
result
|
|
865
|
+
});
|
|
866
|
+
return result;
|
|
867
|
+
}
|
|
868
|
+
const result = await deleteById(this.Model, id, {
|
|
869
|
+
session: options.session,
|
|
870
|
+
query: context.query
|
|
871
|
+
});
|
|
872
|
+
await this._emitHook("after:delete", {
|
|
873
|
+
context,
|
|
874
|
+
result
|
|
875
|
+
});
|
|
876
|
+
return result;
|
|
877
|
+
} catch (error) {
|
|
878
|
+
await this._emitErrorHook("error:delete", {
|
|
879
|
+
context,
|
|
880
|
+
error
|
|
881
|
+
});
|
|
882
|
+
throw this._handleError(error);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Execute aggregation pipeline
|
|
887
|
+
*/
|
|
888
|
+
async aggregate(pipeline, options = {}) {
|
|
889
|
+
return aggregate(this.Model, pipeline, options);
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Aggregate pipeline with pagination
|
|
893
|
+
* Best for: Complex queries, grouping, joins
|
|
894
|
+
*/
|
|
895
|
+
async aggregatePaginate(options = {}) {
|
|
896
|
+
const context = await this._buildContext("aggregatePaginate", options);
|
|
897
|
+
return this._pagination.aggregatePaginate(context);
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Get distinct values
|
|
901
|
+
*/
|
|
902
|
+
async distinct(field, query = {}, options = {}) {
|
|
903
|
+
return distinct(this.Model, field, query, options);
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Query with custom field lookups ($lookup)
|
|
907
|
+
* Best for: Joins on slugs, SKUs, codes, or other indexed custom fields
|
|
908
|
+
*
|
|
909
|
+
* @example
|
|
910
|
+
* ```typescript
|
|
911
|
+
* // Join employees with departments using slug instead of ObjectId
|
|
912
|
+
* const employees = await employeeRepo.lookupPopulate({
|
|
913
|
+
* filters: { status: 'active' },
|
|
914
|
+
* lookups: [
|
|
915
|
+
* {
|
|
916
|
+
* from: 'departments',
|
|
917
|
+
* localField: 'departmentSlug',
|
|
918
|
+
* foreignField: 'slug',
|
|
919
|
+
* as: 'department',
|
|
920
|
+
* single: true
|
|
921
|
+
* }
|
|
922
|
+
* ],
|
|
923
|
+
* sort: '-createdAt',
|
|
924
|
+
* page: 1,
|
|
925
|
+
* limit: 50
|
|
926
|
+
* });
|
|
927
|
+
* ```
|
|
928
|
+
*/
|
|
929
|
+
async lookupPopulate(options) {
|
|
930
|
+
const context = await this._buildContext("lookupPopulate", options);
|
|
931
|
+
try {
|
|
932
|
+
const builder = new AggregationBuilder();
|
|
933
|
+
const filters = context.filters ?? options.filters;
|
|
934
|
+
if (filters && Object.keys(filters).length > 0) builder.match(filters);
|
|
935
|
+
builder.multiLookup(options.lookups);
|
|
936
|
+
if (options.sort) builder.sort(this._parseSort(options.sort));
|
|
937
|
+
const page = options.page || 1;
|
|
938
|
+
const limit = options.limit || this._pagination.config.defaultLimit || 20;
|
|
939
|
+
const skip = (page - 1) * limit;
|
|
940
|
+
const SAFE_LIMIT = 1e3;
|
|
941
|
+
const SAFE_MAX_OFFSET = 1e4;
|
|
942
|
+
if (limit > SAFE_LIMIT) warn(`[mongokit] Large limit (${limit}) in lookupPopulate. $facet results must be <16MB. Consider using smaller limits or stream-based pagination for large datasets.`);
|
|
943
|
+
if (skip > SAFE_MAX_OFFSET) warn(`[mongokit] Large offset (${skip}) in lookupPopulate. $facet with high offsets can exceed 16MB. For deep pagination, consider using keyset/cursor-based pagination instead.`);
|
|
944
|
+
const dataStages = [{ $skip: skip }, { $limit: limit }];
|
|
945
|
+
if (options.select) {
|
|
946
|
+
let projection;
|
|
947
|
+
if (typeof options.select === "string") {
|
|
948
|
+
projection = {};
|
|
949
|
+
const fields = options.select.split(",").map((f) => f.trim());
|
|
950
|
+
for (const field of fields) if (field.startsWith("-")) projection[field.substring(1)] = 0;
|
|
951
|
+
else projection[field] = 1;
|
|
952
|
+
} else if (Array.isArray(options.select)) {
|
|
953
|
+
projection = {};
|
|
954
|
+
for (const field of options.select) if (field.startsWith("-")) projection[field.substring(1)] = 0;
|
|
955
|
+
else projection[field] = 1;
|
|
956
|
+
} else projection = options.select;
|
|
957
|
+
dataStages.push({ $project: projection });
|
|
958
|
+
}
|
|
959
|
+
builder.facet({
|
|
960
|
+
metadata: [{ $count: "total" }],
|
|
961
|
+
data: dataStages
|
|
962
|
+
});
|
|
963
|
+
const pipeline = builder.build();
|
|
964
|
+
const result = (await this.Model.aggregate(pipeline).session(options.session || null))[0] || {
|
|
965
|
+
metadata: [],
|
|
966
|
+
data: []
|
|
967
|
+
};
|
|
968
|
+
const total = result.metadata[0]?.total || 0;
|
|
969
|
+
const data = result.data || [];
|
|
970
|
+
await this._emitHook("after:lookupPopulate", {
|
|
971
|
+
context,
|
|
972
|
+
result: data
|
|
973
|
+
});
|
|
974
|
+
return {
|
|
975
|
+
data,
|
|
976
|
+
total,
|
|
977
|
+
page,
|
|
978
|
+
limit
|
|
979
|
+
};
|
|
980
|
+
} catch (error) {
|
|
981
|
+
await this._emitErrorHook("error:lookupPopulate", {
|
|
982
|
+
context,
|
|
983
|
+
error
|
|
984
|
+
});
|
|
985
|
+
throw this._handleError(error);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Create an aggregation builder for this model
|
|
990
|
+
* Useful for building complex custom aggregations
|
|
991
|
+
*
|
|
992
|
+
* @example
|
|
993
|
+
* ```typescript
|
|
994
|
+
* const pipeline = repo.buildAggregation()
|
|
995
|
+
* .match({ status: 'active' })
|
|
996
|
+
* .lookup('departments', 'deptSlug', 'slug', 'department', true)
|
|
997
|
+
* .group({ _id: '$department', count: { $sum: 1 } })
|
|
998
|
+
* .sort({ count: -1 })
|
|
999
|
+
* .build();
|
|
1000
|
+
*
|
|
1001
|
+
* const results = await repo.Model.aggregate(pipeline);
|
|
1002
|
+
* ```
|
|
1003
|
+
*/
|
|
1004
|
+
buildAggregation() {
|
|
1005
|
+
return new AggregationBuilder();
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Create a lookup builder
|
|
1009
|
+
* Useful for building $lookup stages independently
|
|
1010
|
+
*
|
|
1011
|
+
* @example
|
|
1012
|
+
* ```typescript
|
|
1013
|
+
* const lookupStages = repo.buildLookup('departments')
|
|
1014
|
+
* .localField('deptSlug')
|
|
1015
|
+
* .foreignField('slug')
|
|
1016
|
+
* .as('department')
|
|
1017
|
+
* .single()
|
|
1018
|
+
* .build();
|
|
1019
|
+
*
|
|
1020
|
+
* const pipeline = [
|
|
1021
|
+
* { $match: { status: 'active' } },
|
|
1022
|
+
* ...lookupStages
|
|
1023
|
+
* ];
|
|
1024
|
+
* ```
|
|
1025
|
+
*/
|
|
1026
|
+
buildLookup(from) {
|
|
1027
|
+
return new LookupBuilder(from);
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Execute callback within a transaction with automatic retry on transient failures.
|
|
1031
|
+
*
|
|
1032
|
+
* Uses the MongoDB driver's `session.withTransaction()` which automatically retries
|
|
1033
|
+
* on `TransientTransactionError` and `UnknownTransactionCommitResult`.
|
|
1034
|
+
*
|
|
1035
|
+
* The callback always receives a `ClientSession`. When `allowFallback` is true
|
|
1036
|
+
* and the MongoDB deployment doesn't support transactions (e.g., standalone),
|
|
1037
|
+
* the callback runs without a transaction on the same session.
|
|
1038
|
+
*
|
|
1039
|
+
* @param callback - Receives a `ClientSession` to pass to repository operations
|
|
1040
|
+
* @param options.allowFallback - Run without transaction on standalone MongoDB (default: false)
|
|
1041
|
+
* @param options.onFallback - Called when falling back to non-transactional execution
|
|
1042
|
+
* @param options.transactionOptions - MongoDB driver transaction options (readConcern, writeConcern, etc.)
|
|
1043
|
+
*
|
|
1044
|
+
* @example
|
|
1045
|
+
* ```typescript
|
|
1046
|
+
* const result = await repo.withTransaction(async (session) => {
|
|
1047
|
+
* const order = await repo.create({ total: 100 }, { session });
|
|
1048
|
+
* await paymentRepo.create({ orderId: order._id }, { session });
|
|
1049
|
+
* return order;
|
|
1050
|
+
* });
|
|
1051
|
+
*
|
|
1052
|
+
* // With fallback for standalone/dev environments
|
|
1053
|
+
* await repo.withTransaction(callback, {
|
|
1054
|
+
* allowFallback: true,
|
|
1055
|
+
* onFallback: (err) => logger.warn('Running without transaction', err),
|
|
1056
|
+
* });
|
|
1057
|
+
* ```
|
|
1058
|
+
*/
|
|
1059
|
+
async withTransaction(callback, options = {}) {
|
|
1060
|
+
const session = await mongoose.startSession();
|
|
1061
|
+
try {
|
|
1062
|
+
return await session.withTransaction(() => callback(session), options.transactionOptions);
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
const err = error;
|
|
1065
|
+
if (options.allowFallback && this._isTransactionUnsupported(err)) {
|
|
1066
|
+
options.onFallback?.(err);
|
|
1067
|
+
return await callback(session);
|
|
1068
|
+
}
|
|
1069
|
+
throw err;
|
|
1070
|
+
} finally {
|
|
1071
|
+
await session.endSession();
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
_isTransactionUnsupported(error) {
|
|
1075
|
+
const message = (error.message || "").toLowerCase();
|
|
1076
|
+
return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("replica set") || message.includes("mongos");
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Execute custom query with event emission
|
|
1080
|
+
*/
|
|
1081
|
+
async _executeQuery(buildQuery) {
|
|
1082
|
+
const operation = buildQuery.name || "custom";
|
|
1083
|
+
const context = await this._buildContext(operation, {});
|
|
1084
|
+
try {
|
|
1085
|
+
const result = await buildQuery(this.Model);
|
|
1086
|
+
await this._emitHook(`after:${operation}`, {
|
|
1087
|
+
context,
|
|
1088
|
+
result
|
|
1089
|
+
});
|
|
1090
|
+
return result;
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
await this._emitErrorHook(`error:${operation}`, {
|
|
1093
|
+
context,
|
|
1094
|
+
error
|
|
1095
|
+
});
|
|
1096
|
+
throw this._handleError(error);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Build operation context and run before hooks
|
|
1101
|
+
*/
|
|
1102
|
+
async _buildContext(operation, options) {
|
|
1103
|
+
const context = {
|
|
1104
|
+
operation,
|
|
1105
|
+
model: this.model,
|
|
1106
|
+
...options
|
|
1107
|
+
};
|
|
1108
|
+
const event = `before:${operation}`;
|
|
1109
|
+
const hooks = this._hooks.get(event) || [];
|
|
1110
|
+
for (const hook of hooks) await hook(context);
|
|
1111
|
+
return context;
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Parse sort string or object
|
|
1115
|
+
*/
|
|
1116
|
+
_parseSort(sort) {
|
|
1117
|
+
if (!sort) return { createdAt: -1 };
|
|
1118
|
+
if (typeof sort === "object") {
|
|
1119
|
+
if (Object.keys(sort).length === 0) return { createdAt: -1 };
|
|
1120
|
+
return sort;
|
|
1121
|
+
}
|
|
1122
|
+
const sortObj = {};
|
|
1123
|
+
const fields = sort.split(",").map((s) => s.trim());
|
|
1124
|
+
for (const field of fields) if (field.startsWith("-")) sortObj[field.substring(1)] = -1;
|
|
1125
|
+
else sortObj[field] = 1;
|
|
1126
|
+
return sortObj;
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Parse populate specification
|
|
1130
|
+
*/
|
|
1131
|
+
_parsePopulate(populate) {
|
|
1132
|
+
if (!populate) return [];
|
|
1133
|
+
if (typeof populate === "string") return populate.split(",").map((p) => p.trim());
|
|
1134
|
+
if (Array.isArray(populate)) return populate.map((p) => typeof p === "string" ? p.trim() : p);
|
|
1135
|
+
return [populate];
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Handle errors with proper HTTP status codes
|
|
1139
|
+
*/
|
|
1140
|
+
_handleError(error) {
|
|
1141
|
+
if (error instanceof mongoose.Error.ValidationError) return createError(400, `Validation Error: ${Object.values(error.errors).map((err) => err.message).join(", ")}`);
|
|
1142
|
+
if (error instanceof mongoose.Error.CastError) return createError(400, `Invalid ${error.path}: ${error.value}`);
|
|
1143
|
+
if (error.status && error.message) return error;
|
|
1144
|
+
return createError(500, error.message || "Internal Server Error");
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
//#endregion
|
|
1149
|
+
//#region src/query/QueryParser.ts
|
|
1150
|
+
/**
|
|
1151
|
+
* Modern Query Parser - URL to MongoDB Query Transpiler
|
|
1152
|
+
*
|
|
1153
|
+
* Next-generation query parser that converts URL parameters to MongoDB aggregation pipelines.
|
|
1154
|
+
* Smarter than Prisma/tRPC for MongoDB with support for:
|
|
1155
|
+
* - Custom field lookups ($lookup)
|
|
1156
|
+
* - Complex filtering with operators
|
|
1157
|
+
* - Full-text search
|
|
1158
|
+
* - Aggregations via URL
|
|
1159
|
+
* - Security hardening
|
|
1160
|
+
*
|
|
1161
|
+
* @example
|
|
1162
|
+
* ```typescript
|
|
1163
|
+
* // Simple usage
|
|
1164
|
+
* const parser = new QueryParser();
|
|
1165
|
+
* const query = parser.parse(req.query);
|
|
1166
|
+
*
|
|
1167
|
+
* // URL: ?status=active&lookup[department]=slug&sort=-createdAt&page=1&limit=20
|
|
1168
|
+
* // Result: Complete MongoDB query with $lookup, filters, sort, pagination
|
|
1169
|
+
* ```
|
|
1170
|
+
*
|
|
1171
|
+
* ## SECURITY CONSIDERATIONS FOR PRODUCTION
|
|
1172
|
+
*
|
|
1173
|
+
* ### Aggregation Security (enableAggregations option)
|
|
1174
|
+
*
|
|
1175
|
+
* **IMPORTANT:** The `enableAggregations` option exposes powerful MongoDB aggregation
|
|
1176
|
+
* pipeline capabilities via URL parameters. While this feature includes sanitization
|
|
1177
|
+
* (blocks $where, $function, $accumulator), it should be used with caution:
|
|
1178
|
+
*
|
|
1179
|
+
* **Recommended security practices:**
|
|
1180
|
+
* 1. **Disable by default for public endpoints:**
|
|
1181
|
+
* ```typescript
|
|
1182
|
+
* const parser = new QueryParser({
|
|
1183
|
+
* enableAggregations: false // Default: disabled
|
|
1184
|
+
* });
|
|
1185
|
+
* ```
|
|
1186
|
+
*
|
|
1187
|
+
* 2. **Use per-route allowlists for trusted clients:**
|
|
1188
|
+
* ```typescript
|
|
1189
|
+
* // Admin/internal routes only
|
|
1190
|
+
* if (req.user?.role === 'admin') {
|
|
1191
|
+
* const allowedStages = ['$match', '$project', '$sort', '$limit'];
|
|
1192
|
+
* // Validate aggregate parameter against allowlist
|
|
1193
|
+
* }
|
|
1194
|
+
* ```
|
|
1195
|
+
*
|
|
1196
|
+
* 3. **Validate stage structure:** Even with sanitization, complex pipelines can
|
|
1197
|
+
* cause performance issues. Consider limiting:
|
|
1198
|
+
* - Number of pipeline stages (e.g., max 5)
|
|
1199
|
+
* - Specific allowed operators per stage
|
|
1200
|
+
* - Allowed fields in $project/$match
|
|
1201
|
+
*
|
|
1202
|
+
* 4. **Monitor resource usage:** Aggregation pipelines can be expensive.
|
|
1203
|
+
* Use MongoDB profiling to track slow operations.
|
|
1204
|
+
*
|
|
1205
|
+
* ### Lookup Security
|
|
1206
|
+
*
|
|
1207
|
+
* Lookup pipelines are sanitized by default:
|
|
1208
|
+
* - Dangerous stages blocked ($out, $merge, $unionWith, $collStats, $currentOp, $listSessions)
|
|
1209
|
+
* - Dangerous operators blocked inside $match/$addFields/$set ($where, $function, $accumulator, $expr)
|
|
1210
|
+
* - Optional collection whitelist via `allowedLookupCollections`
|
|
1211
|
+
* For maximum security, use per-collection field allowlists in your controller layer.
|
|
1212
|
+
*
|
|
1213
|
+
* ### Filter Security
|
|
1214
|
+
*
|
|
1215
|
+
* All filters are sanitized:
|
|
1216
|
+
* - Dangerous operators blocked ($where, $function, $accumulator, $expr)
|
|
1217
|
+
* - Regex patterns validated (ReDoS protection)
|
|
1218
|
+
* - Max filter depth enforced (prevents filter bombs)
|
|
1219
|
+
* - Max limit enforced (prevents resource exhaustion)
|
|
1220
|
+
*
|
|
1221
|
+
* @see {@link https://github.com/classytic/mongokit/blob/main/docs/SECURITY.md}
|
|
1222
|
+
*/
|
|
1223
|
+
/**
|
|
1224
|
+
* Modern Query Parser
|
|
1225
|
+
* Converts URL parameters to MongoDB queries with $lookup support
|
|
1226
|
+
*/
|
|
1227
|
+
var QueryParser = class {
|
|
1228
|
+
options;
|
|
1229
|
+
operators = {
|
|
1230
|
+
eq: "$eq",
|
|
1231
|
+
ne: "$ne",
|
|
1232
|
+
gt: "$gt",
|
|
1233
|
+
gte: "$gte",
|
|
1234
|
+
lt: "$lt",
|
|
1235
|
+
lte: "$lte",
|
|
1236
|
+
in: "$in",
|
|
1237
|
+
nin: "$nin",
|
|
1238
|
+
like: "$regex",
|
|
1239
|
+
contains: "$regex",
|
|
1240
|
+
regex: "$regex",
|
|
1241
|
+
exists: "$exists",
|
|
1242
|
+
size: "$size",
|
|
1243
|
+
type: "$type"
|
|
1244
|
+
};
|
|
1245
|
+
dangerousOperators;
|
|
1246
|
+
/**
|
|
1247
|
+
* Regex patterns that can cause catastrophic backtracking (ReDoS attacks)
|
|
1248
|
+
* Detects:
|
|
1249
|
+
* - Quantifiers: {n,m}
|
|
1250
|
+
* - Possessive quantifiers: *+, ++, ?+
|
|
1251
|
+
* - Nested quantifiers: (a+)+, (a*)*
|
|
1252
|
+
* - Backreferences: \1, \2, etc.
|
|
1253
|
+
* - Complex character classes: [...]...[...]
|
|
1254
|
+
*/
|
|
1255
|
+
dangerousRegexPatterns = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?\:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
|
|
1256
|
+
constructor(options = {}) {
|
|
1257
|
+
this.options = {
|
|
1258
|
+
maxRegexLength: options.maxRegexLength ?? 500,
|
|
1259
|
+
maxSearchLength: options.maxSearchLength ?? 200,
|
|
1260
|
+
maxFilterDepth: options.maxFilterDepth ?? 10,
|
|
1261
|
+
maxLimit: options.maxLimit ?? 1e3,
|
|
1262
|
+
additionalDangerousOperators: options.additionalDangerousOperators ?? [],
|
|
1263
|
+
enableLookups: options.enableLookups ?? true,
|
|
1264
|
+
enableAggregations: options.enableAggregations ?? false,
|
|
1265
|
+
searchMode: options.searchMode ?? "text",
|
|
1266
|
+
searchFields: options.searchFields,
|
|
1267
|
+
allowedLookupCollections: options.allowedLookupCollections,
|
|
1268
|
+
allowedFilterFields: options.allowedFilterFields,
|
|
1269
|
+
allowedSortFields: options.allowedSortFields
|
|
1270
|
+
};
|
|
1271
|
+
if (this.options.searchMode === "regex" && (!this.options.searchFields || this.options.searchFields.length === 0)) {
|
|
1272
|
+
warn("[mongokit] searchMode \"regex\" requires searchFields to be specified. Falling back to \"text\" mode.");
|
|
1273
|
+
this.options.searchMode = "text";
|
|
1274
|
+
}
|
|
1275
|
+
this.dangerousOperators = [
|
|
1276
|
+
"$where",
|
|
1277
|
+
"$function",
|
|
1278
|
+
"$accumulator",
|
|
1279
|
+
"$expr",
|
|
1280
|
+
...this.options.additionalDangerousOperators
|
|
1281
|
+
];
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Parse URL query parameters into MongoDB query format
|
|
1285
|
+
*
|
|
1286
|
+
* @example
|
|
1287
|
+
* ```typescript
|
|
1288
|
+
* // URL: ?status=active&lookup[department][foreignField]=slug&sort=-createdAt&page=1
|
|
1289
|
+
* const query = parser.parse(req.query);
|
|
1290
|
+
* // Returns: { filters: {...}, lookups: [...], sort: {...}, page: 1 }
|
|
1291
|
+
* ```
|
|
1292
|
+
*/
|
|
1293
|
+
parse(query) {
|
|
1294
|
+
const { page, limit = 20, sort = "-createdAt", populate, search, after, cursor, select, lookup, aggregate, ...filters } = query || {};
|
|
1295
|
+
let parsedLimit = parseInt(String(limit), 10);
|
|
1296
|
+
if (isNaN(parsedLimit) || parsedLimit < 1) parsedLimit = 20;
|
|
1297
|
+
if (parsedLimit > this.options.maxLimit) {
|
|
1298
|
+
warn(`[mongokit] Limit ${parsedLimit} exceeds maximum ${this.options.maxLimit}, capping to max`);
|
|
1299
|
+
parsedLimit = this.options.maxLimit;
|
|
1300
|
+
}
|
|
1301
|
+
const sanitizedSearch = this._sanitizeSearch(search);
|
|
1302
|
+
const { simplePopulate, populateOptions } = this._parsePopulate(populate);
|
|
1303
|
+
const parsed = {
|
|
1304
|
+
filters: this._parseFilters(filters),
|
|
1305
|
+
limit: parsedLimit,
|
|
1306
|
+
sort: this._parseSort(sort),
|
|
1307
|
+
populate: simplePopulate,
|
|
1308
|
+
populateOptions,
|
|
1309
|
+
search: sanitizedSearch
|
|
1310
|
+
};
|
|
1311
|
+
if (sanitizedSearch && this.options.searchMode === "regex" && this.options.searchFields) {
|
|
1312
|
+
const regexSearchFilters = this._buildRegexSearch(sanitizedSearch);
|
|
1313
|
+
if (regexSearchFilters) {
|
|
1314
|
+
if (parsed.filters.$or) {
|
|
1315
|
+
parsed.filters = {
|
|
1316
|
+
...parsed.filters,
|
|
1317
|
+
$and: [{ $or: parsed.filters.$or }, { $or: regexSearchFilters }]
|
|
1318
|
+
};
|
|
1319
|
+
delete parsed.filters.$or;
|
|
1320
|
+
} else parsed.filters.$or = regexSearchFilters;
|
|
1321
|
+
parsed.search = void 0;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
if (select) parsed.select = this._parseSelect(select);
|
|
1325
|
+
if (this.options.enableLookups && lookup) parsed.lookups = this._parseLookups(lookup);
|
|
1326
|
+
if (this.options.enableAggregations && aggregate) parsed.aggregation = this._parseAggregation(aggregate);
|
|
1327
|
+
if (after || cursor) parsed.after = String(after || cursor);
|
|
1328
|
+
if (page !== void 0) parsed.page = parseInt(String(page), 10);
|
|
1329
|
+
const orGroup = this._parseOr(query);
|
|
1330
|
+
if (orGroup) if (parsed.filters.$or) {
|
|
1331
|
+
const existingOr = parsed.filters.$or;
|
|
1332
|
+
delete parsed.filters.$or;
|
|
1333
|
+
parsed.filters.$and = [{ $or: existingOr }, { $or: orGroup }];
|
|
1334
|
+
} else parsed.filters.$or = orGroup;
|
|
1335
|
+
parsed.filters = this._enhanceWithBetween(parsed.filters);
|
|
1336
|
+
return parsed;
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Parse lookup configurations from URL parameters
|
|
1340
|
+
*
|
|
1341
|
+
* Supported formats:
|
|
1342
|
+
* 1. Simple: ?lookup[department]=slug
|
|
1343
|
+
* → Join with 'departments' collection on slug field
|
|
1344
|
+
*
|
|
1345
|
+
* 2. Detailed: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug
|
|
1346
|
+
* → Full control over join configuration
|
|
1347
|
+
*
|
|
1348
|
+
* 3. Multiple: ?lookup[department]=slug&lookup[category]=categorySlug
|
|
1349
|
+
* → Multiple lookups
|
|
1350
|
+
*
|
|
1351
|
+
* @example
|
|
1352
|
+
* ```typescript
|
|
1353
|
+
* // URL: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug&lookup[department][single]=true
|
|
1354
|
+
* const lookups = parser._parseLookups({
|
|
1355
|
+
* department: { localField: 'deptSlug', foreignField: 'slug', single: 'true' }
|
|
1356
|
+
* });
|
|
1357
|
+
* // Returns: [{ from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true }]
|
|
1358
|
+
* ```
|
|
1359
|
+
*/
|
|
1360
|
+
_parseLookups(lookup) {
|
|
1361
|
+
if (!lookup || typeof lookup !== "object") return [];
|
|
1362
|
+
const lookups = [];
|
|
1363
|
+
const lookupObj = lookup;
|
|
1364
|
+
for (const [collectionName, config] of Object.entries(lookupObj)) try {
|
|
1365
|
+
const lookupConfig = this._parseSingleLookup(collectionName, config);
|
|
1366
|
+
if (lookupConfig) lookups.push(lookupConfig);
|
|
1367
|
+
} catch (error) {
|
|
1368
|
+
warn(`[mongokit] Invalid lookup config for ${collectionName}:`, error);
|
|
1369
|
+
}
|
|
1370
|
+
return lookups;
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Parse a single lookup configuration
|
|
1374
|
+
*/
|
|
1375
|
+
_parseSingleLookup(collectionName, config) {
|
|
1376
|
+
if (!config) return null;
|
|
1377
|
+
if (typeof config === "string") {
|
|
1378
|
+
const from = this._pluralize(collectionName);
|
|
1379
|
+
if (this.options.allowedLookupCollections && !this.options.allowedLookupCollections.includes(from)) {
|
|
1380
|
+
warn(`[mongokit] Blocked lookup to disallowed collection: ${from}`);
|
|
1381
|
+
return null;
|
|
1382
|
+
}
|
|
1383
|
+
return {
|
|
1384
|
+
from,
|
|
1385
|
+
localField: `${collectionName}${this._capitalize(config)}`,
|
|
1386
|
+
foreignField: config,
|
|
1387
|
+
as: collectionName,
|
|
1388
|
+
single: true
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
if (typeof config === "object" && config !== null) {
|
|
1392
|
+
const opts = config;
|
|
1393
|
+
const from = opts.from || this._pluralize(collectionName);
|
|
1394
|
+
const localField = opts.localField;
|
|
1395
|
+
const foreignField = opts.foreignField;
|
|
1396
|
+
if (this.options.allowedLookupCollections && !this.options.allowedLookupCollections.includes(from)) {
|
|
1397
|
+
warn(`[mongokit] Blocked lookup to disallowed collection: ${from}`);
|
|
1398
|
+
return null;
|
|
1399
|
+
}
|
|
1400
|
+
if (!localField || !foreignField) {
|
|
1401
|
+
warn(`[mongokit] Lookup requires localField and foreignField for ${collectionName}`);
|
|
1402
|
+
return null;
|
|
1403
|
+
}
|
|
1404
|
+
return {
|
|
1405
|
+
from,
|
|
1406
|
+
localField,
|
|
1407
|
+
foreignField,
|
|
1408
|
+
as: opts.as || collectionName,
|
|
1409
|
+
single: opts.single === true || opts.single === "true",
|
|
1410
|
+
...opts.pipeline && Array.isArray(opts.pipeline) ? { pipeline: this._sanitizePipeline(opts.pipeline) } : {}
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* Parse aggregation pipeline from URL (advanced feature)
|
|
1417
|
+
*
|
|
1418
|
+
* @example
|
|
1419
|
+
* ```typescript
|
|
1420
|
+
* // URL: ?aggregate[group][_id]=$status&aggregate[group][count]=$sum:1
|
|
1421
|
+
* const pipeline = parser._parseAggregation({
|
|
1422
|
+
* group: { _id: '$status', count: '$sum:1' }
|
|
1423
|
+
* });
|
|
1424
|
+
* ```
|
|
1425
|
+
*/
|
|
1426
|
+
_parseAggregation(aggregate) {
|
|
1427
|
+
if (!aggregate || typeof aggregate !== "object") return void 0;
|
|
1428
|
+
const pipeline = [];
|
|
1429
|
+
const aggObj = aggregate;
|
|
1430
|
+
for (const [stage, config] of Object.entries(aggObj)) try {
|
|
1431
|
+
if (stage === "group" && typeof config === "object") pipeline.push({ $group: config });
|
|
1432
|
+
else if (stage === "match" && typeof config === "object") {
|
|
1433
|
+
const sanitizedMatch = this._sanitizeMatchConfig(config);
|
|
1434
|
+
if (Object.keys(sanitizedMatch).length > 0) pipeline.push({ $match: sanitizedMatch });
|
|
1435
|
+
} else if (stage === "sort" && typeof config === "object") pipeline.push({ $sort: config });
|
|
1436
|
+
else if (stage === "project" && typeof config === "object") pipeline.push({ $project: config });
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
warn(`[mongokit] Invalid aggregation stage ${stage}:`, error);
|
|
1439
|
+
}
|
|
1440
|
+
return pipeline.length > 0 ? pipeline : void 0;
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Parse select/project fields
|
|
1444
|
+
*
|
|
1445
|
+
* @example
|
|
1446
|
+
* ```typescript
|
|
1447
|
+
* // URL: ?select=name,email,-password
|
|
1448
|
+
* // Returns: { name: 1, email: 1, password: 0 }
|
|
1449
|
+
* ```
|
|
1450
|
+
*/
|
|
1451
|
+
_parseSelect(select) {
|
|
1452
|
+
if (!select) return void 0;
|
|
1453
|
+
if (typeof select === "string") {
|
|
1454
|
+
const projection = {};
|
|
1455
|
+
const fields = select.split(",").map((f) => f.trim());
|
|
1456
|
+
for (const field of fields) if (field.startsWith("-")) projection[field.substring(1)] = 0;
|
|
1457
|
+
else projection[field] = 1;
|
|
1458
|
+
return projection;
|
|
1459
|
+
}
|
|
1460
|
+
if (typeof select === "object" && select !== null) return select;
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Parse populate parameter - handles both simple string and advanced object format
|
|
1464
|
+
*
|
|
1465
|
+
* @example
|
|
1466
|
+
* ```typescript
|
|
1467
|
+
* // Simple: ?populate=author,category
|
|
1468
|
+
* // Returns: { simplePopulate: 'author,category', populateOptions: undefined }
|
|
1469
|
+
*
|
|
1470
|
+
* // Advanced: ?populate[author][select]=name,email
|
|
1471
|
+
* // Returns: { simplePopulate: undefined, populateOptions: [{ path: 'author', select: 'name email' }] }
|
|
1472
|
+
* ```
|
|
1473
|
+
*/
|
|
1474
|
+
_parsePopulate(populate) {
|
|
1475
|
+
if (!populate) return {};
|
|
1476
|
+
if (typeof populate === "string") return { simplePopulate: populate };
|
|
1477
|
+
if (typeof populate === "object" && populate !== null) {
|
|
1478
|
+
const populateObj = populate;
|
|
1479
|
+
if (Object.keys(populateObj).length === 0) return {};
|
|
1480
|
+
const populateOptions = [];
|
|
1481
|
+
for (const [path, config] of Object.entries(populateObj)) {
|
|
1482
|
+
if (path.startsWith("$") || this.dangerousOperators.includes(path)) {
|
|
1483
|
+
warn(`[mongokit] Blocked dangerous populate path: ${path}`);
|
|
1484
|
+
continue;
|
|
1485
|
+
}
|
|
1486
|
+
const option = this._parseSinglePopulate(path, config);
|
|
1487
|
+
if (option) populateOptions.push(option);
|
|
1488
|
+
}
|
|
1489
|
+
return populateOptions.length > 0 ? { populateOptions } : {};
|
|
1490
|
+
}
|
|
1491
|
+
return {};
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Parse a single populate configuration
|
|
1495
|
+
*/
|
|
1496
|
+
_parseSinglePopulate(path, config, depth = 0) {
|
|
1497
|
+
if (depth > 5) {
|
|
1498
|
+
warn(`[mongokit] Populate depth exceeds maximum (5), truncating at path: ${path}`);
|
|
1499
|
+
return { path };
|
|
1500
|
+
}
|
|
1501
|
+
if (typeof config === "string") {
|
|
1502
|
+
if (config === "true" || config === "1") return { path };
|
|
1503
|
+
return {
|
|
1504
|
+
path,
|
|
1505
|
+
select: config.split(",").join(" ")
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
if (typeof config === "object" && config !== null) {
|
|
1509
|
+
const opts = config;
|
|
1510
|
+
const option = { path };
|
|
1511
|
+
if (opts.select && typeof opts.select === "string") option.select = opts.select.split(",").map((s) => s.trim()).join(" ");
|
|
1512
|
+
if (opts.match && typeof opts.match === "object") option.match = this._convertPopulateMatch(opts.match);
|
|
1513
|
+
if (opts.limit !== void 0) {
|
|
1514
|
+
const limit = parseInt(String(opts.limit), 10);
|
|
1515
|
+
if (!isNaN(limit) && limit > 0) {
|
|
1516
|
+
option.options = option.options || {};
|
|
1517
|
+
option.options.limit = limit;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
if (opts.sort && typeof opts.sort === "string") {
|
|
1521
|
+
const sortSpec = this._parseSort(opts.sort);
|
|
1522
|
+
if (sortSpec) {
|
|
1523
|
+
option.options = option.options || {};
|
|
1524
|
+
option.options.sort = sortSpec;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
if (opts.skip !== void 0) {
|
|
1528
|
+
const skip = parseInt(String(opts.skip), 10);
|
|
1529
|
+
if (!isNaN(skip) && skip >= 0) {
|
|
1530
|
+
option.options = option.options || {};
|
|
1531
|
+
option.options.skip = skip;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
if (opts.populate && typeof opts.populate === "object") {
|
|
1535
|
+
const nestedPopulate = opts.populate;
|
|
1536
|
+
const nestedEntries = Object.entries(nestedPopulate);
|
|
1537
|
+
if (nestedEntries.length > 0) {
|
|
1538
|
+
const [nestedPath, nestedConfig] = nestedEntries[0];
|
|
1539
|
+
const nestedOption = this._parseSinglePopulate(nestedPath, nestedConfig, depth + 1);
|
|
1540
|
+
if (nestedOption) option.populate = nestedOption;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
return option;
|
|
1544
|
+
}
|
|
1545
|
+
return null;
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Convert populate match values (handles boolean strings, etc.)
|
|
1549
|
+
*/
|
|
1550
|
+
_convertPopulateMatch(match) {
|
|
1551
|
+
const converted = {};
|
|
1552
|
+
for (const [key, value] of Object.entries(match)) converted[key] = this._convertValue(value);
|
|
1553
|
+
return converted;
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Parse filter parameters
|
|
1557
|
+
*/
|
|
1558
|
+
_parseFilters(filters, depth = 0) {
|
|
1559
|
+
if (depth > this.options.maxFilterDepth) {
|
|
1560
|
+
warn(`[mongokit] Filter depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating`);
|
|
1561
|
+
return {};
|
|
1562
|
+
}
|
|
1563
|
+
const parsedFilters = {};
|
|
1564
|
+
const regexFields = {};
|
|
1565
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
1566
|
+
if (this.dangerousOperators.includes(key) || key.startsWith("$") && !["$or", "$and"].includes(key)) {
|
|
1567
|
+
warn(`[mongokit] Blocked dangerous operator: ${key}`);
|
|
1568
|
+
continue;
|
|
1569
|
+
}
|
|
1570
|
+
if ([
|
|
1571
|
+
"page",
|
|
1572
|
+
"limit",
|
|
1573
|
+
"sort",
|
|
1574
|
+
"populate",
|
|
1575
|
+
"search",
|
|
1576
|
+
"select",
|
|
1577
|
+
"lean",
|
|
1578
|
+
"includeDeleted",
|
|
1579
|
+
"lookup",
|
|
1580
|
+
"aggregate",
|
|
1581
|
+
"or",
|
|
1582
|
+
"OR",
|
|
1583
|
+
"$or"
|
|
1584
|
+
].includes(key)) continue;
|
|
1585
|
+
const operatorMatch = key.match(/^(.+)\[(.+)\]$/);
|
|
1586
|
+
const baseField = operatorMatch ? operatorMatch[1] : key;
|
|
1587
|
+
if (this.options.allowedFilterFields && !this.options.allowedFilterFields.includes(baseField)) {
|
|
1588
|
+
warn(`[mongokit] Blocked filter field not in allowlist: ${baseField}`);
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
if (operatorMatch) {
|
|
1592
|
+
const [, , operator] = operatorMatch;
|
|
1593
|
+
if (this.dangerousOperators.includes("$" + operator)) {
|
|
1594
|
+
warn(`[mongokit] Blocked dangerous operator: ${operator}`);
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
this._handleOperatorSyntax(parsedFilters, regexFields, operatorMatch, value);
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) this._handleBracketSyntax(key, value, parsedFilters, depth + 1);
|
|
1601
|
+
else parsedFilters[key] = this._convertValue(value);
|
|
1602
|
+
}
|
|
1603
|
+
return parsedFilters;
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* Handle operator syntax: field[operator]=value
|
|
1607
|
+
*/
|
|
1608
|
+
_handleOperatorSyntax(filters, regexFields, operatorMatch, value) {
|
|
1609
|
+
const [, field, operator] = operatorMatch;
|
|
1610
|
+
if (value === "" || value === null || value === void 0) return;
|
|
1611
|
+
if (operator.toLowerCase() === "options" && regexFields[field]) {
|
|
1612
|
+
const fieldValue = filters[field];
|
|
1613
|
+
if (typeof fieldValue === "object" && fieldValue !== null && "$regex" in fieldValue) if (typeof value === "string" && /^[imsx]+$/.test(value)) fieldValue.$options = value;
|
|
1614
|
+
else warn(`[mongokit] Blocked invalid regex $options value: ${String(value)}. Allowed flags: i, m, s, x`);
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
if (operator.toLowerCase() === "contains" || operator.toLowerCase() === "like") {
|
|
1618
|
+
const safeRegex = this._createSafeRegex(value);
|
|
1619
|
+
if (safeRegex) {
|
|
1620
|
+
filters[field] = { $regex: safeRegex };
|
|
1621
|
+
regexFields[field] = true;
|
|
1622
|
+
}
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
const mongoOperator = this._toMongoOperator(operator);
|
|
1626
|
+
if (this.dangerousOperators.includes(mongoOperator)) {
|
|
1627
|
+
warn(`[mongokit] Blocked dangerous operator: ${mongoOperator}`);
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if (mongoOperator === "$eq") filters[field] = value;
|
|
1631
|
+
else if (mongoOperator === "$regex") {
|
|
1632
|
+
const safeRegex = this._createSafeRegex(value);
|
|
1633
|
+
if (safeRegex) {
|
|
1634
|
+
filters[field] = { $regex: safeRegex };
|
|
1635
|
+
regexFields[field] = true;
|
|
1636
|
+
}
|
|
1637
|
+
} else {
|
|
1638
|
+
let processedValue;
|
|
1639
|
+
const op = operator.toLowerCase();
|
|
1640
|
+
if ([
|
|
1641
|
+
"gt",
|
|
1642
|
+
"gte",
|
|
1643
|
+
"lt",
|
|
1644
|
+
"lte",
|
|
1645
|
+
"size"
|
|
1646
|
+
].includes(op)) {
|
|
1647
|
+
processedValue = parseFloat(String(value));
|
|
1648
|
+
if (isNaN(processedValue)) return;
|
|
1649
|
+
} else if (op === "in" || op === "nin") processedValue = Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim());
|
|
1650
|
+
else processedValue = this._convertValue(value);
|
|
1651
|
+
if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) filters[field] = {};
|
|
1652
|
+
filters[field][mongoOperator] = processedValue;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Handle bracket syntax with object value
|
|
1657
|
+
*/
|
|
1658
|
+
_handleBracketSyntax(field, operators, parsedFilters, depth = 0) {
|
|
1659
|
+
if (depth > this.options.maxFilterDepth) {
|
|
1660
|
+
warn(`[mongokit] Nested filter depth exceeds maximum, skipping field: ${field}`);
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
if (!parsedFilters[field]) parsedFilters[field] = {};
|
|
1664
|
+
for (const [operator, value] of Object.entries(operators)) {
|
|
1665
|
+
if (value === "" || value === null || value === void 0) continue;
|
|
1666
|
+
if (operator === "between") {
|
|
1667
|
+
parsedFilters[field].between = value;
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
if (this.operators[operator]) {
|
|
1671
|
+
const mongoOperator = this.operators[operator];
|
|
1672
|
+
let processedValue;
|
|
1673
|
+
if ([
|
|
1674
|
+
"gt",
|
|
1675
|
+
"gte",
|
|
1676
|
+
"lt",
|
|
1677
|
+
"lte",
|
|
1678
|
+
"size"
|
|
1679
|
+
].includes(operator)) {
|
|
1680
|
+
processedValue = parseFloat(String(value));
|
|
1681
|
+
if (isNaN(processedValue)) continue;
|
|
1682
|
+
} else if (operator === "in" || operator === "nin") processedValue = Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim());
|
|
1683
|
+
else if (operator === "like" || operator === "contains" || operator === "regex") {
|
|
1684
|
+
const safeRegex = this._createSafeRegex(value);
|
|
1685
|
+
if (!safeRegex) continue;
|
|
1686
|
+
processedValue = safeRegex;
|
|
1687
|
+
} else processedValue = this._convertValue(value);
|
|
1688
|
+
parsedFilters[field][mongoOperator] = processedValue;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
if (typeof parsedFilters[field] === "object" && Object.keys(parsedFilters[field]).length === 0) delete parsedFilters[field];
|
|
1692
|
+
}
|
|
1693
|
+
_parseSort(sort) {
|
|
1694
|
+
if (!sort) return void 0;
|
|
1695
|
+
if (typeof sort === "object") {
|
|
1696
|
+
const sortObj = {};
|
|
1697
|
+
for (const [key, value] of Object.entries(sort)) {
|
|
1698
|
+
if (this.options.allowedSortFields && !this.options.allowedSortFields.includes(key)) {
|
|
1699
|
+
warn(`[mongokit] Blocked sort field not in allowlist: ${key}`);
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
const strVal = String(value).toLowerCase();
|
|
1703
|
+
sortObj[key] = strVal === "desc" || strVal === "-1" || value === -1 ? -1 : 1;
|
|
1704
|
+
}
|
|
1705
|
+
return Object.keys(sortObj).length > 0 ? sortObj : void 0;
|
|
1706
|
+
}
|
|
1707
|
+
const sortObj = {};
|
|
1708
|
+
const fields = sort.split(",").map((s) => s.trim());
|
|
1709
|
+
for (const field of fields) {
|
|
1710
|
+
if (!field) continue;
|
|
1711
|
+
const cleanField = field.startsWith("-") ? field.substring(1) : field;
|
|
1712
|
+
if (this.options.allowedSortFields && !this.options.allowedSortFields.includes(cleanField)) {
|
|
1713
|
+
warn(`[mongokit] Blocked sort field not in allowlist: ${cleanField}`);
|
|
1714
|
+
continue;
|
|
1715
|
+
}
|
|
1716
|
+
if (field.startsWith("-")) sortObj[field.substring(1)] = -1;
|
|
1717
|
+
else sortObj[field] = 1;
|
|
1718
|
+
}
|
|
1719
|
+
return Object.keys(sortObj).length > 0 ? sortObj : void 0;
|
|
1720
|
+
}
|
|
1721
|
+
_toMongoOperator(operator) {
|
|
1722
|
+
const op = operator.toLowerCase();
|
|
1723
|
+
return op.startsWith("$") ? op : "$" + op;
|
|
1724
|
+
}
|
|
1725
|
+
_createSafeRegex(pattern, flags = "i") {
|
|
1726
|
+
if (pattern === null || pattern === void 0) return null;
|
|
1727
|
+
const patternStr = String(pattern);
|
|
1728
|
+
if (patternStr.length > this.options.maxRegexLength) {
|
|
1729
|
+
warn(`[mongokit] Regex pattern too long, truncating`);
|
|
1730
|
+
return new RegExp(this._escapeRegex(patternStr.substring(0, this.options.maxRegexLength)), flags);
|
|
1731
|
+
}
|
|
1732
|
+
if (this.dangerousRegexPatterns.test(patternStr)) {
|
|
1733
|
+
warn("[mongokit] Potentially dangerous regex pattern, escaping");
|
|
1734
|
+
return new RegExp(this._escapeRegex(patternStr), flags);
|
|
1735
|
+
}
|
|
1736
|
+
try {
|
|
1737
|
+
return new RegExp(patternStr, flags);
|
|
1738
|
+
} catch {
|
|
1739
|
+
return new RegExp(this._escapeRegex(patternStr), flags);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
_escapeRegex(str) {
|
|
1743
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Sanitize $match configuration to prevent dangerous operators
|
|
1747
|
+
* Recursively filters out operators like $where, $function, $accumulator
|
|
1748
|
+
*/
|
|
1749
|
+
_sanitizeMatchConfig(config) {
|
|
1750
|
+
const sanitized = {};
|
|
1751
|
+
for (const [key, value] of Object.entries(config)) {
|
|
1752
|
+
if (this.dangerousOperators.includes(key)) {
|
|
1753
|
+
warn(`[mongokit] Blocked dangerous operator in aggregation: ${key}`);
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = this._sanitizeMatchConfig(value);
|
|
1757
|
+
else if (Array.isArray(value)) sanitized[key] = value.map((item) => {
|
|
1758
|
+
if (item && typeof item === "object" && !Array.isArray(item)) return this._sanitizeMatchConfig(item);
|
|
1759
|
+
return item;
|
|
1760
|
+
});
|
|
1761
|
+
else sanitized[key] = value;
|
|
1762
|
+
}
|
|
1763
|
+
return sanitized;
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Sanitize pipeline stages for use in $lookup.
|
|
1767
|
+
* Blocks dangerous stages ($out, $merge, etc.) and recursively sanitizes
|
|
1768
|
+
* operator expressions within $match, $addFields, and $set stages.
|
|
1769
|
+
*/
|
|
1770
|
+
_sanitizePipeline(stages) {
|
|
1771
|
+
const blockedStages = [
|
|
1772
|
+
"$out",
|
|
1773
|
+
"$merge",
|
|
1774
|
+
"$unionWith",
|
|
1775
|
+
"$collStats",
|
|
1776
|
+
"$currentOp",
|
|
1777
|
+
"$listSessions"
|
|
1778
|
+
];
|
|
1779
|
+
const sanitized = [];
|
|
1780
|
+
for (const stage of stages) {
|
|
1781
|
+
if (!stage || typeof stage !== "object") continue;
|
|
1782
|
+
const entries = Object.entries(stage);
|
|
1783
|
+
if (entries.length !== 1) continue;
|
|
1784
|
+
const [op, config] = entries[0];
|
|
1785
|
+
if (blockedStages.includes(op)) {
|
|
1786
|
+
warn(`[mongokit] Blocked dangerous pipeline stage in lookup: ${op}`);
|
|
1787
|
+
continue;
|
|
1788
|
+
}
|
|
1789
|
+
if (op === "$match" && typeof config === "object" && config !== null) sanitized.push({ $match: this._sanitizeMatchConfig(config) });
|
|
1790
|
+
else if ((op === "$addFields" || op === "$set") && typeof config === "object" && config !== null) sanitized.push({ [op]: this._sanitizeExpressions(config) });
|
|
1791
|
+
else sanitized.push(stage);
|
|
1792
|
+
}
|
|
1793
|
+
return sanitized;
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Recursively sanitize expression objects, blocking dangerous operators
|
|
1797
|
+
* like $where, $function, $accumulator inside $addFields/$set stages.
|
|
1798
|
+
*/
|
|
1799
|
+
_sanitizeExpressions(config) {
|
|
1800
|
+
const sanitized = {};
|
|
1801
|
+
for (const [key, value] of Object.entries(config)) {
|
|
1802
|
+
if (this.dangerousOperators.includes(key)) {
|
|
1803
|
+
warn(`[mongokit] Blocked dangerous operator in pipeline expression: ${key}`);
|
|
1804
|
+
continue;
|
|
1805
|
+
}
|
|
1806
|
+
if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = this._sanitizeExpressions(value);
|
|
1807
|
+
else if (Array.isArray(value)) sanitized[key] = value.map((item) => {
|
|
1808
|
+
if (item && typeof item === "object" && !Array.isArray(item)) return this._sanitizeExpressions(item);
|
|
1809
|
+
return item;
|
|
1810
|
+
});
|
|
1811
|
+
else sanitized[key] = value;
|
|
1812
|
+
}
|
|
1813
|
+
return sanitized;
|
|
1814
|
+
}
|
|
1815
|
+
_sanitizeSearch(search) {
|
|
1816
|
+
if (search === null || search === void 0 || search === "") return void 0;
|
|
1817
|
+
let searchStr = String(search).trim();
|
|
1818
|
+
if (!searchStr) return void 0;
|
|
1819
|
+
if (searchStr.length > this.options.maxSearchLength) {
|
|
1820
|
+
warn(`[mongokit] Search query too long, truncating`);
|
|
1821
|
+
searchStr = searchStr.substring(0, this.options.maxSearchLength);
|
|
1822
|
+
}
|
|
1823
|
+
return searchStr;
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* Build regex-based multi-field search filters
|
|
1827
|
+
* Creates an $or query with case-insensitive regex across all searchFields
|
|
1828
|
+
*
|
|
1829
|
+
* @example
|
|
1830
|
+
* // searchFields: ['name', 'description', 'sku']
|
|
1831
|
+
* // search: 'azure'
|
|
1832
|
+
* // Returns: [
|
|
1833
|
+
* // { name: { $regex: /azure/i } },
|
|
1834
|
+
* // { description: { $regex: /azure/i } },
|
|
1835
|
+
* // { sku: { $regex: /azure/i } }
|
|
1836
|
+
* // ]
|
|
1837
|
+
*/
|
|
1838
|
+
_buildRegexSearch(searchTerm) {
|
|
1839
|
+
if (!this.options.searchFields || this.options.searchFields.length === 0) return null;
|
|
1840
|
+
const safeRegex = this._createSafeRegex(searchTerm, "i");
|
|
1841
|
+
if (!safeRegex) return null;
|
|
1842
|
+
const orConditions = [];
|
|
1843
|
+
for (const field of this.options.searchFields) orConditions.push({ [field]: { $regex: safeRegex } });
|
|
1844
|
+
return orConditions.length > 0 ? orConditions : null;
|
|
1845
|
+
}
|
|
1846
|
+
_convertValue(value) {
|
|
1847
|
+
if (value === null || value === void 0) return value;
|
|
1848
|
+
if (Array.isArray(value)) return value.map((v) => this._convertValue(v));
|
|
1849
|
+
if (typeof value === "object") return value;
|
|
1850
|
+
const stringValue = String(value);
|
|
1851
|
+
if (stringValue === "true") return true;
|
|
1852
|
+
if (stringValue === "false") return false;
|
|
1853
|
+
if (mongoose.Types.ObjectId.isValid(stringValue) && stringValue.length === 24) return stringValue;
|
|
1854
|
+
return stringValue;
|
|
1855
|
+
}
|
|
1856
|
+
_parseOr(query) {
|
|
1857
|
+
const orArray = [];
|
|
1858
|
+
const raw = query?.or || query?.OR || query?.$or;
|
|
1859
|
+
if (!raw) return void 0;
|
|
1860
|
+
const items = Array.isArray(raw) ? raw : typeof raw === "object" ? Object.values(raw) : [];
|
|
1861
|
+
for (const item of items) if (typeof item === "object" && item) orArray.push(this._parseFilters(item, 1));
|
|
1862
|
+
return orArray.length ? orArray : void 0;
|
|
1863
|
+
}
|
|
1864
|
+
_enhanceWithBetween(filters) {
|
|
1865
|
+
const output = { ...filters };
|
|
1866
|
+
for (const [key, value] of Object.entries(filters || {})) if (value && typeof value === "object" && "between" in value) {
|
|
1867
|
+
const between = value.between;
|
|
1868
|
+
const [from, to] = String(between).split(",").map((s) => s.trim());
|
|
1869
|
+
const fromDate = from ? new Date(from) : void 0;
|
|
1870
|
+
const toDate = to ? new Date(to) : void 0;
|
|
1871
|
+
const range = {};
|
|
1872
|
+
if (fromDate && !isNaN(fromDate.getTime())) range.$gte = fromDate;
|
|
1873
|
+
if (toDate && !isNaN(toDate.getTime())) range.$lte = toDate;
|
|
1874
|
+
output[key] = range;
|
|
1875
|
+
}
|
|
1876
|
+
return output;
|
|
1877
|
+
}
|
|
1878
|
+
_pluralize(str) {
|
|
1879
|
+
if (str.endsWith("y")) return str.slice(0, -1) + "ies";
|
|
1880
|
+
if (str.endsWith("s")) return str;
|
|
1881
|
+
return str + "s";
|
|
1882
|
+
}
|
|
1883
|
+
_capitalize(str) {
|
|
1884
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
1885
|
+
}
|
|
1886
|
+
};
|
|
1887
|
+
|
|
1888
|
+
//#endregion
|
|
1889
|
+
//#region src/index.ts
|
|
1890
|
+
/**
|
|
1891
|
+
* Factory function to create a repository instance
|
|
1892
|
+
*
|
|
1893
|
+
* @param Model - Mongoose model
|
|
1894
|
+
* @param plugins - Array of plugins to apply
|
|
1895
|
+
* @returns Repository instance
|
|
1896
|
+
*
|
|
1897
|
+
* @example
|
|
1898
|
+
* const userRepo = createRepository(UserModel, [timestampPlugin()]);
|
|
1899
|
+
*/
|
|
1900
|
+
function createRepository(Model, plugins = [], paginationConfig = {}, options = {}) {
|
|
1901
|
+
return new Repository(Model, plugins, paginationConfig, options);
|
|
1902
|
+
}
|
|
1903
|
+
var src_default = Repository;
|
|
1904
|
+
|
|
1905
|
+
//#endregion
|
|
1906
|
+
export { AggregationBuilder, LookupBuilder, PaginationEngine, QueryParser, Repository, actions_exports as actions, aggregateHelpersPlugin, auditLogPlugin, autoInject, batchOperationsPlugin, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, configureLogger, createError, createFieldPreset, createMemoryCache, createRepository, customIdPlugin, dateSequentialId, src_default as default, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getImmutableFields, getMongooseProjection, getNextSequence, getSystemManagedFields, immutableField, isFieldUpdateAllowed, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validateUpdateBody, validationChainPlugin };
|