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