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