@classytic/mongokit 3.0.6 → 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-CkwbNdpJ.d.ts → index-3Nkm_Brq.d.ts} +170 -3
- package/dist/index.d.ts +997 -8
- package/dist/index.js +1202 -197
- package/dist/{queryParser-Do3SgsyJ.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-DDDYo18H.d.ts → types-CrSoCuWu.d.ts} +11 -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,11 +1770,153 @@ 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
1918
|
async withTransaction(callback, options = {}) {
|
|
1112
|
-
const session = await
|
|
1919
|
+
const session = await mongoose.startSession();
|
|
1113
1920
|
let started = false;
|
|
1114
1921
|
try {
|
|
1115
1922
|
session.startTransaction();
|
|
@@ -1193,11 +2000,11 @@ var Repository = class {
|
|
|
1193
2000
|
* Handle errors with proper HTTP status codes
|
|
1194
2001
|
*/
|
|
1195
2002
|
_handleError(error) {
|
|
1196
|
-
if (error instanceof
|
|
2003
|
+
if (error instanceof mongoose.Error.ValidationError) {
|
|
1197
2004
|
const messages = Object.values(error.errors).map((err) => err.message);
|
|
1198
2005
|
return createError(400, `Validation Error: ${messages.join(", ")}`);
|
|
1199
2006
|
}
|
|
1200
|
-
if (error instanceof
|
|
2007
|
+
if (error instanceof mongoose.Error.CastError) {
|
|
1201
2008
|
return createError(400, `Invalid ${error.path}: ${error.value}`);
|
|
1202
2009
|
}
|
|
1203
2010
|
if (error.status && error.message) return error;
|
|
@@ -2228,7 +3035,7 @@ function cascadePlugin(options) {
|
|
|
2228
3035
|
}
|
|
2229
3036
|
const isSoftDelete = context.softDeleted === true;
|
|
2230
3037
|
const cascadeDelete = async (relation) => {
|
|
2231
|
-
const RelatedModel =
|
|
3038
|
+
const RelatedModel = mongoose.models[relation.model];
|
|
2232
3039
|
if (!RelatedModel) {
|
|
2233
3040
|
logger?.warn?.(`Cascade delete skipped: model '${relation.model}' not found`, {
|
|
2234
3041
|
parentModel: context.model,
|
|
@@ -2319,7 +3126,7 @@ function cascadePlugin(options) {
|
|
|
2319
3126
|
}
|
|
2320
3127
|
const isSoftDelete = context.softDeleted === true;
|
|
2321
3128
|
const cascadeDeleteMany = async (relation) => {
|
|
2322
|
-
const RelatedModel =
|
|
3129
|
+
const RelatedModel = mongoose.models[relation.model];
|
|
2323
3130
|
if (!RelatedModel) {
|
|
2324
3131
|
logger?.warn?.(`Cascade deleteMany skipped: model '${relation.model}' not found`, {
|
|
2325
3132
|
parentModel: context.model
|
|
@@ -2435,24 +3242,15 @@ function createMemoryCache(maxEntries = 1e3) {
|
|
|
2435
3242
|
}
|
|
2436
3243
|
};
|
|
2437
3244
|
}
|
|
2438
|
-
function isMongooseSchema(value) {
|
|
2439
|
-
return value instanceof mongoose4.Schema;
|
|
2440
|
-
}
|
|
2441
|
-
function isPlainObject(value) {
|
|
2442
|
-
return Object.prototype.toString.call(value) === "[object Object]";
|
|
2443
|
-
}
|
|
2444
|
-
function isObjectIdType(t) {
|
|
2445
|
-
return t === mongoose4.Schema.Types.ObjectId || t === mongoose4.Types.ObjectId;
|
|
2446
|
-
}
|
|
2447
3245
|
function buildCrudSchemasFromMongooseSchema(mongooseSchema, options = {}) {
|
|
2448
|
-
const
|
|
2449
|
-
const jsonCreate = buildJsonSchemaForCreate(tree, options);
|
|
3246
|
+
const jsonCreate = buildJsonSchemaFromPaths(mongooseSchema, options);
|
|
2450
3247
|
const jsonUpdate = buildJsonSchemaForUpdate(jsonCreate, options);
|
|
2451
3248
|
const jsonParams = {
|
|
2452
3249
|
type: "object",
|
|
2453
3250
|
properties: { id: { type: "string", pattern: "^[0-9a-fA-F]{24}$" } },
|
|
2454
3251
|
required: ["id"]
|
|
2455
3252
|
};
|
|
3253
|
+
const tree = mongooseSchema?.obj || {};
|
|
2456
3254
|
const jsonQuery = buildJsonSchemaForQuery(tree, options);
|
|
2457
3255
|
return { createBody: jsonCreate, updateBody: jsonUpdate, params: jsonParams, listQuery: jsonQuery };
|
|
2458
3256
|
}
|
|
@@ -2506,88 +3304,37 @@ function validateUpdateBody(body = {}, options = {}) {
|
|
|
2506
3304
|
violations
|
|
2507
3305
|
};
|
|
2508
3306
|
}
|
|
2509
|
-
function
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
const
|
|
2518
|
-
if (
|
|
2519
|
-
|
|
2520
|
-
}
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
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);
|
|
2525
3327
|
}
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
if (typedDef.type === Date) {
|
|
2532
|
-
const mode = options?.dateAs || "datetime";
|
|
2533
|
-
return mode === "date" ? { type: "string", format: "date" } : { type: "string", format: "date-time" };
|
|
2534
|
-
}
|
|
2535
|
-
if (typedDef.type === Map || typedDef.type === mongoose4.Schema.Types.Map) {
|
|
2536
|
-
const ofSchema = jsonTypeFor(typedDef.of || String, options, seen);
|
|
2537
|
-
return { type: "object", additionalProperties: ofSchema };
|
|
2538
|
-
}
|
|
2539
|
-
if (typedDef.type === mongoose4.Schema.Types.Mixed) {
|
|
2540
|
-
return { type: "object", additionalProperties: true };
|
|
2541
|
-
}
|
|
2542
|
-
if (isObjectIdType(typedDef.type)) {
|
|
2543
|
-
return { type: "string", pattern: "^[0-9a-fA-F]{24}$" };
|
|
2544
|
-
}
|
|
2545
|
-
if (isMongooseSchema(typedDef.type)) {
|
|
2546
|
-
const obj = typedDef.type.obj;
|
|
2547
|
-
if (obj && typeof obj === "object") {
|
|
2548
|
-
if (seen.has(obj)) return { type: "object", additionalProperties: true };
|
|
2549
|
-
seen.add(obj);
|
|
2550
|
-
return convertTreeToJsonSchema(obj, options, seen);
|
|
3328
|
+
} else {
|
|
3329
|
+
const nestedSchema = buildNestedJsonSchema(fieldPaths, rootField);
|
|
3330
|
+
properties[rootField] = nestedSchema.schema;
|
|
3331
|
+
if (nestedSchema.required) {
|
|
3332
|
+
required.push(rootField);
|
|
2551
3333
|
}
|
|
2552
3334
|
}
|
|
2553
3335
|
}
|
|
2554
|
-
if (def === String) return { type: "string" };
|
|
2555
|
-
if (def === Number) return { type: "number" };
|
|
2556
|
-
if (def === Boolean) return { type: "boolean" };
|
|
2557
|
-
if (def === Date) {
|
|
2558
|
-
const mode = options?.dateAs || "datetime";
|
|
2559
|
-
return mode === "date" ? { type: "string", format: "date" } : { type: "string", format: "date-time" };
|
|
2560
|
-
}
|
|
2561
|
-
if (isObjectIdType(def)) return { type: "string", pattern: "^[0-9a-fA-F]{24}$" };
|
|
2562
|
-
if (isPlainObject(def)) {
|
|
2563
|
-
if (seen.has(def)) return { type: "object", additionalProperties: true };
|
|
2564
|
-
seen.add(def);
|
|
2565
|
-
return convertTreeToJsonSchema(def, options, seen);
|
|
2566
|
-
}
|
|
2567
|
-
return {};
|
|
2568
|
-
}
|
|
2569
|
-
function convertTreeToJsonSchema(tree, options, seen = /* @__PURE__ */ new WeakSet()) {
|
|
2570
|
-
if (!tree || typeof tree !== "object") {
|
|
2571
|
-
return { type: "object", properties: {} };
|
|
2572
|
-
}
|
|
2573
|
-
if (seen.has(tree)) {
|
|
2574
|
-
return { type: "object", additionalProperties: true };
|
|
2575
|
-
}
|
|
2576
|
-
seen.add(tree);
|
|
2577
|
-
const properties = {};
|
|
2578
|
-
const required = [];
|
|
2579
|
-
for (const [key, val] of Object.entries(tree || {})) {
|
|
2580
|
-
if (key === "__v" || key === "_id" || key === "id") continue;
|
|
2581
|
-
const cfg = isPlainObject(val) && "type" in val ? val : { };
|
|
2582
|
-
properties[key] = jsonTypeFor(val, options, seen);
|
|
2583
|
-
if (cfg.required === true) required.push(key);
|
|
2584
|
-
}
|
|
2585
3336
|
const schema = { type: "object", properties };
|
|
2586
3337
|
if (required.length) schema.required = required;
|
|
2587
|
-
return schema;
|
|
2588
|
-
}
|
|
2589
|
-
function buildJsonSchemaForCreate(tree, options) {
|
|
2590
|
-
const base = convertTreeToJsonSchema(tree, options, /* @__PURE__ */ new WeakSet());
|
|
2591
3338
|
const fieldsToOmit = /* @__PURE__ */ new Set(["createdAt", "updatedAt", "__v"]);
|
|
2592
3339
|
(options?.create?.omitFields || []).forEach((f) => fieldsToOmit.add(f));
|
|
2593
3340
|
const fieldRules = options?.fieldRules || {};
|
|
@@ -2597,37 +3344,96 @@ function buildJsonSchemaForCreate(tree, options) {
|
|
|
2597
3344
|
}
|
|
2598
3345
|
});
|
|
2599
3346
|
fieldsToOmit.forEach((field) => {
|
|
2600
|
-
if (
|
|
2601
|
-
delete
|
|
3347
|
+
if (schema.properties?.[field]) {
|
|
3348
|
+
delete schema.properties[field];
|
|
2602
3349
|
}
|
|
2603
|
-
if (
|
|
2604
|
-
|
|
3350
|
+
if (schema.required) {
|
|
3351
|
+
schema.required = schema.required.filter((k) => k !== field);
|
|
2605
3352
|
}
|
|
2606
3353
|
});
|
|
2607
3354
|
const reqOv = options?.create?.requiredOverrides || {};
|
|
2608
3355
|
const optOv = options?.create?.optionalOverrides || {};
|
|
2609
|
-
|
|
3356
|
+
schema.required = schema.required || [];
|
|
2610
3357
|
for (const [k, v] of Object.entries(reqOv)) {
|
|
2611
|
-
if (v && !
|
|
3358
|
+
if (v && !schema.required.includes(k)) schema.required.push(k);
|
|
2612
3359
|
}
|
|
2613
3360
|
for (const [k, v] of Object.entries(optOv)) {
|
|
2614
|
-
if (v &&
|
|
3361
|
+
if (v && schema.required) schema.required = schema.required.filter((x) => x !== k);
|
|
2615
3362
|
}
|
|
2616
3363
|
Object.entries(fieldRules).forEach(([field, rules]) => {
|
|
2617
|
-
if (rules.optional &&
|
|
2618
|
-
|
|
3364
|
+
if (rules.optional && schema.required) {
|
|
3365
|
+
schema.required = schema.required.filter((x) => x !== field);
|
|
2619
3366
|
}
|
|
2620
3367
|
});
|
|
2621
3368
|
const schemaOverrides = options?.create?.schemaOverrides || {};
|
|
2622
3369
|
for (const [k, override] of Object.entries(schemaOverrides)) {
|
|
2623
|
-
if (
|
|
2624
|
-
|
|
3370
|
+
if (schema.properties?.[k]) {
|
|
3371
|
+
schema.properties[k] = override;
|
|
2625
3372
|
}
|
|
2626
3373
|
}
|
|
2627
3374
|
if (options?.strictAdditionalProperties === true) {
|
|
2628
|
-
|
|
3375
|
+
schema.additionalProperties = false;
|
|
3376
|
+
}
|
|
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
|
+
}
|
|
2629
3402
|
}
|
|
2630
|
-
|
|
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;
|
|
2631
3437
|
}
|
|
2632
3438
|
function buildJsonSchemaForUpdate(createJson, options) {
|
|
2633
3439
|
const clone = JSON.parse(JSON.stringify(createJson));
|
|
@@ -2648,6 +3454,9 @@ function buildJsonSchemaForUpdate(createJson, options) {
|
|
|
2648
3454
|
if (options?.strictAdditionalProperties === true) {
|
|
2649
3455
|
clone.additionalProperties = false;
|
|
2650
3456
|
}
|
|
3457
|
+
if (options?.update?.requireAtLeastOne === true) {
|
|
3458
|
+
clone.minProperties = 1;
|
|
3459
|
+
}
|
|
2651
3460
|
return clone;
|
|
2652
3461
|
}
|
|
2653
3462
|
function buildJsonSchemaForQuery(_tree, options) {
|
|
@@ -2691,21 +3500,26 @@ var QueryParser = class {
|
|
|
2691
3500
|
size: "$size",
|
|
2692
3501
|
type: "$type"
|
|
2693
3502
|
};
|
|
2694
|
-
/**
|
|
2695
|
-
* Dangerous MongoDB operators that should never be accepted from user input
|
|
2696
|
-
* Security: Prevent NoSQL injection attacks
|
|
2697
|
-
*/
|
|
2698
3503
|
dangerousOperators;
|
|
2699
3504
|
/**
|
|
2700
|
-
* 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: [...]...[...]
|
|
2701
3512
|
*/
|
|
2702
|
-
dangerousRegexPatterns = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(
|
|
3513
|
+
dangerousRegexPatterns = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?\:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
|
|
2703
3514
|
constructor(options = {}) {
|
|
2704
3515
|
this.options = {
|
|
2705
3516
|
maxRegexLength: options.maxRegexLength ?? 500,
|
|
2706
3517
|
maxSearchLength: options.maxSearchLength ?? 200,
|
|
2707
3518
|
maxFilterDepth: options.maxFilterDepth ?? 10,
|
|
2708
|
-
|
|
3519
|
+
maxLimit: options.maxLimit ?? 1e3,
|
|
3520
|
+
additionalDangerousOperators: options.additionalDangerousOperators ?? [],
|
|
3521
|
+
enableLookups: options.enableLookups ?? true,
|
|
3522
|
+
enableAggregations: options.enableAggregations ?? false
|
|
2709
3523
|
};
|
|
2710
3524
|
this.dangerousOperators = [
|
|
2711
3525
|
"$where",
|
|
@@ -2716,9 +3530,16 @@ var QueryParser = class {
|
|
|
2716
3530
|
];
|
|
2717
3531
|
}
|
|
2718
3532
|
/**
|
|
2719
|
-
* 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
|
+
* ```
|
|
2720
3541
|
*/
|
|
2721
|
-
|
|
3542
|
+
parse(query) {
|
|
2722
3543
|
const {
|
|
2723
3544
|
page,
|
|
2724
3545
|
limit = 20,
|
|
@@ -2727,15 +3548,35 @@ var QueryParser = class {
|
|
|
2727
3548
|
search,
|
|
2728
3549
|
after,
|
|
2729
3550
|
cursor,
|
|
3551
|
+
select,
|
|
3552
|
+
lookup: lookup2,
|
|
3553
|
+
aggregate: aggregate2,
|
|
2730
3554
|
...filters
|
|
2731
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
|
+
}
|
|
2732
3564
|
const parsed = {
|
|
2733
3565
|
filters: this._parseFilters(filters),
|
|
2734
|
-
limit:
|
|
3566
|
+
limit: parsedLimit,
|
|
2735
3567
|
sort: this._parseSort(sort),
|
|
2736
3568
|
populate,
|
|
2737
3569
|
search: this._sanitizeSearch(search)
|
|
2738
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
|
+
}
|
|
2739
3580
|
if (after || cursor) {
|
|
2740
3581
|
parsed.after = after || cursor;
|
|
2741
3582
|
} else if (page !== void 0) {
|
|
@@ -2750,29 +3591,161 @@ var QueryParser = class {
|
|
|
2750
3591
|
parsed.filters = this._enhanceWithBetween(parsed.filters);
|
|
2751
3592
|
return parsed;
|
|
2752
3593
|
}
|
|
3594
|
+
// ============================================================
|
|
3595
|
+
// LOOKUP PARSING (NEW)
|
|
3596
|
+
// ============================================================
|
|
2753
3597
|
/**
|
|
2754
|
-
* Parse
|
|
2755
|
-
*
|
|
2756
|
-
*
|
|
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
|
+
* ```
|
|
2757
3618
|
*/
|
|
2758
|
-
|
|
2759
|
-
if (!
|
|
2760
|
-
|
|
2761
|
-
const
|
|
2762
|
-
const
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
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);
|
|
2768
3631
|
}
|
|
2769
3632
|
}
|
|
2770
|
-
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;
|
|
2771
3668
|
}
|
|
3669
|
+
// ============================================================
|
|
3670
|
+
// AGGREGATION PARSING (ADVANCED)
|
|
3671
|
+
// ============================================================
|
|
2772
3672
|
/**
|
|
2773
|
-
* Parse
|
|
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
|
+
* ```
|
|
2774
3682
|
*/
|
|
2775
|
-
|
|
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;
|
|
3737
|
+
}
|
|
3738
|
+
// ============================================================
|
|
3739
|
+
// FILTER PARSING (Enhanced from original)
|
|
3740
|
+
// ============================================================
|
|
3741
|
+
/**
|
|
3742
|
+
* Parse filter parameters
|
|
3743
|
+
*/
|
|
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
|
+
}
|
|
2776
3749
|
const parsedFilters = {};
|
|
2777
3750
|
const regexFields = {};
|
|
2778
3751
|
for (const [key, value] of Object.entries(filters)) {
|
|
@@ -2780,7 +3753,7 @@ var QueryParser = class {
|
|
|
2780
3753
|
console.warn(`[mongokit] Blocked dangerous operator: ${key}`);
|
|
2781
3754
|
continue;
|
|
2782
3755
|
}
|
|
2783
|
-
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)) {
|
|
2784
3757
|
continue;
|
|
2785
3758
|
}
|
|
2786
3759
|
const operatorMatch = key.match(/^(.+)\[(.+)\]$/);
|
|
@@ -2794,7 +3767,7 @@ var QueryParser = class {
|
|
|
2794
3767
|
continue;
|
|
2795
3768
|
}
|
|
2796
3769
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
2797
|
-
this._handleBracketSyntax(key, value, parsedFilters);
|
|
3770
|
+
this._handleBracketSyntax(key, value, parsedFilters, depth + 1);
|
|
2798
3771
|
} else {
|
|
2799
3772
|
parsedFilters[key] = this._convertValue(value);
|
|
2800
3773
|
}
|
|
@@ -2806,6 +3779,9 @@ var QueryParser = class {
|
|
|
2806
3779
|
*/
|
|
2807
3780
|
_handleOperatorSyntax(filters, regexFields, operatorMatch, value) {
|
|
2808
3781
|
const [, field, operator] = operatorMatch;
|
|
3782
|
+
if (value === "" || value === null || value === void 0) {
|
|
3783
|
+
return;
|
|
3784
|
+
}
|
|
2809
3785
|
if (operator.toLowerCase() === "options" && regexFields[field]) {
|
|
2810
3786
|
const fieldValue = filters[field];
|
|
2811
3787
|
if (typeof fieldValue === "object" && fieldValue !== null && "$regex" in fieldValue) {
|
|
@@ -2823,18 +3799,18 @@ var QueryParser = class {
|
|
|
2823
3799
|
}
|
|
2824
3800
|
const mongoOperator = this._toMongoOperator(operator);
|
|
2825
3801
|
if (this.dangerousOperators.includes(mongoOperator)) {
|
|
2826
|
-
console.warn(`[mongokit] Blocked dangerous operator
|
|
3802
|
+
console.warn(`[mongokit] Blocked dangerous operator: ${mongoOperator}`);
|
|
2827
3803
|
return;
|
|
2828
3804
|
}
|
|
2829
3805
|
if (mongoOperator === "$eq") {
|
|
2830
3806
|
filters[field] = value;
|
|
2831
3807
|
} else if (mongoOperator === "$regex") {
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
filters[field] = {};
|
|
3808
|
+
const safeRegex = this._createSafeRegex(value);
|
|
3809
|
+
if (safeRegex) {
|
|
3810
|
+
filters[field] = { $regex: safeRegex };
|
|
3811
|
+
regexFields[field] = true;
|
|
2837
3812
|
}
|
|
3813
|
+
} else {
|
|
2838
3814
|
let processedValue;
|
|
2839
3815
|
const op = operator.toLowerCase();
|
|
2840
3816
|
if (["gt", "gte", "lt", "lte", "size"].includes(op)) {
|
|
@@ -2845,17 +3821,25 @@ var QueryParser = class {
|
|
|
2845
3821
|
} else {
|
|
2846
3822
|
processedValue = this._convertValue(value);
|
|
2847
3823
|
}
|
|
3824
|
+
if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) {
|
|
3825
|
+
filters[field] = {};
|
|
3826
|
+
}
|
|
2848
3827
|
filters[field][mongoOperator] = processedValue;
|
|
2849
3828
|
}
|
|
2850
3829
|
}
|
|
2851
3830
|
/**
|
|
2852
3831
|
* Handle bracket syntax with object value
|
|
2853
3832
|
*/
|
|
2854
|
-
_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
|
+
}
|
|
2855
3838
|
if (!parsedFilters[field]) {
|
|
2856
3839
|
parsedFilters[field] = {};
|
|
2857
3840
|
}
|
|
2858
3841
|
for (const [operator, value] of Object.entries(operators)) {
|
|
3842
|
+
if (value === "" || value === null || value === void 0) continue;
|
|
2859
3843
|
if (operator === "between") {
|
|
2860
3844
|
parsedFilters[field].between = value;
|
|
2861
3845
|
continue;
|
|
@@ -2868,7 +3852,7 @@ var QueryParser = class {
|
|
|
2868
3852
|
if (isNaN(processedValue)) continue;
|
|
2869
3853
|
} else if (operator === "in" || operator === "nin") {
|
|
2870
3854
|
processedValue = Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim());
|
|
2871
|
-
} else if (operator === "like" || operator === "contains") {
|
|
3855
|
+
} else if (operator === "like" || operator === "contains" || operator === "regex") {
|
|
2872
3856
|
const safeRegex = this._createSafeRegex(value);
|
|
2873
3857
|
if (!safeRegex) continue;
|
|
2874
3858
|
processedValue = safeRegex;
|
|
@@ -2878,31 +3862,40 @@ var QueryParser = class {
|
|
|
2878
3862
|
parsedFilters[field][mongoOperator] = processedValue;
|
|
2879
3863
|
}
|
|
2880
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;
|
|
2881
3885
|
}
|
|
2882
|
-
/**
|
|
2883
|
-
* Convert operator to MongoDB format
|
|
2884
|
-
*/
|
|
2885
3886
|
_toMongoOperator(operator) {
|
|
2886
3887
|
const op = operator.toLowerCase();
|
|
2887
3888
|
return op.startsWith("$") ? op : "$" + op;
|
|
2888
3889
|
}
|
|
2889
|
-
/**
|
|
2890
|
-
* Create a safe regex pattern with protection against ReDoS attacks
|
|
2891
|
-
* @param pattern - The pattern string from user input
|
|
2892
|
-
* @param flags - Regex flags (default: 'i' for case-insensitive)
|
|
2893
|
-
* @returns A safe RegExp or null if pattern is invalid/dangerous
|
|
2894
|
-
*/
|
|
2895
3890
|
_createSafeRegex(pattern, flags = "i") {
|
|
2896
|
-
if (pattern === null || pattern === void 0)
|
|
2897
|
-
return null;
|
|
2898
|
-
}
|
|
3891
|
+
if (pattern === null || pattern === void 0) return null;
|
|
2899
3892
|
const patternStr = String(pattern);
|
|
2900
3893
|
if (patternStr.length > this.options.maxRegexLength) {
|
|
2901
|
-
console.warn(`[mongokit] Regex pattern too long
|
|
3894
|
+
console.warn(`[mongokit] Regex pattern too long, truncating`);
|
|
2902
3895
|
return new RegExp(this._escapeRegex(patternStr.substring(0, this.options.maxRegexLength)), flags);
|
|
2903
3896
|
}
|
|
2904
3897
|
if (this.dangerousRegexPatterns.test(patternStr)) {
|
|
2905
|
-
console.warn("[mongokit] Potentially dangerous regex pattern
|
|
3898
|
+
console.warn("[mongokit] Potentially dangerous regex pattern, escaping");
|
|
2906
3899
|
return new RegExp(this._escapeRegex(patternStr), flags);
|
|
2907
3900
|
}
|
|
2908
3901
|
try {
|
|
@@ -2911,34 +3904,45 @@ var QueryParser = class {
|
|
|
2911
3904
|
return new RegExp(this._escapeRegex(patternStr), flags);
|
|
2912
3905
|
}
|
|
2913
3906
|
}
|
|
2914
|
-
/**
|
|
2915
|
-
* Escape special regex characters for literal matching
|
|
2916
|
-
*/
|
|
2917
3907
|
_escapeRegex(str) {
|
|
2918
3908
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2919
3909
|
}
|
|
2920
3910
|
/**
|
|
2921
|
-
* Sanitize
|
|
2922
|
-
*
|
|
2923
|
-
* @returns Sanitized search string or undefined
|
|
3911
|
+
* Sanitize $match configuration to prevent dangerous operators
|
|
3912
|
+
* Recursively filters out operators like $where, $function, $accumulator
|
|
2924
3913
|
*/
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
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
|
+
}
|
|
2928
3933
|
}
|
|
3934
|
+
return sanitized;
|
|
3935
|
+
}
|
|
3936
|
+
_sanitizeSearch(search) {
|
|
3937
|
+
if (search === null || search === void 0 || search === "") return void 0;
|
|
2929
3938
|
let searchStr = String(search).trim();
|
|
2930
|
-
if (!searchStr)
|
|
2931
|
-
return void 0;
|
|
2932
|
-
}
|
|
3939
|
+
if (!searchStr) return void 0;
|
|
2933
3940
|
if (searchStr.length > this.options.maxSearchLength) {
|
|
2934
|
-
console.warn(`[mongokit] Search query too long
|
|
3941
|
+
console.warn(`[mongokit] Search query too long, truncating`);
|
|
2935
3942
|
searchStr = searchStr.substring(0, this.options.maxSearchLength);
|
|
2936
3943
|
}
|
|
2937
3944
|
return searchStr;
|
|
2938
3945
|
}
|
|
2939
|
-
/**
|
|
2940
|
-
* Convert values based on operator type
|
|
2941
|
-
*/
|
|
2942
3946
|
_convertValue(value) {
|
|
2943
3947
|
if (value === null || value === void 0) return value;
|
|
2944
3948
|
if (Array.isArray(value)) return value.map((v) => this._convertValue(v));
|
|
@@ -2946,14 +3950,11 @@ var QueryParser = class {
|
|
|
2946
3950
|
const stringValue = String(value);
|
|
2947
3951
|
if (stringValue === "true") return true;
|
|
2948
3952
|
if (stringValue === "false") return false;
|
|
2949
|
-
if (
|
|
3953
|
+
if (mongoose.Types.ObjectId.isValid(stringValue) && stringValue.length === 24) {
|
|
2950
3954
|
return stringValue;
|
|
2951
3955
|
}
|
|
2952
3956
|
return stringValue;
|
|
2953
3957
|
}
|
|
2954
|
-
/**
|
|
2955
|
-
* Parse $or conditions
|
|
2956
|
-
*/
|
|
2957
3958
|
_parseOr(query) {
|
|
2958
3959
|
const orArray = [];
|
|
2959
3960
|
const raw = query?.or || query?.OR || query?.$or;
|
|
@@ -2961,14 +3962,11 @@ var QueryParser = class {
|
|
|
2961
3962
|
const items = Array.isArray(raw) ? raw : typeof raw === "object" ? Object.values(raw) : [];
|
|
2962
3963
|
for (const item of items) {
|
|
2963
3964
|
if (typeof item === "object" && item) {
|
|
2964
|
-
orArray.push(this._parseFilters(item));
|
|
3965
|
+
orArray.push(this._parseFilters(item, 1));
|
|
2965
3966
|
}
|
|
2966
3967
|
}
|
|
2967
3968
|
return orArray.length ? orArray : void 0;
|
|
2968
3969
|
}
|
|
2969
|
-
/**
|
|
2970
|
-
* Enhance filters with between operator
|
|
2971
|
-
*/
|
|
2972
3970
|
_enhanceWithBetween(filters) {
|
|
2973
3971
|
const output = { ...filters };
|
|
2974
3972
|
for (const [key, value] of Object.entries(filters || {})) {
|
|
@@ -2985,9 +3983,16 @@ var QueryParser = class {
|
|
|
2985
3983
|
}
|
|
2986
3984
|
return output;
|
|
2987
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
|
+
}
|
|
2988
3995
|
};
|
|
2989
|
-
var defaultQueryParser = new QueryParser();
|
|
2990
|
-
var queryParser_default = defaultQueryParser;
|
|
2991
3996
|
|
|
2992
3997
|
// src/actions/index.ts
|
|
2993
3998
|
var actions_exports = {};
|
|
@@ -3043,4 +4048,4 @@ var index_default = Repository;
|
|
|
3043
4048
|
* ```
|
|
3044
4049
|
*/
|
|
3045
4050
|
|
|
3046
|
-
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 };
|