@classytic/mongokit 3.0.5 → 3.1.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 +4 -2
- package/dist/actions/index.d.ts +2 -2
- package/dist/actions/index.js +40 -9
- package/dist/{index-BljuCDFC.d.ts → index-3Nkm_Brq.d.ts} +170 -3
- package/dist/index.d.ts +999 -9
- package/dist/index.js +1227 -201
- package/dist/{queryParser-CxzCjzXd.d.ts → mongooseToJsonSchema-CUQma8QK.d.ts} +8 -111
- package/dist/pagination/PaginationEngine.d.ts +1 -1
- package/dist/plugins/index.d.ts +1 -1
- package/dist/{types-CHIDluaP.d.ts → types-CrSoCuWu.d.ts} +18 -31
- package/dist/utils/index.d.ts +2 -2
- package/dist/utils/index.js +104 -417
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
2
|
|
|
3
3
|
var __defProp = Object.defineProperty;
|
|
4
4
|
var __export = (target, all) => {
|
|
@@ -403,20 +403,51 @@ async function countBy(Model, field, query = {}, options = {}) {
|
|
|
403
403
|
return aggregate(Model, pipeline, options);
|
|
404
404
|
}
|
|
405
405
|
async function lookup(Model, lookupOptions) {
|
|
406
|
-
const { from, localField, foreignField, as, pipeline = [], query = {}, options = {} } = lookupOptions;
|
|
406
|
+
const { from, localField, foreignField, as, pipeline = [], let: letVars, query = {}, options = {} } = lookupOptions;
|
|
407
407
|
const aggPipeline = [];
|
|
408
408
|
if (Object.keys(query).length > 0) {
|
|
409
409
|
aggPipeline.push({ $match: query });
|
|
410
410
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
411
|
+
const usePipelineForm = pipeline.length > 0 || letVars;
|
|
412
|
+
if (usePipelineForm) {
|
|
413
|
+
if (pipeline.length === 0 && localField && foreignField) {
|
|
414
|
+
const autoPipeline = [
|
|
415
|
+
{
|
|
416
|
+
$match: {
|
|
417
|
+
$expr: {
|
|
418
|
+
$eq: [`$${foreignField}`, `$$${localField}`]
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
];
|
|
423
|
+
aggPipeline.push({
|
|
424
|
+
$lookup: {
|
|
425
|
+
from,
|
|
426
|
+
let: { [localField]: `$${localField}`, ...letVars || {} },
|
|
427
|
+
pipeline: autoPipeline,
|
|
428
|
+
as
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
} else {
|
|
432
|
+
aggPipeline.push({
|
|
433
|
+
$lookup: {
|
|
434
|
+
from,
|
|
435
|
+
...letVars && { let: letVars },
|
|
436
|
+
pipeline,
|
|
437
|
+
as
|
|
438
|
+
}
|
|
439
|
+
});
|
|
418
440
|
}
|
|
419
|
-
}
|
|
441
|
+
} else {
|
|
442
|
+
aggPipeline.push({
|
|
443
|
+
$lookup: {
|
|
444
|
+
from,
|
|
445
|
+
localField,
|
|
446
|
+
foreignField,
|
|
447
|
+
as
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
}
|
|
420
451
|
return aggregate(Model, aggPipeline, options);
|
|
421
452
|
}
|
|
422
453
|
async function unwind(Model, field, options = {}) {
|
|
@@ -521,12 +552,12 @@ function validateCursorVersion(cursorVersion, expectedVersion) {
|
|
|
521
552
|
}
|
|
522
553
|
function serializeValue(value) {
|
|
523
554
|
if (value instanceof Date) return value.toISOString();
|
|
524
|
-
if (value instanceof
|
|
555
|
+
if (value instanceof mongoose.Types.ObjectId) return value.toString();
|
|
525
556
|
return value;
|
|
526
557
|
}
|
|
527
558
|
function getValueType(value) {
|
|
528
559
|
if (value instanceof Date) return "date";
|
|
529
|
-
if (value instanceof
|
|
560
|
+
if (value instanceof mongoose.Types.ObjectId) return "objectid";
|
|
530
561
|
if (typeof value === "boolean") return "boolean";
|
|
531
562
|
if (typeof value === "number") return "number";
|
|
532
563
|
if (typeof value === "string") return "string";
|
|
@@ -537,7 +568,7 @@ function rehydrateValue(serialized, type) {
|
|
|
537
568
|
case "date":
|
|
538
569
|
return new Date(serialized);
|
|
539
570
|
case "objectid":
|
|
540
|
-
return new
|
|
571
|
+
return new mongoose.Types.ObjectId(serialized);
|
|
541
572
|
case "boolean":
|
|
542
573
|
return Boolean(serialized);
|
|
543
574
|
case "number":
|
|
@@ -837,6 +868,640 @@ var PaginationEngine = class {
|
|
|
837
868
|
}
|
|
838
869
|
};
|
|
839
870
|
|
|
871
|
+
// src/query/LookupBuilder.ts
|
|
872
|
+
var LookupBuilder = class _LookupBuilder {
|
|
873
|
+
options = {};
|
|
874
|
+
constructor(from) {
|
|
875
|
+
if (from) this.options.from = from;
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Set the collection to join with
|
|
879
|
+
*/
|
|
880
|
+
from(collection) {
|
|
881
|
+
this.options.from = collection;
|
|
882
|
+
return this;
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Set the local field (source collection)
|
|
886
|
+
* IMPORTANT: This field should be indexed for optimal performance
|
|
887
|
+
*/
|
|
888
|
+
localField(field) {
|
|
889
|
+
this.options.localField = field;
|
|
890
|
+
return this;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Set the foreign field (target collection)
|
|
894
|
+
* IMPORTANT: This field should be indexed (preferably unique) for optimal performance
|
|
895
|
+
*/
|
|
896
|
+
foreignField(field) {
|
|
897
|
+
this.options.foreignField = field;
|
|
898
|
+
return this;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Set the output field name
|
|
902
|
+
* Defaults to the collection name if not specified
|
|
903
|
+
*/
|
|
904
|
+
as(fieldName) {
|
|
905
|
+
this.options.as = fieldName;
|
|
906
|
+
return this;
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Mark this lookup as returning a single document
|
|
910
|
+
* Automatically unwraps the array result to a single object or null
|
|
911
|
+
*/
|
|
912
|
+
single(isSingle = true) {
|
|
913
|
+
this.options.single = isSingle;
|
|
914
|
+
return this;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Add a pipeline to filter/transform joined documents
|
|
918
|
+
* Useful for filtering, sorting, or limiting joined results
|
|
919
|
+
*
|
|
920
|
+
* @example
|
|
921
|
+
* ```typescript
|
|
922
|
+
* lookup.pipeline([
|
|
923
|
+
* { $match: { status: 'active' } },
|
|
924
|
+
* { $sort: { priority: -1 } },
|
|
925
|
+
* { $limit: 5 }
|
|
926
|
+
* ]);
|
|
927
|
+
* ```
|
|
928
|
+
*/
|
|
929
|
+
pipeline(stages) {
|
|
930
|
+
this.options.pipeline = stages;
|
|
931
|
+
return this;
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Set let variables for use in pipeline
|
|
935
|
+
* Allows referencing local document fields in the pipeline
|
|
936
|
+
*/
|
|
937
|
+
let(variables) {
|
|
938
|
+
this.options.let = variables;
|
|
939
|
+
return this;
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Build the $lookup aggregation stage(s)
|
|
943
|
+
* Returns an array of pipeline stages including $lookup and optional $unwind
|
|
944
|
+
*
|
|
945
|
+
* IMPORTANT: MongoDB $lookup has two mutually exclusive forms:
|
|
946
|
+
* 1. Simple form: { from, localField, foreignField, as }
|
|
947
|
+
* 2. Pipeline form: { from, let, pipeline, as }
|
|
948
|
+
*
|
|
949
|
+
* When pipeline or let is specified, we use the pipeline form.
|
|
950
|
+
* Otherwise, we use the simpler localField/foreignField form.
|
|
951
|
+
*/
|
|
952
|
+
build() {
|
|
953
|
+
const { from, localField, foreignField, as, single, pipeline, let: letVars } = this.options;
|
|
954
|
+
if (!from) {
|
|
955
|
+
throw new Error('LookupBuilder: "from" collection is required');
|
|
956
|
+
}
|
|
957
|
+
const outputField = as || from;
|
|
958
|
+
const stages = [];
|
|
959
|
+
const usePipelineForm = pipeline || letVars;
|
|
960
|
+
let lookupStage;
|
|
961
|
+
if (usePipelineForm) {
|
|
962
|
+
if (!pipeline || pipeline.length === 0) {
|
|
963
|
+
if (!localField || !foreignField) {
|
|
964
|
+
throw new Error(
|
|
965
|
+
"LookupBuilder: When using pipeline form without a custom pipeline, both localField and foreignField are required to auto-generate the pipeline"
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
const autoPipeline = [
|
|
969
|
+
{
|
|
970
|
+
$match: {
|
|
971
|
+
$expr: {
|
|
972
|
+
$eq: [`$${foreignField}`, `$$${localField}`]
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
];
|
|
977
|
+
lookupStage = {
|
|
978
|
+
$lookup: {
|
|
979
|
+
from,
|
|
980
|
+
let: { [localField]: `$${localField}`, ...letVars || {} },
|
|
981
|
+
pipeline: autoPipeline,
|
|
982
|
+
as: outputField
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
} else {
|
|
986
|
+
lookupStage = {
|
|
987
|
+
$lookup: {
|
|
988
|
+
from,
|
|
989
|
+
...letVars && { let: letVars },
|
|
990
|
+
pipeline,
|
|
991
|
+
as: outputField
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
} else {
|
|
996
|
+
if (!localField || !foreignField) {
|
|
997
|
+
throw new Error("LookupBuilder: localField and foreignField are required for simple lookup");
|
|
998
|
+
}
|
|
999
|
+
lookupStage = {
|
|
1000
|
+
$lookup: {
|
|
1001
|
+
from,
|
|
1002
|
+
localField,
|
|
1003
|
+
foreignField,
|
|
1004
|
+
as: outputField
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
stages.push(lookupStage);
|
|
1009
|
+
if (single) {
|
|
1010
|
+
stages.push({
|
|
1011
|
+
$unwind: {
|
|
1012
|
+
path: `$${outputField}`,
|
|
1013
|
+
preserveNullAndEmptyArrays: true
|
|
1014
|
+
// Keep documents even if no match found
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
return stages;
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Build and return only the $lookup stage (without $unwind)
|
|
1022
|
+
* Useful when you want to handle unwrapping yourself
|
|
1023
|
+
*/
|
|
1024
|
+
buildLookupOnly() {
|
|
1025
|
+
const stages = this.build();
|
|
1026
|
+
return stages[0];
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Static helper: Create a simple lookup in one line
|
|
1030
|
+
*/
|
|
1031
|
+
static simple(from, localField, foreignField, options = {}) {
|
|
1032
|
+
return new _LookupBuilder(from).localField(localField).foreignField(foreignField).as(options.as || from).single(options.single || false).build();
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Static helper: Create multiple lookups at once
|
|
1036
|
+
*
|
|
1037
|
+
* @example
|
|
1038
|
+
* ```typescript
|
|
1039
|
+
* const pipeline = LookupBuilder.multiple([
|
|
1040
|
+
* { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
|
|
1041
|
+
* { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
|
|
1042
|
+
* ]);
|
|
1043
|
+
* ```
|
|
1044
|
+
*/
|
|
1045
|
+
static multiple(lookups) {
|
|
1046
|
+
return lookups.flatMap((lookup2) => {
|
|
1047
|
+
const builder = new _LookupBuilder(lookup2.from).localField(lookup2.localField).foreignField(lookup2.foreignField);
|
|
1048
|
+
if (lookup2.as) builder.as(lookup2.as);
|
|
1049
|
+
if (lookup2.single) builder.single(lookup2.single);
|
|
1050
|
+
if (lookup2.pipeline) builder.pipeline(lookup2.pipeline);
|
|
1051
|
+
if (lookup2.let) builder.let(lookup2.let);
|
|
1052
|
+
return builder.build();
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Static helper: Create a nested lookup (lookup within lookup)
|
|
1057
|
+
* Useful for multi-level joins like Order -> Product -> Category
|
|
1058
|
+
*
|
|
1059
|
+
* @example
|
|
1060
|
+
* ```typescript
|
|
1061
|
+
* // Join orders with products, then products with categories
|
|
1062
|
+
* const pipeline = LookupBuilder.nested([
|
|
1063
|
+
* { from: 'products', localField: 'productSku', foreignField: 'sku', as: 'product', single: true },
|
|
1064
|
+
* { from: 'categories', localField: 'product.categorySlug', foreignField: 'slug', as: 'product.category', single: true }
|
|
1065
|
+
* ]);
|
|
1066
|
+
* ```
|
|
1067
|
+
*/
|
|
1068
|
+
static nested(lookups) {
|
|
1069
|
+
return lookups.flatMap((lookup2, index) => {
|
|
1070
|
+
const builder = new _LookupBuilder(lookup2.from).localField(lookup2.localField).foreignField(lookup2.foreignField);
|
|
1071
|
+
if (lookup2.as) builder.as(lookup2.as);
|
|
1072
|
+
if (lookup2.single !== void 0) builder.single(lookup2.single);
|
|
1073
|
+
if (lookup2.pipeline) builder.pipeline(lookup2.pipeline);
|
|
1074
|
+
if (lookup2.let) builder.let(lookup2.let);
|
|
1075
|
+
return builder.build();
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
// src/query/AggregationBuilder.ts
|
|
1081
|
+
function normalizeSortSpec(sortSpec) {
|
|
1082
|
+
const normalized = {};
|
|
1083
|
+
for (const [field, order] of Object.entries(sortSpec)) {
|
|
1084
|
+
if (order === "asc") {
|
|
1085
|
+
normalized[field] = 1;
|
|
1086
|
+
} else if (order === "desc") {
|
|
1087
|
+
normalized[field] = -1;
|
|
1088
|
+
} else {
|
|
1089
|
+
normalized[field] = order;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return normalized;
|
|
1093
|
+
}
|
|
1094
|
+
var AggregationBuilder = class _AggregationBuilder {
|
|
1095
|
+
pipeline = [];
|
|
1096
|
+
/**
|
|
1097
|
+
* Get the current pipeline
|
|
1098
|
+
*/
|
|
1099
|
+
get() {
|
|
1100
|
+
return [...this.pipeline];
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Build and return the final pipeline
|
|
1104
|
+
*/
|
|
1105
|
+
build() {
|
|
1106
|
+
return this.get();
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Reset the pipeline
|
|
1110
|
+
*/
|
|
1111
|
+
reset() {
|
|
1112
|
+
this.pipeline = [];
|
|
1113
|
+
return this;
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Add a raw pipeline stage
|
|
1117
|
+
*/
|
|
1118
|
+
addStage(stage) {
|
|
1119
|
+
this.pipeline.push(stage);
|
|
1120
|
+
return this;
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Add multiple raw pipeline stages
|
|
1124
|
+
*/
|
|
1125
|
+
addStages(stages) {
|
|
1126
|
+
this.pipeline.push(...stages);
|
|
1127
|
+
return this;
|
|
1128
|
+
}
|
|
1129
|
+
// ============================================================
|
|
1130
|
+
// CORE AGGREGATION STAGES
|
|
1131
|
+
// ============================================================
|
|
1132
|
+
/**
|
|
1133
|
+
* $match - Filter documents
|
|
1134
|
+
* IMPORTANT: Place $match as early as possible for performance
|
|
1135
|
+
*/
|
|
1136
|
+
match(query) {
|
|
1137
|
+
this.pipeline.push({ $match: query });
|
|
1138
|
+
return this;
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* $project - Include/exclude fields or compute new fields
|
|
1142
|
+
*/
|
|
1143
|
+
project(projection) {
|
|
1144
|
+
this.pipeline.push({ $project: projection });
|
|
1145
|
+
return this;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* $group - Group documents and compute aggregations
|
|
1149
|
+
*
|
|
1150
|
+
* @example
|
|
1151
|
+
* ```typescript
|
|
1152
|
+
* .group({
|
|
1153
|
+
* _id: '$department',
|
|
1154
|
+
* count: { $sum: 1 },
|
|
1155
|
+
* avgSalary: { $avg: '$salary' }
|
|
1156
|
+
* })
|
|
1157
|
+
* ```
|
|
1158
|
+
*/
|
|
1159
|
+
group(groupSpec) {
|
|
1160
|
+
this.pipeline.push({ $group: groupSpec });
|
|
1161
|
+
return this;
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* $sort - Sort documents
|
|
1165
|
+
*/
|
|
1166
|
+
sort(sortSpec) {
|
|
1167
|
+
if (typeof sortSpec === "string") {
|
|
1168
|
+
const order = sortSpec.startsWith("-") ? -1 : 1;
|
|
1169
|
+
const field = sortSpec.startsWith("-") ? sortSpec.substring(1) : sortSpec;
|
|
1170
|
+
this.pipeline.push({ $sort: { [field]: order } });
|
|
1171
|
+
} else {
|
|
1172
|
+
this.pipeline.push({ $sort: normalizeSortSpec(sortSpec) });
|
|
1173
|
+
}
|
|
1174
|
+
return this;
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* $limit - Limit number of documents
|
|
1178
|
+
*/
|
|
1179
|
+
limit(count2) {
|
|
1180
|
+
this.pipeline.push({ $limit: count2 });
|
|
1181
|
+
return this;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* $skip - Skip documents
|
|
1185
|
+
*/
|
|
1186
|
+
skip(count2) {
|
|
1187
|
+
this.pipeline.push({ $skip: count2 });
|
|
1188
|
+
return this;
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* $unwind - Deconstruct array field
|
|
1192
|
+
*/
|
|
1193
|
+
unwind(path, preserveNullAndEmptyArrays = false) {
|
|
1194
|
+
this.pipeline.push({
|
|
1195
|
+
$unwind: {
|
|
1196
|
+
path: path.startsWith("$") ? path : `$${path}`,
|
|
1197
|
+
preserveNullAndEmptyArrays
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
return this;
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* $addFields - Add new fields or replace existing fields
|
|
1204
|
+
*/
|
|
1205
|
+
addFields(fields) {
|
|
1206
|
+
this.pipeline.push({ $addFields: fields });
|
|
1207
|
+
return this;
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* $set - Alias for $addFields
|
|
1211
|
+
*/
|
|
1212
|
+
set(fields) {
|
|
1213
|
+
return this.addFields(fields);
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* $unset - Remove fields
|
|
1217
|
+
*/
|
|
1218
|
+
unset(fields) {
|
|
1219
|
+
this.pipeline.push({ $unset: fields });
|
|
1220
|
+
return this;
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* $replaceRoot - Replace the root document
|
|
1224
|
+
*/
|
|
1225
|
+
replaceRoot(newRoot) {
|
|
1226
|
+
this.pipeline.push({
|
|
1227
|
+
$replaceRoot: {
|
|
1228
|
+
newRoot: typeof newRoot === "string" ? `$${newRoot}` : newRoot
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
return this;
|
|
1232
|
+
}
|
|
1233
|
+
// ============================================================
|
|
1234
|
+
// LOOKUP (JOINS)
|
|
1235
|
+
// ============================================================
|
|
1236
|
+
/**
|
|
1237
|
+
* $lookup - Join with another collection (simple form)
|
|
1238
|
+
*
|
|
1239
|
+
* @param from - Collection to join with
|
|
1240
|
+
* @param localField - Field from source collection
|
|
1241
|
+
* @param foreignField - Field from target collection
|
|
1242
|
+
* @param as - Output field name
|
|
1243
|
+
* @param single - Unwrap array to single object
|
|
1244
|
+
*
|
|
1245
|
+
* @example
|
|
1246
|
+
* ```typescript
|
|
1247
|
+
* // Join employees with departments by slug
|
|
1248
|
+
* .lookup('departments', 'deptSlug', 'slug', 'department', true)
|
|
1249
|
+
* ```
|
|
1250
|
+
*/
|
|
1251
|
+
lookup(from, localField, foreignField, as, single) {
|
|
1252
|
+
const stages = new LookupBuilder(from).localField(localField).foreignField(foreignField).as(as || from).single(single || false).build();
|
|
1253
|
+
this.pipeline.push(...stages);
|
|
1254
|
+
return this;
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* $lookup - Join with another collection (advanced form with pipeline)
|
|
1258
|
+
*
|
|
1259
|
+
* @example
|
|
1260
|
+
* ```typescript
|
|
1261
|
+
* .lookupWithPipeline({
|
|
1262
|
+
* from: 'products',
|
|
1263
|
+
* localField: 'productIds',
|
|
1264
|
+
* foreignField: 'sku',
|
|
1265
|
+
* as: 'products',
|
|
1266
|
+
* pipeline: [
|
|
1267
|
+
* { $match: { status: 'active' } },
|
|
1268
|
+
* { $project: { name: 1, price: 1 } }
|
|
1269
|
+
* ]
|
|
1270
|
+
* })
|
|
1271
|
+
* ```
|
|
1272
|
+
*/
|
|
1273
|
+
lookupWithPipeline(options) {
|
|
1274
|
+
const builder = new LookupBuilder(options.from).localField(options.localField).foreignField(options.foreignField);
|
|
1275
|
+
if (options.as) builder.as(options.as);
|
|
1276
|
+
if (options.single) builder.single(options.single);
|
|
1277
|
+
if (options.pipeline) builder.pipeline(options.pipeline);
|
|
1278
|
+
if (options.let) builder.let(options.let);
|
|
1279
|
+
this.pipeline.push(...builder.build());
|
|
1280
|
+
return this;
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Multiple lookups at once
|
|
1284
|
+
*
|
|
1285
|
+
* @example
|
|
1286
|
+
* ```typescript
|
|
1287
|
+
* .multiLookup([
|
|
1288
|
+
* { from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true },
|
|
1289
|
+
* { from: 'managers', localField: 'managerId', foreignField: '_id', single: true }
|
|
1290
|
+
* ])
|
|
1291
|
+
* ```
|
|
1292
|
+
*/
|
|
1293
|
+
multiLookup(lookups) {
|
|
1294
|
+
const stages = LookupBuilder.multiple(lookups);
|
|
1295
|
+
this.pipeline.push(...stages);
|
|
1296
|
+
return this;
|
|
1297
|
+
}
|
|
1298
|
+
// ============================================================
|
|
1299
|
+
// ADVANCED OPERATORS (MongoDB 6+)
|
|
1300
|
+
// ============================================================
|
|
1301
|
+
/**
|
|
1302
|
+
* $facet - Process multiple aggregation pipelines in a single stage
|
|
1303
|
+
* Useful for computing multiple aggregations in parallel
|
|
1304
|
+
*
|
|
1305
|
+
* @example
|
|
1306
|
+
* ```typescript
|
|
1307
|
+
* .facet({
|
|
1308
|
+
* totalCount: [{ $count: 'count' }],
|
|
1309
|
+
* avgPrice: [{ $group: { _id: null, avg: { $avg: '$price' } } }],
|
|
1310
|
+
* topProducts: [{ $sort: { sales: -1 } }, { $limit: 10 }]
|
|
1311
|
+
* })
|
|
1312
|
+
* ```
|
|
1313
|
+
*/
|
|
1314
|
+
facet(facets) {
|
|
1315
|
+
this.pipeline.push({ $facet: facets });
|
|
1316
|
+
return this;
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* $bucket - Categorize documents into buckets
|
|
1320
|
+
*
|
|
1321
|
+
* @example
|
|
1322
|
+
* ```typescript
|
|
1323
|
+
* .bucket({
|
|
1324
|
+
* groupBy: '$price',
|
|
1325
|
+
* boundaries: [0, 50, 100, 200],
|
|
1326
|
+
* default: 'Other',
|
|
1327
|
+
* output: {
|
|
1328
|
+
* count: { $sum: 1 },
|
|
1329
|
+
* products: { $push: '$name' }
|
|
1330
|
+
* }
|
|
1331
|
+
* })
|
|
1332
|
+
* ```
|
|
1333
|
+
*/
|
|
1334
|
+
bucket(options) {
|
|
1335
|
+
this.pipeline.push({ $bucket: options });
|
|
1336
|
+
return this;
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* $bucketAuto - Automatically determine bucket boundaries
|
|
1340
|
+
*/
|
|
1341
|
+
bucketAuto(options) {
|
|
1342
|
+
this.pipeline.push({ $bucketAuto: options });
|
|
1343
|
+
return this;
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* $setWindowFields - Perform window functions (MongoDB 5.0+)
|
|
1347
|
+
* Useful for rankings, running totals, moving averages
|
|
1348
|
+
*
|
|
1349
|
+
* @example
|
|
1350
|
+
* ```typescript
|
|
1351
|
+
* .setWindowFields({
|
|
1352
|
+
* partitionBy: '$department',
|
|
1353
|
+
* sortBy: { salary: -1 },
|
|
1354
|
+
* output: {
|
|
1355
|
+
* rank: { $rank: {} },
|
|
1356
|
+
* runningTotal: { $sum: '$salary', window: { documents: ['unbounded', 'current'] } }
|
|
1357
|
+
* }
|
|
1358
|
+
* })
|
|
1359
|
+
* ```
|
|
1360
|
+
*/
|
|
1361
|
+
setWindowFields(options) {
|
|
1362
|
+
const normalizedOptions = {
|
|
1363
|
+
...options,
|
|
1364
|
+
sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
|
|
1365
|
+
};
|
|
1366
|
+
this.pipeline.push({ $setWindowFields: normalizedOptions });
|
|
1367
|
+
return this;
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* $unionWith - Combine results from multiple collections (MongoDB 4.4+)
|
|
1371
|
+
*
|
|
1372
|
+
* @example
|
|
1373
|
+
* ```typescript
|
|
1374
|
+
* .unionWith({
|
|
1375
|
+
* coll: 'archivedOrders',
|
|
1376
|
+
* pipeline: [{ $match: { year: 2024 } }]
|
|
1377
|
+
* })
|
|
1378
|
+
* ```
|
|
1379
|
+
*/
|
|
1380
|
+
unionWith(options) {
|
|
1381
|
+
this.pipeline.push({ $unionWith: options });
|
|
1382
|
+
return this;
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* $densify - Fill gaps in data (MongoDB 5.1+)
|
|
1386
|
+
* Useful for time series data with missing points
|
|
1387
|
+
*/
|
|
1388
|
+
densify(options) {
|
|
1389
|
+
this.pipeline.push({ $densify: options });
|
|
1390
|
+
return this;
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* $fill - Fill null or missing field values (MongoDB 5.3+)
|
|
1394
|
+
*/
|
|
1395
|
+
fill(options) {
|
|
1396
|
+
const normalizedOptions = {
|
|
1397
|
+
...options,
|
|
1398
|
+
sortBy: options.sortBy ? normalizeSortSpec(options.sortBy) : void 0
|
|
1399
|
+
};
|
|
1400
|
+
this.pipeline.push({ $fill: normalizedOptions });
|
|
1401
|
+
return this;
|
|
1402
|
+
}
|
|
1403
|
+
// ============================================================
|
|
1404
|
+
// UTILITY METHODS
|
|
1405
|
+
// ============================================================
|
|
1406
|
+
/**
|
|
1407
|
+
* Paginate - Add skip and limit for offset-based pagination
|
|
1408
|
+
*/
|
|
1409
|
+
paginate(page, limit) {
|
|
1410
|
+
const skip = (page - 1) * limit;
|
|
1411
|
+
return this.skip(skip).limit(limit);
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Count total documents (useful with $facet for pagination metadata)
|
|
1415
|
+
*/
|
|
1416
|
+
count(outputField = "count") {
|
|
1417
|
+
this.pipeline.push({ $count: outputField });
|
|
1418
|
+
return this;
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Sample - Randomly select N documents
|
|
1422
|
+
*/
|
|
1423
|
+
sample(size) {
|
|
1424
|
+
this.pipeline.push({ $sample: { size } });
|
|
1425
|
+
return this;
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Out - Write results to a collection
|
|
1429
|
+
*/
|
|
1430
|
+
out(collection) {
|
|
1431
|
+
this.pipeline.push({ $out: collection });
|
|
1432
|
+
return this;
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Merge - Merge results into a collection
|
|
1436
|
+
*/
|
|
1437
|
+
merge(options) {
|
|
1438
|
+
this.pipeline.push({
|
|
1439
|
+
$merge: typeof options === "string" ? { into: options } : options
|
|
1440
|
+
});
|
|
1441
|
+
return this;
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* GeoNear - Perform geospatial queries
|
|
1445
|
+
*/
|
|
1446
|
+
geoNear(options) {
|
|
1447
|
+
this.pipeline.push({ $geoNear: options });
|
|
1448
|
+
return this;
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* GraphLookup - Perform recursive search (graph traversal)
|
|
1452
|
+
*/
|
|
1453
|
+
graphLookup(options) {
|
|
1454
|
+
this.pipeline.push({ $graphLookup: options });
|
|
1455
|
+
return this;
|
|
1456
|
+
}
|
|
1457
|
+
// ============================================================
|
|
1458
|
+
// ATLAS SEARCH (MongoDB Atlas only)
|
|
1459
|
+
// ============================================================
|
|
1460
|
+
/**
|
|
1461
|
+
* $search - Atlas Search full-text search (Atlas only)
|
|
1462
|
+
*
|
|
1463
|
+
* @example
|
|
1464
|
+
* ```typescript
|
|
1465
|
+
* .search({
|
|
1466
|
+
* index: 'default',
|
|
1467
|
+
* text: {
|
|
1468
|
+
* query: 'laptop computer',
|
|
1469
|
+
* path: ['title', 'description'],
|
|
1470
|
+
* fuzzy: { maxEdits: 2 }
|
|
1471
|
+
* }
|
|
1472
|
+
* })
|
|
1473
|
+
* ```
|
|
1474
|
+
*/
|
|
1475
|
+
search(options) {
|
|
1476
|
+
this.pipeline.push({ $search: options });
|
|
1477
|
+
return this;
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* $searchMeta - Get Atlas Search metadata (Atlas only)
|
|
1481
|
+
*/
|
|
1482
|
+
searchMeta(options) {
|
|
1483
|
+
this.pipeline.push({ $searchMeta: options });
|
|
1484
|
+
return this;
|
|
1485
|
+
}
|
|
1486
|
+
// ============================================================
|
|
1487
|
+
// HELPER FACTORY METHODS
|
|
1488
|
+
// ============================================================
|
|
1489
|
+
/**
|
|
1490
|
+
* Create a builder from an existing pipeline
|
|
1491
|
+
*/
|
|
1492
|
+
static from(pipeline) {
|
|
1493
|
+
const builder = new _AggregationBuilder();
|
|
1494
|
+
builder.pipeline = [...pipeline];
|
|
1495
|
+
return builder;
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Create a builder with initial match stage
|
|
1499
|
+
*/
|
|
1500
|
+
static startWith(query) {
|
|
1501
|
+
return new _AggregationBuilder().match(query);
|
|
1502
|
+
}
|
|
1503
|
+
};
|
|
1504
|
+
|
|
840
1505
|
// src/Repository.ts
|
|
841
1506
|
var Repository = class {
|
|
842
1507
|
Model;
|
|
@@ -999,8 +1664,8 @@ var Repository = class {
|
|
|
999
1664
|
}
|
|
1000
1665
|
const hasPageParam = params.page !== void 0 || params.pagination;
|
|
1001
1666
|
const hasCursorParam = "cursor" in params || "after" in params;
|
|
1002
|
-
const
|
|
1003
|
-
const useKeyset = !hasPageParam && (hasCursorParam ||
|
|
1667
|
+
const hasSortParam = params.sort !== void 0;
|
|
1668
|
+
const useKeyset = !hasPageParam && (hasCursorParam || hasSortParam);
|
|
1004
1669
|
const filters = context.filters || params.filters || {};
|
|
1005
1670
|
const search = params.search;
|
|
1006
1671
|
const sort = params.sort || "-createdAt";
|
|
@@ -1105,23 +1770,186 @@ var Repository = class {
|
|
|
1105
1770
|
async distinct(field, query = {}, options = {}) {
|
|
1106
1771
|
return distinct(this.Model, field, query, options);
|
|
1107
1772
|
}
|
|
1773
|
+
/**
|
|
1774
|
+
* Query with custom field lookups ($lookup)
|
|
1775
|
+
* Best for: Joins on slugs, SKUs, codes, or other indexed custom fields
|
|
1776
|
+
*
|
|
1777
|
+
* @example
|
|
1778
|
+
* ```typescript
|
|
1779
|
+
* // Join employees with departments using slug instead of ObjectId
|
|
1780
|
+
* const employees = await employeeRepo.lookupPopulate({
|
|
1781
|
+
* filters: { status: 'active' },
|
|
1782
|
+
* lookups: [
|
|
1783
|
+
* {
|
|
1784
|
+
* from: 'departments',
|
|
1785
|
+
* localField: 'departmentSlug',
|
|
1786
|
+
* foreignField: 'slug',
|
|
1787
|
+
* as: 'department',
|
|
1788
|
+
* single: true
|
|
1789
|
+
* }
|
|
1790
|
+
* ],
|
|
1791
|
+
* sort: '-createdAt',
|
|
1792
|
+
* page: 1,
|
|
1793
|
+
* limit: 50
|
|
1794
|
+
* });
|
|
1795
|
+
* ```
|
|
1796
|
+
*/
|
|
1797
|
+
async lookupPopulate(options) {
|
|
1798
|
+
const context = await this._buildContext("lookupPopulate", options);
|
|
1799
|
+
try {
|
|
1800
|
+
const builder = new AggregationBuilder();
|
|
1801
|
+
if (options.filters && Object.keys(options.filters).length > 0) {
|
|
1802
|
+
builder.match(options.filters);
|
|
1803
|
+
}
|
|
1804
|
+
builder.multiLookup(options.lookups);
|
|
1805
|
+
if (options.sort) {
|
|
1806
|
+
builder.sort(this._parseSort(options.sort));
|
|
1807
|
+
}
|
|
1808
|
+
const page = options.page || 1;
|
|
1809
|
+
const limit = options.limit || this._pagination.config.defaultLimit || 20;
|
|
1810
|
+
const skip = (page - 1) * limit;
|
|
1811
|
+
const SAFE_LIMIT = 1e3;
|
|
1812
|
+
const SAFE_MAX_OFFSET = 1e4;
|
|
1813
|
+
if (limit > SAFE_LIMIT) {
|
|
1814
|
+
console.warn(
|
|
1815
|
+
`[mongokit] Large limit (${limit}) in lookupPopulate. $facet results must be <16MB. Consider using smaller limits or stream-based pagination for large datasets.`
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
if (skip > SAFE_MAX_OFFSET) {
|
|
1819
|
+
console.warn(
|
|
1820
|
+
`[mongokit] Large offset (${skip}) in lookupPopulate. $facet with high offsets can exceed 16MB. For deep pagination, consider using keyset/cursor-based pagination instead.`
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
const dataStages = [
|
|
1824
|
+
{ $skip: skip },
|
|
1825
|
+
{ $limit: limit }
|
|
1826
|
+
];
|
|
1827
|
+
if (options.select) {
|
|
1828
|
+
let projection;
|
|
1829
|
+
if (typeof options.select === "string") {
|
|
1830
|
+
projection = {};
|
|
1831
|
+
const fields = options.select.split(",").map((f) => f.trim());
|
|
1832
|
+
for (const field of fields) {
|
|
1833
|
+
if (field.startsWith("-")) {
|
|
1834
|
+
projection[field.substring(1)] = 0;
|
|
1835
|
+
} else {
|
|
1836
|
+
projection[field] = 1;
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
} else if (Array.isArray(options.select)) {
|
|
1840
|
+
projection = {};
|
|
1841
|
+
for (const field of options.select) {
|
|
1842
|
+
if (field.startsWith("-")) {
|
|
1843
|
+
projection[field.substring(1)] = 0;
|
|
1844
|
+
} else {
|
|
1845
|
+
projection[field] = 1;
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
} else {
|
|
1849
|
+
projection = options.select;
|
|
1850
|
+
}
|
|
1851
|
+
dataStages.push({ $project: projection });
|
|
1852
|
+
}
|
|
1853
|
+
builder.facet({
|
|
1854
|
+
metadata: [{ $count: "total" }],
|
|
1855
|
+
data: dataStages
|
|
1856
|
+
});
|
|
1857
|
+
const pipeline = builder.build();
|
|
1858
|
+
const results = await this.Model.aggregate(pipeline).session(options.session || null);
|
|
1859
|
+
const result = results[0] || { metadata: [], data: [] };
|
|
1860
|
+
const total = result.metadata[0]?.total || 0;
|
|
1861
|
+
const data = result.data || [];
|
|
1862
|
+
await this._emitHook("after:lookupPopulate", { context, result: data });
|
|
1863
|
+
return {
|
|
1864
|
+
data,
|
|
1865
|
+
total,
|
|
1866
|
+
page,
|
|
1867
|
+
limit
|
|
1868
|
+
};
|
|
1869
|
+
} catch (error) {
|
|
1870
|
+
await this._emitErrorHook("error:lookupPopulate", { context, error });
|
|
1871
|
+
throw this._handleError(error);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Create an aggregation builder for this model
|
|
1876
|
+
* Useful for building complex custom aggregations
|
|
1877
|
+
*
|
|
1878
|
+
* @example
|
|
1879
|
+
* ```typescript
|
|
1880
|
+
* const pipeline = repo.buildAggregation()
|
|
1881
|
+
* .match({ status: 'active' })
|
|
1882
|
+
* .lookup('departments', 'deptSlug', 'slug', 'department', true)
|
|
1883
|
+
* .group({ _id: '$department', count: { $sum: 1 } })
|
|
1884
|
+
* .sort({ count: -1 })
|
|
1885
|
+
* .build();
|
|
1886
|
+
*
|
|
1887
|
+
* const results = await repo.Model.aggregate(pipeline);
|
|
1888
|
+
* ```
|
|
1889
|
+
*/
|
|
1890
|
+
buildAggregation() {
|
|
1891
|
+
return new AggregationBuilder();
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Create a lookup builder
|
|
1895
|
+
* Useful for building $lookup stages independently
|
|
1896
|
+
*
|
|
1897
|
+
* @example
|
|
1898
|
+
* ```typescript
|
|
1899
|
+
* const lookupStages = repo.buildLookup('departments')
|
|
1900
|
+
* .localField('deptSlug')
|
|
1901
|
+
* .foreignField('slug')
|
|
1902
|
+
* .as('department')
|
|
1903
|
+
* .single()
|
|
1904
|
+
* .build();
|
|
1905
|
+
*
|
|
1906
|
+
* const pipeline = [
|
|
1907
|
+
* { $match: { status: 'active' } },
|
|
1908
|
+
* ...lookupStages
|
|
1909
|
+
* ];
|
|
1910
|
+
* ```
|
|
1911
|
+
*/
|
|
1912
|
+
buildLookup(from) {
|
|
1913
|
+
return new LookupBuilder(from);
|
|
1914
|
+
}
|
|
1108
1915
|
/**
|
|
1109
1916
|
* Execute callback within a transaction
|
|
1110
1917
|
*/
|
|
1111
|
-
async withTransaction(callback) {
|
|
1112
|
-
const session = await
|
|
1113
|
-
|
|
1918
|
+
async withTransaction(callback, options = {}) {
|
|
1919
|
+
const session = await mongoose.startSession();
|
|
1920
|
+
let started = false;
|
|
1114
1921
|
try {
|
|
1922
|
+
session.startTransaction();
|
|
1923
|
+
started = true;
|
|
1115
1924
|
const result = await callback(session);
|
|
1116
1925
|
await session.commitTransaction();
|
|
1117
1926
|
return result;
|
|
1118
1927
|
} catch (error) {
|
|
1119
|
-
|
|
1120
|
-
|
|
1928
|
+
const err = error;
|
|
1929
|
+
if (options.allowFallback && this._isTransactionUnsupported(err)) {
|
|
1930
|
+
if (typeof options.onFallback === "function") {
|
|
1931
|
+
options.onFallback(err);
|
|
1932
|
+
}
|
|
1933
|
+
if (started && session.inTransaction()) {
|
|
1934
|
+
try {
|
|
1935
|
+
await session.abortTransaction();
|
|
1936
|
+
} catch {
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
return await callback(null);
|
|
1940
|
+
}
|
|
1941
|
+
if (started && session.inTransaction()) {
|
|
1942
|
+
await session.abortTransaction();
|
|
1943
|
+
}
|
|
1944
|
+
throw err;
|
|
1121
1945
|
} finally {
|
|
1122
1946
|
session.endSession();
|
|
1123
1947
|
}
|
|
1124
1948
|
}
|
|
1949
|
+
_isTransactionUnsupported(error) {
|
|
1950
|
+
const message = (error.message || "").toLowerCase();
|
|
1951
|
+
return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("replica set") || message.includes("mongos");
|
|
1952
|
+
}
|
|
1125
1953
|
/**
|
|
1126
1954
|
* Execute custom query with event emission
|
|
1127
1955
|
*/
|
|
@@ -1172,11 +2000,11 @@ var Repository = class {
|
|
|
1172
2000
|
* Handle errors with proper HTTP status codes
|
|
1173
2001
|
*/
|
|
1174
2002
|
_handleError(error) {
|
|
1175
|
-
if (error instanceof
|
|
2003
|
+
if (error instanceof mongoose.Error.ValidationError) {
|
|
1176
2004
|
const messages = Object.values(error.errors).map((err) => err.message);
|
|
1177
2005
|
return createError(400, `Validation Error: ${messages.join(", ")}`);
|
|
1178
2006
|
}
|
|
1179
|
-
if (error instanceof
|
|
2007
|
+
if (error instanceof mongoose.Error.CastError) {
|
|
1180
2008
|
return createError(400, `Invalid ${error.path}: ${error.value}`);
|
|
1181
2009
|
}
|
|
1182
2010
|
if (error.status && error.message) return error;
|
|
@@ -2207,7 +3035,7 @@ function cascadePlugin(options) {
|
|
|
2207
3035
|
}
|
|
2208
3036
|
const isSoftDelete = context.softDeleted === true;
|
|
2209
3037
|
const cascadeDelete = async (relation) => {
|
|
2210
|
-
const RelatedModel =
|
|
3038
|
+
const RelatedModel = mongoose.models[relation.model];
|
|
2211
3039
|
if (!RelatedModel) {
|
|
2212
3040
|
logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`, {
|
|
2213
3041
|
parentModel: context.model,
|
|
@@ -2298,7 +3126,7 @@ function cascadePlugin(options) {
|
|
|
2298
3126
|
}
|
|
2299
3127
|
const isSoftDelete = context.softDeleted === true;
|
|
2300
3128
|
const cascadeDeleteMany = async (relation) => {
|
|
2301
|
-
const RelatedModel =
|
|
3129
|
+
const RelatedModel = mongoose.models[relation.model];
|
|
2302
3130
|
if (!RelatedModel) {
|
|
2303
3131
|
logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`, {
|
|
2304
3132
|
parentModel: context.model
|
|
@@ -2414,24 +3242,15 @@ function createMemoryCache(maxEntries = 1e3) {
|
|
|
2414
3242
|
}
|
|
2415
3243
|
};
|
|
2416
3244
|
}
|
|
2417
|
-
function isMongooseSchema(value) {
|
|
2418
|
-
return value instanceof mongoose4.Schema;
|
|
2419
|
-
}
|
|
2420
|
-
function isPlainObject(value) {
|
|
2421
|
-
return Object.prototype.toString.call(value) === "[object Object]";
|
|
2422
|
-
}
|
|
2423
|
-
function isObjectIdType(t) {
|
|
2424
|
-
return t === mongoose4.Schema.Types.ObjectId || t === mongoose4.Types.ObjectId;
|
|
2425
|
-
}
|
|
2426
3245
|
function buildCrudSchemasFromMongooseSchema(mongooseSchema, options = {}) {
|
|
2427
|
-
const
|
|
2428
|
-
const jsonCreate = buildJsonSchemaForCreate(tree, options);
|
|
3246
|
+
const jsonCreate = buildJsonSchemaFromPaths(mongooseSchema, options);
|
|
2429
3247
|
const jsonUpdate = buildJsonSchemaForUpdate(jsonCreate, options);
|
|
2430
3248
|
const jsonParams = {
|
|
2431
3249
|
type: "object",
|
|
2432
3250
|
properties: { id: { type: "string", pattern: "^[0-9a-fA-F]{24}$" } },
|
|
2433
3251
|
required: ["id"]
|
|
2434
3252
|
};
|
|
3253
|
+
const tree = mongooseSchema?.obj || {};
|
|
2435
3254
|
const jsonQuery = buildJsonSchemaForQuery(tree, options);
|
|
2436
3255
|
return { createBody: jsonCreate, updateBody: jsonUpdate, params: jsonParams, listQuery: jsonQuery };
|
|
2437
3256
|
}
|
|
@@ -2485,88 +3304,37 @@ function validateUpdateBody(body = {}, options = {}) {
|
|
|
2485
3304
|
violations
|
|
2486
3305
|
};
|
|
2487
3306
|
}
|
|
2488
|
-
function
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
const
|
|
2497
|
-
if (
|
|
2498
|
-
|
|
2499
|
-
}
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
3307
|
+
function buildJsonSchemaFromPaths(mongooseSchema, options) {
|
|
3308
|
+
const properties = {};
|
|
3309
|
+
const required = [];
|
|
3310
|
+
const paths = mongooseSchema.paths;
|
|
3311
|
+
const rootFields = /* @__PURE__ */ new Map();
|
|
3312
|
+
for (const [path, schemaType] of Object.entries(paths)) {
|
|
3313
|
+
if (path === "_id" || path === "__v") continue;
|
|
3314
|
+
const parts = path.split(".");
|
|
3315
|
+
const rootField = parts[0];
|
|
3316
|
+
if (!rootFields.has(rootField)) {
|
|
3317
|
+
rootFields.set(rootField, []);
|
|
3318
|
+
}
|
|
3319
|
+
rootFields.get(rootField).push({ path, schemaType });
|
|
3320
|
+
}
|
|
3321
|
+
for (const [rootField, fieldPaths] of rootFields.entries()) {
|
|
3322
|
+
if (fieldPaths.length === 1 && fieldPaths[0].path === rootField) {
|
|
3323
|
+
const schemaType = fieldPaths[0].schemaType;
|
|
3324
|
+
properties[rootField] = schemaTypeToJsonSchema(schemaType);
|
|
3325
|
+
if (schemaType.isRequired) {
|
|
3326
|
+
required.push(rootField);
|
|
2504
3327
|
}
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
if (typedDef.type === Date) {
|
|
2511
|
-
const mode = options?.dateAs || "datetime";
|
|
2512
|
-
return mode === "date" ? { type: "string", format: "date" } : { type: "string", format: "date-time" };
|
|
2513
|
-
}
|
|
2514
|
-
if (typedDef.type === Map || typedDef.type === mongoose4.Schema.Types.Map) {
|
|
2515
|
-
const ofSchema = jsonTypeFor(typedDef.of || String, options, seen);
|
|
2516
|
-
return { type: "object", additionalProperties: ofSchema };
|
|
2517
|
-
}
|
|
2518
|
-
if (typedDef.type === mongoose4.Schema.Types.Mixed) {
|
|
2519
|
-
return { type: "object", additionalProperties: true };
|
|
2520
|
-
}
|
|
2521
|
-
if (isObjectIdType(typedDef.type)) {
|
|
2522
|
-
return { type: "string", pattern: "^[0-9a-fA-F]{24}$" };
|
|
2523
|
-
}
|
|
2524
|
-
if (isMongooseSchema(typedDef.type)) {
|
|
2525
|
-
const obj = typedDef.type.obj;
|
|
2526
|
-
if (obj && typeof obj === "object") {
|
|
2527
|
-
if (seen.has(obj)) return { type: "object", additionalProperties: true };
|
|
2528
|
-
seen.add(obj);
|
|
2529
|
-
return convertTreeToJsonSchema(obj, options, seen);
|
|
3328
|
+
} else {
|
|
3329
|
+
const nestedSchema = buildNestedJsonSchema(fieldPaths, rootField);
|
|
3330
|
+
properties[rootField] = nestedSchema.schema;
|
|
3331
|
+
if (nestedSchema.required) {
|
|
3332
|
+
required.push(rootField);
|
|
2530
3333
|
}
|
|
2531
3334
|
}
|
|
2532
3335
|
}
|
|
2533
|
-
if (def === String) return { type: "string" };
|
|
2534
|
-
if (def === Number) return { type: "number" };
|
|
2535
|
-
if (def === Boolean) return { type: "boolean" };
|
|
2536
|
-
if (def === Date) {
|
|
2537
|
-
const mode = options?.dateAs || "datetime";
|
|
2538
|
-
return mode === "date" ? { type: "string", format: "date" } : { type: "string", format: "date-time" };
|
|
2539
|
-
}
|
|
2540
|
-
if (isObjectIdType(def)) return { type: "string", pattern: "^[0-9a-fA-F]{24}$" };
|
|
2541
|
-
if (isPlainObject(def)) {
|
|
2542
|
-
if (seen.has(def)) return { type: "object", additionalProperties: true };
|
|
2543
|
-
seen.add(def);
|
|
2544
|
-
return convertTreeToJsonSchema(def, options, seen);
|
|
2545
|
-
}
|
|
2546
|
-
return {};
|
|
2547
|
-
}
|
|
2548
|
-
function convertTreeToJsonSchema(tree, options, seen = /* @__PURE__ */ new WeakSet()) {
|
|
2549
|
-
if (!tree || typeof tree !== "object") {
|
|
2550
|
-
return { type: "object", properties: {} };
|
|
2551
|
-
}
|
|
2552
|
-
if (seen.has(tree)) {
|
|
2553
|
-
return { type: "object", additionalProperties: true };
|
|
2554
|
-
}
|
|
2555
|
-
seen.add(tree);
|
|
2556
|
-
const properties = {};
|
|
2557
|
-
const required = [];
|
|
2558
|
-
for (const [key, val] of Object.entries(tree || {})) {
|
|
2559
|
-
if (key === "__v" || key === "_id" || key === "id") continue;
|
|
2560
|
-
const cfg = isPlainObject(val) && "type" in val ? val : { };
|
|
2561
|
-
properties[key] = jsonTypeFor(val, options, seen);
|
|
2562
|
-
if (cfg.required === true) required.push(key);
|
|
2563
|
-
}
|
|
2564
3336
|
const schema = { type: "object", properties };
|
|
2565
3337
|
if (required.length) schema.required = required;
|
|
2566
|
-
return schema;
|
|
2567
|
-
}
|
|
2568
|
-
function buildJsonSchemaForCreate(tree, options) {
|
|
2569
|
-
const base = convertTreeToJsonSchema(tree, options, /* @__PURE__ */ new WeakSet());
|
|
2570
3338
|
const fieldsToOmit = /* @__PURE__ */ new Set(["createdAt", "updatedAt", "__v"]);
|
|
2571
3339
|
(options?.create?.omitFields || []).forEach((f) => fieldsToOmit.add(f));
|
|
2572
3340
|
const fieldRules = options?.fieldRules || {};
|
|
@@ -2576,37 +3344,96 @@ function buildJsonSchemaForCreate(tree, options) {
|
|
|
2576
3344
|
}
|
|
2577
3345
|
});
|
|
2578
3346
|
fieldsToOmit.forEach((field) => {
|
|
2579
|
-
if (
|
|
2580
|
-
delete
|
|
3347
|
+
if (schema.properties?.[field]) {
|
|
3348
|
+
delete schema.properties[field];
|
|
2581
3349
|
}
|
|
2582
|
-
if (
|
|
2583
|
-
|
|
3350
|
+
if (schema.required) {
|
|
3351
|
+
schema.required = schema.required.filter((k) => k !== field);
|
|
2584
3352
|
}
|
|
2585
3353
|
});
|
|
2586
3354
|
const reqOv = options?.create?.requiredOverrides || {};
|
|
2587
3355
|
const optOv = options?.create?.optionalOverrides || {};
|
|
2588
|
-
|
|
3356
|
+
schema.required = schema.required || [];
|
|
2589
3357
|
for (const [k, v] of Object.entries(reqOv)) {
|
|
2590
|
-
if (v && !
|
|
3358
|
+
if (v && !schema.required.includes(k)) schema.required.push(k);
|
|
2591
3359
|
}
|
|
2592
3360
|
for (const [k, v] of Object.entries(optOv)) {
|
|
2593
|
-
if (v &&
|
|
3361
|
+
if (v && schema.required) schema.required = schema.required.filter((x) => x !== k);
|
|
2594
3362
|
}
|
|
2595
3363
|
Object.entries(fieldRules).forEach(([field, rules]) => {
|
|
2596
|
-
if (rules.optional &&
|
|
2597
|
-
|
|
3364
|
+
if (rules.optional && schema.required) {
|
|
3365
|
+
schema.required = schema.required.filter((x) => x !== field);
|
|
2598
3366
|
}
|
|
2599
3367
|
});
|
|
2600
3368
|
const schemaOverrides = options?.create?.schemaOverrides || {};
|
|
2601
3369
|
for (const [k, override] of Object.entries(schemaOverrides)) {
|
|
2602
|
-
if (
|
|
2603
|
-
|
|
3370
|
+
if (schema.properties?.[k]) {
|
|
3371
|
+
schema.properties[k] = override;
|
|
2604
3372
|
}
|
|
2605
3373
|
}
|
|
2606
3374
|
if (options?.strictAdditionalProperties === true) {
|
|
2607
|
-
|
|
3375
|
+
schema.additionalProperties = false;
|
|
2608
3376
|
}
|
|
2609
|
-
return
|
|
3377
|
+
return schema;
|
|
3378
|
+
}
|
|
3379
|
+
function buildNestedJsonSchema(fieldPaths, rootField) {
|
|
3380
|
+
const properties = {};
|
|
3381
|
+
const required = [];
|
|
3382
|
+
let hasRequiredFields = false;
|
|
3383
|
+
for (const { path, schemaType } of fieldPaths) {
|
|
3384
|
+
const relativePath = path.substring(rootField.length + 1);
|
|
3385
|
+
const parts = relativePath.split(".");
|
|
3386
|
+
if (parts.length === 1) {
|
|
3387
|
+
properties[parts[0]] = schemaTypeToJsonSchema(schemaType);
|
|
3388
|
+
if (schemaType.isRequired) {
|
|
3389
|
+
required.push(parts[0]);
|
|
3390
|
+
hasRequiredFields = true;
|
|
3391
|
+
}
|
|
3392
|
+
} else {
|
|
3393
|
+
const fieldName = parts[0];
|
|
3394
|
+
if (!properties[fieldName]) {
|
|
3395
|
+
properties[fieldName] = { type: "object", properties: {} };
|
|
3396
|
+
}
|
|
3397
|
+
const nestedObj = properties[fieldName];
|
|
3398
|
+
if (!nestedObj.properties) nestedObj.properties = {};
|
|
3399
|
+
const deepPath = parts.slice(1).join(".");
|
|
3400
|
+
nestedObj.properties[deepPath] = schemaTypeToJsonSchema(schemaType);
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
const schema = { type: "object", properties };
|
|
3404
|
+
if (required.length) schema.required = required;
|
|
3405
|
+
return { schema, required: hasRequiredFields };
|
|
3406
|
+
}
|
|
3407
|
+
function schemaTypeToJsonSchema(schemaType) {
|
|
3408
|
+
const result = {};
|
|
3409
|
+
const instance = schemaType.instance;
|
|
3410
|
+
const options = schemaType.options || {};
|
|
3411
|
+
if (instance === "String") {
|
|
3412
|
+
result.type = "string";
|
|
3413
|
+
if (typeof options.minlength === "number") result.minLength = options.minlength;
|
|
3414
|
+
if (typeof options.maxlength === "number") result.maxLength = options.maxlength;
|
|
3415
|
+
if (options.match instanceof RegExp) result.pattern = options.match.source;
|
|
3416
|
+
if (options.enum && Array.isArray(options.enum)) result.enum = options.enum;
|
|
3417
|
+
} else if (instance === "Number") {
|
|
3418
|
+
result.type = "number";
|
|
3419
|
+
if (typeof options.min === "number") result.minimum = options.min;
|
|
3420
|
+
if (typeof options.max === "number") result.maximum = options.max;
|
|
3421
|
+
} else if (instance === "Boolean") {
|
|
3422
|
+
result.type = "boolean";
|
|
3423
|
+
} else if (instance === "Date") {
|
|
3424
|
+
result.type = "string";
|
|
3425
|
+
result.format = "date-time";
|
|
3426
|
+
} else if (instance === "ObjectId" || instance === "ObjectID") {
|
|
3427
|
+
result.type = "string";
|
|
3428
|
+
result.pattern = "^[0-9a-fA-F]{24}$";
|
|
3429
|
+
} else if (instance === "Array") {
|
|
3430
|
+
result.type = "array";
|
|
3431
|
+
result.items = { type: "string" };
|
|
3432
|
+
} else {
|
|
3433
|
+
result.type = "object";
|
|
3434
|
+
result.additionalProperties = true;
|
|
3435
|
+
}
|
|
3436
|
+
return result;
|
|
2610
3437
|
}
|
|
2611
3438
|
function buildJsonSchemaForUpdate(createJson, options) {
|
|
2612
3439
|
const clone = JSON.parse(JSON.stringify(createJson));
|
|
@@ -2627,6 +3454,9 @@ function buildJsonSchemaForUpdate(createJson, options) {
|
|
|
2627
3454
|
if (options?.strictAdditionalProperties === true) {
|
|
2628
3455
|
clone.additionalProperties = false;
|
|
2629
3456
|
}
|
|
3457
|
+
if (options?.update?.requireAtLeastOne === true) {
|
|
3458
|
+
clone.minProperties = 1;
|
|
3459
|
+
}
|
|
2630
3460
|
return clone;
|
|
2631
3461
|
}
|
|
2632
3462
|
function buildJsonSchemaForQuery(_tree, options) {
|
|
@@ -2670,21 +3500,26 @@ var QueryParser = class {
|
|
|
2670
3500
|
size: "$size",
|
|
2671
3501
|
type: "$type"
|
|
2672
3502
|
};
|
|
2673
|
-
/**
|
|
2674
|
-
* Dangerous MongoDB operators that should never be accepted from user input
|
|
2675
|
-
* Security: Prevent NoSQL injection attacks
|
|
2676
|
-
*/
|
|
2677
3503
|
dangerousOperators;
|
|
2678
3504
|
/**
|
|
2679
|
-
* Regex
|
|
3505
|
+
* Regex patterns that can cause catastrophic backtracking (ReDoS attacks)
|
|
3506
|
+
* Detects:
|
|
3507
|
+
* - Quantifiers: {n,m}
|
|
3508
|
+
* - Possessive quantifiers: *+, ++, ?+
|
|
3509
|
+
* - Nested quantifiers: (a+)+, (a*)*
|
|
3510
|
+
* - Backreferences: \1, \2, etc.
|
|
3511
|
+
* - Complex character classes: [...]...[...]
|
|
2680
3512
|
*/
|
|
2681
|
-
dangerousRegexPatterns = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(
|
|
3513
|
+
dangerousRegexPatterns = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?\:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
|
|
2682
3514
|
constructor(options = {}) {
|
|
2683
3515
|
this.options = {
|
|
2684
3516
|
maxRegexLength: options.maxRegexLength ?? 500,
|
|
2685
3517
|
maxSearchLength: options.maxSearchLength ?? 200,
|
|
2686
3518
|
maxFilterDepth: options.maxFilterDepth ?? 10,
|
|
2687
|
-
|
|
3519
|
+
maxLimit: options.maxLimit ?? 1e3,
|
|
3520
|
+
additionalDangerousOperators: options.additionalDangerousOperators ?? [],
|
|
3521
|
+
enableLookups: options.enableLookups ?? true,
|
|
3522
|
+
enableAggregations: options.enableAggregations ?? false
|
|
2688
3523
|
};
|
|
2689
3524
|
this.dangerousOperators = [
|
|
2690
3525
|
"$where",
|
|
@@ -2695,9 +3530,16 @@ var QueryParser = class {
|
|
|
2695
3530
|
];
|
|
2696
3531
|
}
|
|
2697
3532
|
/**
|
|
2698
|
-
* Parse query parameters into MongoDB query format
|
|
3533
|
+
* Parse URL query parameters into MongoDB query format
|
|
3534
|
+
*
|
|
3535
|
+
* @example
|
|
3536
|
+
* ```typescript
|
|
3537
|
+
* // URL: ?status=active&lookup[department][foreignField]=slug&sort=-createdAt&page=1
|
|
3538
|
+
* const query = parser.parse(req.query);
|
|
3539
|
+
* // Returns: { filters: {...}, lookups: [...], sort: {...}, page: 1 }
|
|
3540
|
+
* ```
|
|
2699
3541
|
*/
|
|
2700
|
-
|
|
3542
|
+
parse(query) {
|
|
2701
3543
|
const {
|
|
2702
3544
|
page,
|
|
2703
3545
|
limit = 20,
|
|
@@ -2706,15 +3548,35 @@ var QueryParser = class {
|
|
|
2706
3548
|
search,
|
|
2707
3549
|
after,
|
|
2708
3550
|
cursor,
|
|
3551
|
+
select,
|
|
3552
|
+
lookup: lookup2,
|
|
3553
|
+
aggregate: aggregate2,
|
|
2709
3554
|
...filters
|
|
2710
3555
|
} = query || {};
|
|
3556
|
+
let parsedLimit = parseInt(String(limit), 10);
|
|
3557
|
+
if (isNaN(parsedLimit) || parsedLimit < 1) {
|
|
3558
|
+
parsedLimit = 20;
|
|
3559
|
+
}
|
|
3560
|
+
if (parsedLimit > this.options.maxLimit) {
|
|
3561
|
+
console.warn(`[mongokit] Limit ${parsedLimit} exceeds maximum ${this.options.maxLimit}, capping to max`);
|
|
3562
|
+
parsedLimit = this.options.maxLimit;
|
|
3563
|
+
}
|
|
2711
3564
|
const parsed = {
|
|
2712
3565
|
filters: this._parseFilters(filters),
|
|
2713
|
-
limit:
|
|
3566
|
+
limit: parsedLimit,
|
|
2714
3567
|
sort: this._parseSort(sort),
|
|
2715
3568
|
populate,
|
|
2716
3569
|
search: this._sanitizeSearch(search)
|
|
2717
3570
|
};
|
|
3571
|
+
if (select) {
|
|
3572
|
+
parsed.select = this._parseSelect(select);
|
|
3573
|
+
}
|
|
3574
|
+
if (this.options.enableLookups && lookup2) {
|
|
3575
|
+
parsed.lookups = this._parseLookups(lookup2);
|
|
3576
|
+
}
|
|
3577
|
+
if (this.options.enableAggregations && aggregate2) {
|
|
3578
|
+
parsed.aggregation = this._parseAggregation(aggregate2);
|
|
3579
|
+
}
|
|
2718
3580
|
if (after || cursor) {
|
|
2719
3581
|
parsed.after = after || cursor;
|
|
2720
3582
|
} else if (page !== void 0) {
|
|
@@ -2729,29 +3591,161 @@ var QueryParser = class {
|
|
|
2729
3591
|
parsed.filters = this._enhanceWithBetween(parsed.filters);
|
|
2730
3592
|
return parsed;
|
|
2731
3593
|
}
|
|
3594
|
+
// ============================================================
|
|
3595
|
+
// LOOKUP PARSING (NEW)
|
|
3596
|
+
// ============================================================
|
|
2732
3597
|
/**
|
|
2733
|
-
* Parse
|
|
2734
|
-
*
|
|
2735
|
-
*
|
|
3598
|
+
* Parse lookup configurations from URL parameters
|
|
3599
|
+
*
|
|
3600
|
+
* Supported formats:
|
|
3601
|
+
* 1. Simple: ?lookup[department]=slug
|
|
3602
|
+
* → Join with 'departments' collection on slug field
|
|
3603
|
+
*
|
|
3604
|
+
* 2. Detailed: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug
|
|
3605
|
+
* → Full control over join configuration
|
|
3606
|
+
*
|
|
3607
|
+
* 3. Multiple: ?lookup[department]=slug&lookup[category]=categorySlug
|
|
3608
|
+
* → Multiple lookups
|
|
3609
|
+
*
|
|
3610
|
+
* @example
|
|
3611
|
+
* ```typescript
|
|
3612
|
+
* // URL: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug&lookup[department][single]=true
|
|
3613
|
+
* const lookups = parser._parseLookups({
|
|
3614
|
+
* department: { localField: 'deptSlug', foreignField: 'slug', single: 'true' }
|
|
3615
|
+
* });
|
|
3616
|
+
* // Returns: [{ from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true }]
|
|
3617
|
+
* ```
|
|
2736
3618
|
*/
|
|
2737
|
-
|
|
2738
|
-
if (!
|
|
2739
|
-
|
|
2740
|
-
const
|
|
2741
|
-
const
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
3619
|
+
_parseLookups(lookup2) {
|
|
3620
|
+
if (!lookup2 || typeof lookup2 !== "object") return [];
|
|
3621
|
+
const lookups = [];
|
|
3622
|
+
const lookupObj = lookup2;
|
|
3623
|
+
for (const [collectionName, config] of Object.entries(lookupObj)) {
|
|
3624
|
+
try {
|
|
3625
|
+
const lookupConfig = this._parseSingleLookup(collectionName, config);
|
|
3626
|
+
if (lookupConfig) {
|
|
3627
|
+
lookups.push(lookupConfig);
|
|
3628
|
+
}
|
|
3629
|
+
} catch (error) {
|
|
3630
|
+
console.warn(`[mongokit] Invalid lookup config for ${collectionName}:`, error);
|
|
2747
3631
|
}
|
|
2748
3632
|
}
|
|
2749
|
-
return
|
|
3633
|
+
return lookups;
|
|
3634
|
+
}
|
|
3635
|
+
/**
|
|
3636
|
+
* Parse a single lookup configuration
|
|
3637
|
+
*/
|
|
3638
|
+
_parseSingleLookup(collectionName, config) {
|
|
3639
|
+
if (!config) return null;
|
|
3640
|
+
if (typeof config === "string") {
|
|
3641
|
+
return {
|
|
3642
|
+
from: this._pluralize(collectionName),
|
|
3643
|
+
localField: `${collectionName}${this._capitalize(config)}`,
|
|
3644
|
+
foreignField: config,
|
|
3645
|
+
as: collectionName,
|
|
3646
|
+
single: true
|
|
3647
|
+
};
|
|
3648
|
+
}
|
|
3649
|
+
if (typeof config === "object" && config !== null) {
|
|
3650
|
+
const opts = config;
|
|
3651
|
+
const from = opts.from || this._pluralize(collectionName);
|
|
3652
|
+
const localField = opts.localField;
|
|
3653
|
+
const foreignField = opts.foreignField;
|
|
3654
|
+
if (!localField || !foreignField) {
|
|
3655
|
+
console.warn(`[mongokit] Lookup requires localField and foreignField for ${collectionName}`);
|
|
3656
|
+
return null;
|
|
3657
|
+
}
|
|
3658
|
+
return {
|
|
3659
|
+
from,
|
|
3660
|
+
localField,
|
|
3661
|
+
foreignField,
|
|
3662
|
+
as: opts.as || collectionName,
|
|
3663
|
+
single: opts.single === true || opts.single === "true",
|
|
3664
|
+
...opts.pipeline && Array.isArray(opts.pipeline) ? { pipeline: opts.pipeline } : {}
|
|
3665
|
+
};
|
|
3666
|
+
}
|
|
3667
|
+
return null;
|
|
3668
|
+
}
|
|
3669
|
+
// ============================================================
|
|
3670
|
+
// AGGREGATION PARSING (ADVANCED)
|
|
3671
|
+
// ============================================================
|
|
3672
|
+
/**
|
|
3673
|
+
* Parse aggregation pipeline from URL (advanced feature)
|
|
3674
|
+
*
|
|
3675
|
+
* @example
|
|
3676
|
+
* ```typescript
|
|
3677
|
+
* // URL: ?aggregate[group][_id]=$status&aggregate[group][count]=$sum:1
|
|
3678
|
+
* const pipeline = parser._parseAggregation({
|
|
3679
|
+
* group: { _id: '$status', count: '$sum:1' }
|
|
3680
|
+
* });
|
|
3681
|
+
* ```
|
|
3682
|
+
*/
|
|
3683
|
+
_parseAggregation(aggregate2) {
|
|
3684
|
+
if (!aggregate2 || typeof aggregate2 !== "object") return void 0;
|
|
3685
|
+
const pipeline = [];
|
|
3686
|
+
const aggObj = aggregate2;
|
|
3687
|
+
for (const [stage, config] of Object.entries(aggObj)) {
|
|
3688
|
+
try {
|
|
3689
|
+
if (stage === "group" && typeof config === "object") {
|
|
3690
|
+
pipeline.push({ $group: config });
|
|
3691
|
+
} else if (stage === "match" && typeof config === "object") {
|
|
3692
|
+
const sanitizedMatch = this._sanitizeMatchConfig(config);
|
|
3693
|
+
if (Object.keys(sanitizedMatch).length > 0) {
|
|
3694
|
+
pipeline.push({ $match: sanitizedMatch });
|
|
3695
|
+
}
|
|
3696
|
+
} else if (stage === "sort" && typeof config === "object") {
|
|
3697
|
+
pipeline.push({ $sort: config });
|
|
3698
|
+
} else if (stage === "project" && typeof config === "object") {
|
|
3699
|
+
pipeline.push({ $project: config });
|
|
3700
|
+
}
|
|
3701
|
+
} catch (error) {
|
|
3702
|
+
console.warn(`[mongokit] Invalid aggregation stage ${stage}:`, error);
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
return pipeline.length > 0 ? pipeline : void 0;
|
|
3706
|
+
}
|
|
3707
|
+
// ============================================================
|
|
3708
|
+
// SELECT/PROJECT PARSING
|
|
3709
|
+
// ============================================================
|
|
3710
|
+
/**
|
|
3711
|
+
* Parse select/project fields
|
|
3712
|
+
*
|
|
3713
|
+
* @example
|
|
3714
|
+
* ```typescript
|
|
3715
|
+
* // URL: ?select=name,email,-password
|
|
3716
|
+
* // Returns: { name: 1, email: 1, password: 0 }
|
|
3717
|
+
* ```
|
|
3718
|
+
*/
|
|
3719
|
+
_parseSelect(select) {
|
|
3720
|
+
if (!select) return void 0;
|
|
3721
|
+
if (typeof select === "string") {
|
|
3722
|
+
const projection = {};
|
|
3723
|
+
const fields = select.split(",").map((f) => f.trim());
|
|
3724
|
+
for (const field of fields) {
|
|
3725
|
+
if (field.startsWith("-")) {
|
|
3726
|
+
projection[field.substring(1)] = 0;
|
|
3727
|
+
} else {
|
|
3728
|
+
projection[field] = 1;
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3731
|
+
return projection;
|
|
3732
|
+
}
|
|
3733
|
+
if (typeof select === "object" && select !== null) {
|
|
3734
|
+
return select;
|
|
3735
|
+
}
|
|
3736
|
+
return void 0;
|
|
2750
3737
|
}
|
|
3738
|
+
// ============================================================
|
|
3739
|
+
// FILTER PARSING (Enhanced from original)
|
|
3740
|
+
// ============================================================
|
|
2751
3741
|
/**
|
|
2752
|
-
* Parse
|
|
3742
|
+
* Parse filter parameters
|
|
2753
3743
|
*/
|
|
2754
|
-
_parseFilters(filters) {
|
|
3744
|
+
_parseFilters(filters, depth = 0) {
|
|
3745
|
+
if (depth > this.options.maxFilterDepth) {
|
|
3746
|
+
console.warn(`[mongokit] Filter depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating`);
|
|
3747
|
+
return {};
|
|
3748
|
+
}
|
|
2755
3749
|
const parsedFilters = {};
|
|
2756
3750
|
const regexFields = {};
|
|
2757
3751
|
for (const [key, value] of Object.entries(filters)) {
|
|
@@ -2759,7 +3753,7 @@ var QueryParser = class {
|
|
|
2759
3753
|
console.warn(`[mongokit] Blocked dangerous operator: ${key}`);
|
|
2760
3754
|
continue;
|
|
2761
3755
|
}
|
|
2762
|
-
if (["page", "limit", "sort", "populate", "search", "select", "lean", "includeDeleted"].includes(key)) {
|
|
3756
|
+
if (["page", "limit", "sort", "populate", "search", "select", "lean", "includeDeleted", "lookup", "aggregate"].includes(key)) {
|
|
2763
3757
|
continue;
|
|
2764
3758
|
}
|
|
2765
3759
|
const operatorMatch = key.match(/^(.+)\[(.+)\]$/);
|
|
@@ -2773,7 +3767,7 @@ var QueryParser = class {
|
|
|
2773
3767
|
continue;
|
|
2774
3768
|
}
|
|
2775
3769
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
2776
|
-
this._handleBracketSyntax(key, value, parsedFilters);
|
|
3770
|
+
this._handleBracketSyntax(key, value, parsedFilters, depth + 1);
|
|
2777
3771
|
} else {
|
|
2778
3772
|
parsedFilters[key] = this._convertValue(value);
|
|
2779
3773
|
}
|
|
@@ -2785,6 +3779,9 @@ var QueryParser = class {
|
|
|
2785
3779
|
*/
|
|
2786
3780
|
_handleOperatorSyntax(filters, regexFields, operatorMatch, value) {
|
|
2787
3781
|
const [, field, operator] = operatorMatch;
|
|
3782
|
+
if (value === "" || value === null || value === void 0) {
|
|
3783
|
+
return;
|
|
3784
|
+
}
|
|
2788
3785
|
if (operator.toLowerCase() === "options" && regexFields[field]) {
|
|
2789
3786
|
const fieldValue = filters[field];
|
|
2790
3787
|
if (typeof fieldValue === "object" && fieldValue !== null && "$regex" in fieldValue) {
|
|
@@ -2802,18 +3799,18 @@ var QueryParser = class {
|
|
|
2802
3799
|
}
|
|
2803
3800
|
const mongoOperator = this._toMongoOperator(operator);
|
|
2804
3801
|
if (this.dangerousOperators.includes(mongoOperator)) {
|
|
2805
|
-
console.warn(`[mongokit] Blocked dangerous operator
|
|
3802
|
+
console.warn(`[mongokit] Blocked dangerous operator: ${mongoOperator}`);
|
|
2806
3803
|
return;
|
|
2807
3804
|
}
|
|
2808
3805
|
if (mongoOperator === "$eq") {
|
|
2809
3806
|
filters[field] = value;
|
|
2810
3807
|
} else if (mongoOperator === "$regex") {
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
filters[field] = {};
|
|
3808
|
+
const safeRegex = this._createSafeRegex(value);
|
|
3809
|
+
if (safeRegex) {
|
|
3810
|
+
filters[field] = { $regex: safeRegex };
|
|
3811
|
+
regexFields[field] = true;
|
|
2816
3812
|
}
|
|
3813
|
+
} else {
|
|
2817
3814
|
let processedValue;
|
|
2818
3815
|
const op = operator.toLowerCase();
|
|
2819
3816
|
if (["gt", "gte", "lt", "lte", "size"].includes(op)) {
|
|
@@ -2824,17 +3821,25 @@ var QueryParser = class {
|
|
|
2824
3821
|
} else {
|
|
2825
3822
|
processedValue = this._convertValue(value);
|
|
2826
3823
|
}
|
|
3824
|
+
if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) {
|
|
3825
|
+
filters[field] = {};
|
|
3826
|
+
}
|
|
2827
3827
|
filters[field][mongoOperator] = processedValue;
|
|
2828
3828
|
}
|
|
2829
3829
|
}
|
|
2830
3830
|
/**
|
|
2831
3831
|
* Handle bracket syntax with object value
|
|
2832
3832
|
*/
|
|
2833
|
-
_handleBracketSyntax(field, operators, parsedFilters) {
|
|
3833
|
+
_handleBracketSyntax(field, operators, parsedFilters, depth = 0) {
|
|
3834
|
+
if (depth > this.options.maxFilterDepth) {
|
|
3835
|
+
console.warn(`[mongokit] Nested filter depth exceeds maximum, skipping field: ${field}`);
|
|
3836
|
+
return;
|
|
3837
|
+
}
|
|
2834
3838
|
if (!parsedFilters[field]) {
|
|
2835
3839
|
parsedFilters[field] = {};
|
|
2836
3840
|
}
|
|
2837
3841
|
for (const [operator, value] of Object.entries(operators)) {
|
|
3842
|
+
if (value === "" || value === null || value === void 0) continue;
|
|
2838
3843
|
if (operator === "between") {
|
|
2839
3844
|
parsedFilters[field].between = value;
|
|
2840
3845
|
continue;
|
|
@@ -2847,7 +3852,7 @@ var QueryParser = class {
|
|
|
2847
3852
|
if (isNaN(processedValue)) continue;
|
|
2848
3853
|
} else if (operator === "in" || operator === "nin") {
|
|
2849
3854
|
processedValue = Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim());
|
|
2850
|
-
} else if (operator === "like" || operator === "contains") {
|
|
3855
|
+
} else if (operator === "like" || operator === "contains" || operator === "regex") {
|
|
2851
3856
|
const safeRegex = this._createSafeRegex(value);
|
|
2852
3857
|
if (!safeRegex) continue;
|
|
2853
3858
|
processedValue = safeRegex;
|
|
@@ -2857,31 +3862,40 @@ var QueryParser = class {
|
|
|
2857
3862
|
parsedFilters[field][mongoOperator] = processedValue;
|
|
2858
3863
|
}
|
|
2859
3864
|
}
|
|
3865
|
+
if (typeof parsedFilters[field] === "object" && Object.keys(parsedFilters[field]).length === 0) {
|
|
3866
|
+
delete parsedFilters[field];
|
|
3867
|
+
}
|
|
3868
|
+
}
|
|
3869
|
+
// ============================================================
|
|
3870
|
+
// UTILITY METHODS
|
|
3871
|
+
// ============================================================
|
|
3872
|
+
_parseSort(sort) {
|
|
3873
|
+
if (!sort) return void 0;
|
|
3874
|
+
if (typeof sort === "object") return sort;
|
|
3875
|
+
const sortObj = {};
|
|
3876
|
+
const fields = sort.split(",").map((s) => s.trim());
|
|
3877
|
+
for (const field of fields) {
|
|
3878
|
+
if (field.startsWith("-")) {
|
|
3879
|
+
sortObj[field.substring(1)] = -1;
|
|
3880
|
+
} else {
|
|
3881
|
+
sortObj[field] = 1;
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
return sortObj;
|
|
2860
3885
|
}
|
|
2861
|
-
/**
|
|
2862
|
-
* Convert operator to MongoDB format
|
|
2863
|
-
*/
|
|
2864
3886
|
_toMongoOperator(operator) {
|
|
2865
3887
|
const op = operator.toLowerCase();
|
|
2866
3888
|
return op.startsWith("$") ? op : "$" + op;
|
|
2867
3889
|
}
|
|
2868
|
-
/**
|
|
2869
|
-
* Create a safe regex pattern with protection against ReDoS attacks
|
|
2870
|
-
* @param pattern - The pattern string from user input
|
|
2871
|
-
* @param flags - Regex flags (default: 'i' for case-insensitive)
|
|
2872
|
-
* @returns A safe RegExp or null if pattern is invalid/dangerous
|
|
2873
|
-
*/
|
|
2874
3890
|
_createSafeRegex(pattern, flags = "i") {
|
|
2875
|
-
if (pattern === null || pattern === void 0)
|
|
2876
|
-
return null;
|
|
2877
|
-
}
|
|
3891
|
+
if (pattern === null || pattern === void 0) return null;
|
|
2878
3892
|
const patternStr = String(pattern);
|
|
2879
3893
|
if (patternStr.length > this.options.maxRegexLength) {
|
|
2880
|
-
console.warn(`[mongokit] Regex pattern too long
|
|
3894
|
+
console.warn(`[mongokit] Regex pattern too long, truncating`);
|
|
2881
3895
|
return new RegExp(this._escapeRegex(patternStr.substring(0, this.options.maxRegexLength)), flags);
|
|
2882
3896
|
}
|
|
2883
3897
|
if (this.dangerousRegexPatterns.test(patternStr)) {
|
|
2884
|
-
console.warn("[mongokit] Potentially dangerous regex pattern
|
|
3898
|
+
console.warn("[mongokit] Potentially dangerous regex pattern, escaping");
|
|
2885
3899
|
return new RegExp(this._escapeRegex(patternStr), flags);
|
|
2886
3900
|
}
|
|
2887
3901
|
try {
|
|
@@ -2890,34 +3904,45 @@ var QueryParser = class {
|
|
|
2890
3904
|
return new RegExp(this._escapeRegex(patternStr), flags);
|
|
2891
3905
|
}
|
|
2892
3906
|
}
|
|
2893
|
-
/**
|
|
2894
|
-
* Escape special regex characters for literal matching
|
|
2895
|
-
*/
|
|
2896
3907
|
_escapeRegex(str) {
|
|
2897
3908
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2898
3909
|
}
|
|
2899
3910
|
/**
|
|
2900
|
-
* Sanitize
|
|
2901
|
-
*
|
|
2902
|
-
* @returns Sanitized search string or undefined
|
|
3911
|
+
* Sanitize $match configuration to prevent dangerous operators
|
|
3912
|
+
* Recursively filters out operators like $where, $function, $accumulator
|
|
2903
3913
|
*/
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
3914
|
+
_sanitizeMatchConfig(config) {
|
|
3915
|
+
const sanitized = {};
|
|
3916
|
+
for (const [key, value] of Object.entries(config)) {
|
|
3917
|
+
if (this.dangerousOperators.includes(key)) {
|
|
3918
|
+
console.warn(`[mongokit] Blocked dangerous operator in aggregation: ${key}`);
|
|
3919
|
+
continue;
|
|
3920
|
+
}
|
|
3921
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
3922
|
+
sanitized[key] = this._sanitizeMatchConfig(value);
|
|
3923
|
+
} else if (Array.isArray(value)) {
|
|
3924
|
+
sanitized[key] = value.map((item) => {
|
|
3925
|
+
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
3926
|
+
return this._sanitizeMatchConfig(item);
|
|
3927
|
+
}
|
|
3928
|
+
return item;
|
|
3929
|
+
});
|
|
3930
|
+
} else {
|
|
3931
|
+
sanitized[key] = value;
|
|
3932
|
+
}
|
|
2907
3933
|
}
|
|
3934
|
+
return sanitized;
|
|
3935
|
+
}
|
|
3936
|
+
_sanitizeSearch(search) {
|
|
3937
|
+
if (search === null || search === void 0 || search === "") return void 0;
|
|
2908
3938
|
let searchStr = String(search).trim();
|
|
2909
|
-
if (!searchStr)
|
|
2910
|
-
return void 0;
|
|
2911
|
-
}
|
|
3939
|
+
if (!searchStr) return void 0;
|
|
2912
3940
|
if (searchStr.length > this.options.maxSearchLength) {
|
|
2913
|
-
console.warn(`[mongokit] Search query too long
|
|
3941
|
+
console.warn(`[mongokit] Search query too long, truncating`);
|
|
2914
3942
|
searchStr = searchStr.substring(0, this.options.maxSearchLength);
|
|
2915
3943
|
}
|
|
2916
3944
|
return searchStr;
|
|
2917
3945
|
}
|
|
2918
|
-
/**
|
|
2919
|
-
* Convert values based on operator type
|
|
2920
|
-
*/
|
|
2921
3946
|
_convertValue(value) {
|
|
2922
3947
|
if (value === null || value === void 0) return value;
|
|
2923
3948
|
if (Array.isArray(value)) return value.map((v) => this._convertValue(v));
|
|
@@ -2925,14 +3950,11 @@ var QueryParser = class {
|
|
|
2925
3950
|
const stringValue = String(value);
|
|
2926
3951
|
if (stringValue === "true") return true;
|
|
2927
3952
|
if (stringValue === "false") return false;
|
|
2928
|
-
if (
|
|
3953
|
+
if (mongoose.Types.ObjectId.isValid(stringValue) && stringValue.length === 24) {
|
|
2929
3954
|
return stringValue;
|
|
2930
3955
|
}
|
|
2931
3956
|
return stringValue;
|
|
2932
3957
|
}
|
|
2933
|
-
/**
|
|
2934
|
-
* Parse $or conditions
|
|
2935
|
-
*/
|
|
2936
3958
|
_parseOr(query) {
|
|
2937
3959
|
const orArray = [];
|
|
2938
3960
|
const raw = query?.or || query?.OR || query?.$or;
|
|
@@ -2940,14 +3962,11 @@ var QueryParser = class {
|
|
|
2940
3962
|
const items = Array.isArray(raw) ? raw : typeof raw === "object" ? Object.values(raw) : [];
|
|
2941
3963
|
for (const item of items) {
|
|
2942
3964
|
if (typeof item === "object" && item) {
|
|
2943
|
-
orArray.push(this._parseFilters(item));
|
|
3965
|
+
orArray.push(this._parseFilters(item, 1));
|
|
2944
3966
|
}
|
|
2945
3967
|
}
|
|
2946
3968
|
return orArray.length ? orArray : void 0;
|
|
2947
3969
|
}
|
|
2948
|
-
/**
|
|
2949
|
-
* Enhance filters with between operator
|
|
2950
|
-
*/
|
|
2951
3970
|
_enhanceWithBetween(filters) {
|
|
2952
3971
|
const output = { ...filters };
|
|
2953
3972
|
for (const [key, value] of Object.entries(filters || {})) {
|
|
@@ -2964,9 +3983,16 @@ var QueryParser = class {
|
|
|
2964
3983
|
}
|
|
2965
3984
|
return output;
|
|
2966
3985
|
}
|
|
3986
|
+
// String helpers
|
|
3987
|
+
_pluralize(str) {
|
|
3988
|
+
if (str.endsWith("y")) return str.slice(0, -1) + "ies";
|
|
3989
|
+
if (str.endsWith("s")) return str;
|
|
3990
|
+
return str + "s";
|
|
3991
|
+
}
|
|
3992
|
+
_capitalize(str) {
|
|
3993
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
3994
|
+
}
|
|
2967
3995
|
};
|
|
2968
|
-
var defaultQueryParser = new QueryParser();
|
|
2969
|
-
var queryParser_default = defaultQueryParser;
|
|
2970
3996
|
|
|
2971
3997
|
// src/actions/index.ts
|
|
2972
3998
|
var actions_exports = {};
|
|
@@ -3022,4 +4048,4 @@ var index_default = Repository;
|
|
|
3022
4048
|
* ```
|
|
3023
4049
|
*/
|
|
3024
4050
|
|
|
3025
|
-
export { PaginationEngine, QueryParser, Repository, actions_exports as actions, aggregateHelpersPlugin, auditLogPlugin, autoInject, batchOperationsPlugin, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, createError, createFieldPreset, createMemoryCache, createRepository, index_default as default, fieldFilterPlugin, filterResponseData, getFieldsForUser, getImmutableFields, getMongooseProjection, getSystemManagedFields, immutableField, isFieldUpdateAllowed, methodRegistryPlugin, mongoOperationsPlugin,
|
|
4051
|
+
export { AggregationBuilder, LookupBuilder, PaginationEngine, QueryParser, Repository, actions_exports as actions, aggregateHelpersPlugin, auditLogPlugin, autoInject, batchOperationsPlugin, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, createError, createFieldPreset, createMemoryCache, createRepository, index_default as default, fieldFilterPlugin, filterResponseData, getFieldsForUser, getImmutableFields, getMongooseProjection, getSystemManagedFields, immutableField, isFieldUpdateAllowed, methodRegistryPlugin, mongoOperationsPlugin, requireField, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validateUpdateBody, validationChainPlugin };
|