@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/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import mongoose4 from 'mongoose';
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
- aggPipeline.push({
412
- $lookup: {
413
- from,
414
- localField,
415
- foreignField,
416
- as,
417
- ...pipeline.length > 0 ? { pipeline } : {}
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 mongoose4.Types.ObjectId) return value.toString();
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 mongoose4.Types.ObjectId) return "objectid";
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 mongoose4.Types.ObjectId(serialized);
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 hasExplicitSort = params.sort !== void 0;
1003
- const useKeyset = !hasPageParam && (hasCursorParam || hasExplicitSort);
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 mongoose4.startSession();
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 mongoose4.Error.ValidationError) {
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 mongoose4.Error.CastError) {
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 = mongoose4.models[relation.model];
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 = mongoose4.models[relation.model];
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 tree = mongooseSchema?.obj || {};
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 jsonTypeFor(def, options, seen) {
2510
- if (Array.isArray(def)) {
2511
- if (def[0] === mongoose4.Schema.Types.Mixed) {
2512
- return { type: "array", items: { type: "object", additionalProperties: true } };
2513
- }
2514
- return { type: "array", items: jsonTypeFor(def[0] ?? String, options, seen) };
2515
- }
2516
- if (isPlainObject(def) && "type" in def) {
2517
- const typedDef = def;
2518
- if (typedDef.enum && Array.isArray(typedDef.enum) && typedDef.enum.length) {
2519
- return { type: "string", enum: typedDef.enum.map(String) };
2520
- }
2521
- if (Array.isArray(typedDef.type)) {
2522
- const inner = typedDef.type[0] !== void 0 ? typedDef.type[0] : String;
2523
- if (inner === mongoose4.Schema.Types.Mixed) {
2524
- return { type: "array", items: { type: "object", additionalProperties: true } };
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
- return { type: "array", items: jsonTypeFor(inner, options, seen) };
2527
- }
2528
- if (typedDef.type === String) return { type: "string" };
2529
- if (typedDef.type === Number) return { type: "number" };
2530
- if (typedDef.type === Boolean) return { type: "boolean" };
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 (base.properties?.[field]) {
2601
- delete base.properties[field];
3347
+ if (schema.properties?.[field]) {
3348
+ delete schema.properties[field];
2602
3349
  }
2603
- if (base.required) {
2604
- base.required = base.required.filter((k) => k !== field);
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
- base.required = base.required || [];
3356
+ schema.required = schema.required || [];
2610
3357
  for (const [k, v] of Object.entries(reqOv)) {
2611
- if (v && !base.required.includes(k)) base.required.push(k);
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 && base.required) base.required = base.required.filter((x) => x !== k);
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 && base.required) {
2618
- base.required = base.required.filter((x) => x !== field);
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 (base.properties?.[k]) {
2624
- base.properties[k] = override;
3370
+ if (schema.properties?.[k]) {
3371
+ schema.properties[k] = override;
2625
3372
  }
2626
3373
  }
2627
3374
  if (options?.strictAdditionalProperties === true) {
2628
- base.additionalProperties = false;
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
- return base;
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 pattern characters that can cause catastrophic backtracking (ReDoS)
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,]+\}|\*\+|\+\+|\?\+|(\([^)]*\))\1|\(\?[^)]*\)|[\[\]].*[\[\]])/;
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
- additionalDangerousOperators: options.additionalDangerousOperators ?? []
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
- parseQuery(query) {
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: parseInt(String(limit), 10),
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 sort parameter
2755
- * Converts string like '-createdAt' to { createdAt: -1 }
2756
- * Handles multiple sorts: '-createdAt,name' → { createdAt: -1, name: 1 }
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
- _parseSort(sort) {
2759
- if (!sort) return void 0;
2760
- if (typeof sort === "object") return sort;
2761
- const sortObj = {};
2762
- const fields = sort.split(",").map((s) => s.trim());
2763
- for (const field of fields) {
2764
- if (field.startsWith("-")) {
2765
- sortObj[field.substring(1)] = -1;
2766
- } else {
2767
- sortObj[field] = 1;
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 sortObj;
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 standard filter parameter (filter[field]=value)
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
- _parseFilters(filters) {
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 in field[${operator}]: ${mongoOperator}`);
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
- filters[field] = { $regex: value };
2833
- regexFields[field] = true;
2834
- } else {
2835
- if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) {
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 (${patternStr.length} > ${this.options.maxRegexLength}), truncating`);
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 detected, escaping");
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 text search query for MongoDB $text search
2922
- * @param search - Raw search input from user
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
- _sanitizeSearch(search) {
2926
- if (search === null || search === void 0 || search === "") {
2927
- return void 0;
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 (${searchStr.length} > ${this.options.maxSearchLength}), truncating`);
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 (mongoose4.Types.ObjectId.isValid(stringValue) && stringValue.length === 24) {
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, queryParser_default as queryParser, requireField, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validateUpdateBody, validationChainPlugin };
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 };