@classytic/mongokit 3.1.3 → 3.1.5

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 CHANGED
@@ -14,7 +14,7 @@
14
14
  - **Event-driven** - Pre/post hooks for all operations
15
15
  - **12 built-in plugins** - Caching, soft delete, validation, audit logs, and more
16
16
  - **TypeScript first** - Full type safety with discriminated unions
17
- - **194 passing tests** - Battle-tested and production-ready
17
+ - **410+ passing tests** - Battle-tested and production-ready
18
18
 
19
19
  ## Installation
20
20
 
@@ -476,6 +476,16 @@ GET /users?sort=-createdAt,name
476
476
 
477
477
  # Search (requires text index)
478
478
  GET /users?search=john
479
+
480
+ # Simple populate
481
+ GET /posts?populate=author,category
482
+
483
+ # Advanced populate with options
484
+ GET /posts?populate[author][select]=name,email
485
+ GET /posts?populate[author][match][active]=true
486
+ GET /posts?populate[comments][limit]=10
487
+ GET /posts?populate[comments][sort]=-createdAt
488
+ GET /posts?populate[author][populate][department][select]=name # Nested
479
489
  ```
480
490
 
481
491
  **Security features:**
@@ -483,6 +493,54 @@ GET /users?search=john
483
493
  - ReDoS protection for regex patterns
484
494
  - Max filter depth enforcement
485
495
  - Collection allowlists for lookups
496
+ - Populate path sanitization (blocks `$where`, `__proto__`, etc.)
497
+ - Max populate depth limit (default: 5)
498
+
499
+ ### Advanced Populate Options
500
+
501
+ QueryParser supports Mongoose populate options via URL query parameters:
502
+
503
+ ```typescript
504
+ import { QueryParser } from '@classytic/mongokit';
505
+
506
+ const parser = new QueryParser();
507
+
508
+ // Parse URL: /posts?populate[author][select]=name,email&populate[author][match][active]=true
509
+ const parsed = parser.parse(req.query);
510
+
511
+ // Use with Repository
512
+ const posts = await postRepo.getAll(
513
+ { filters: parsed.filters, page: parsed.page, limit: parsed.limit },
514
+ { populateOptions: parsed.populateOptions }
515
+ );
516
+ ```
517
+
518
+ **Supported populate options:**
519
+
520
+ | Option | URL Syntax | Description |
521
+ |--------|------------|-------------|
522
+ | `select` | `populate[path][select]=field1,field2` | Fields to include (space-separated in Mongoose) |
523
+ | `match` | `populate[path][match][field]=value` | Filter populated documents |
524
+ | `limit` | `populate[path][limit]=10` | Limit number of populated docs |
525
+ | `sort` | `populate[path][sort]=-createdAt` | Sort populated documents |
526
+ | `populate` | `populate[path][populate][nested][select]=field` | Nested populate (max depth: 5) |
527
+
528
+ **Example - Complex populate:**
529
+
530
+ ```typescript
531
+ // URL: /posts?populate[author][select]=name,avatar&populate[comments][limit]=5&populate[comments][sort]=-createdAt&populate[comments][match][approved]=true
532
+
533
+ const parsed = parser.parse(req.query);
534
+ // parsed.populateOptions = [
535
+ // { path: 'author', select: 'name avatar' },
536
+ // { path: 'comments', match: { approved: true }, options: { limit: 5, sort: { createdAt: -1 } } }
537
+ // ]
538
+
539
+ // Simple string populate still works
540
+ // URL: /posts?populate=author,category
541
+ // parsed.populate = 'author,category'
542
+ // parsed.populateOptions = undefined
543
+ ```
486
544
 
487
545
  ### JSON Schema Generation
488
546
 
package/dist/index.d.ts CHANGED
@@ -833,6 +833,7 @@ declare class Repository<TDoc = AnyDocument> {
833
833
  }, options?: {
834
834
  select?: SelectSpec;
835
835
  populate?: PopulateSpec;
836
+ populateOptions?: PopulateOptions[];
836
837
  lean?: boolean;
837
838
  session?: ClientSession;
838
839
  skipCache?: boolean;
@@ -1069,14 +1070,46 @@ declare class Repository<TDoc = AnyDocument> {
1069
1070
 
1070
1071
  type SortSpec = Record<string, 1 | -1>;
1071
1072
  type FilterQuery = Record<string, unknown>;
1073
+ /**
1074
+ * Mongoose-compatible populate option
1075
+ * Supports advanced populate with select, match, limit, sort, and nested populate
1076
+ *
1077
+ * @example
1078
+ * ```typescript
1079
+ * // URL: ?populate[author][select]=name,email&populate[author][match][active]=true
1080
+ * // Generates: { path: 'author', select: 'name email', match: { active: true } }
1081
+ * ```
1082
+ */
1083
+ interface PopulateOption {
1084
+ /** Field path to populate */
1085
+ path: string;
1086
+ /** Fields to select (space-separated) */
1087
+ select?: string;
1088
+ /** Filter conditions for populated documents */
1089
+ match?: Record<string, unknown>;
1090
+ /** Query options (limit, sort, skip) */
1091
+ options?: {
1092
+ limit?: number;
1093
+ sort?: SortSpec;
1094
+ skip?: number;
1095
+ };
1096
+ /** Nested populate configuration */
1097
+ populate?: PopulateOption;
1098
+ }
1072
1099
  /** Parsed query result with optional lookup configuration */
1073
1100
  interface ParsedQuery {
1074
1101
  /** MongoDB filter query */
1075
1102
  filters: FilterQuery;
1076
1103
  /** Sort specification */
1077
1104
  sort?: SortSpec;
1078
- /** Fields to populate (ObjectId-based) */
1105
+ /** Fields to populate (simple comma-separated string) */
1079
1106
  populate?: string;
1107
+ /**
1108
+ * Advanced populate options (Mongoose-compatible)
1109
+ * When this is set, `populate` will be undefined
1110
+ * @example [{ path: 'author', select: 'name email' }]
1111
+ */
1112
+ populateOptions?: PopulateOption[];
1080
1113
  /** Page number for offset pagination */
1081
1114
  page?: number;
1082
1115
  /** Cursor for keyset pagination */
@@ -1092,6 +1125,8 @@ interface ParsedQuery {
1092
1125
  /** Select/project fields */
1093
1126
  select?: Record<string, 0 | 1>;
1094
1127
  }
1128
+ /** Search mode for query parser */
1129
+ type SearchMode = 'text' | 'regex';
1095
1130
  interface QueryParserOptions {
1096
1131
  /** Maximum allowed regex pattern length (default: 500) */
1097
1132
  maxRegexLength?: number;
@@ -1107,6 +1142,18 @@ interface QueryParserOptions {
1107
1142
  enableLookups?: boolean;
1108
1143
  /** Enable aggregation parsing (default: false - requires explicit opt-in) */
1109
1144
  enableAggregations?: boolean;
1145
+ /**
1146
+ * Search mode (default: 'text')
1147
+ * - 'text': Uses MongoDB $text search (requires text index)
1148
+ * - 'regex': Uses $or with $regex across searchFields (no index required)
1149
+ */
1150
+ searchMode?: SearchMode;
1151
+ /**
1152
+ * Fields to search when searchMode is 'regex'
1153
+ * Required when searchMode is 'regex'
1154
+ * @example ['name', 'description', 'sku', 'tags']
1155
+ */
1156
+ searchFields?: string[];
1110
1157
  }
1111
1158
  /**
1112
1159
  * Modern Query Parser
@@ -1187,6 +1234,27 @@ declare class QueryParser {
1187
1234
  * ```
1188
1235
  */
1189
1236
  private _parseSelect;
1237
+ /**
1238
+ * Parse populate parameter - handles both simple string and advanced object format
1239
+ *
1240
+ * @example
1241
+ * ```typescript
1242
+ * // Simple: ?populate=author,category
1243
+ * // Returns: { simplePopulate: 'author,category', populateOptions: undefined }
1244
+ *
1245
+ * // Advanced: ?populate[author][select]=name,email
1246
+ * // Returns: { simplePopulate: undefined, populateOptions: [{ path: 'author', select: 'name email' }] }
1247
+ * ```
1248
+ */
1249
+ private _parsePopulate;
1250
+ /**
1251
+ * Parse a single populate configuration
1252
+ */
1253
+ private _parseSinglePopulate;
1254
+ /**
1255
+ * Convert populate match values (handles boolean strings, etc.)
1256
+ */
1257
+ private _convertPopulateMatch;
1190
1258
  /**
1191
1259
  * Parse filter parameters
1192
1260
  */
@@ -1209,6 +1277,20 @@ declare class QueryParser {
1209
1277
  */
1210
1278
  private _sanitizeMatchConfig;
1211
1279
  private _sanitizeSearch;
1280
+ /**
1281
+ * Build regex-based multi-field search filters
1282
+ * Creates an $or query with case-insensitive regex across all searchFields
1283
+ *
1284
+ * @example
1285
+ * // searchFields: ['name', 'description', 'sku']
1286
+ * // search: 'azure'
1287
+ * // Returns: [
1288
+ * // { name: { $regex: /azure/i } },
1289
+ * // { description: { $regex: /azure/i } },
1290
+ * // { sku: { $regex: /azure/i } }
1291
+ * // ]
1292
+ */
1293
+ private _buildRegexSearch;
1212
1294
  private _convertValue;
1213
1295
  private _parseOr;
1214
1296
  private _enhanceWithBetween;
@@ -1228,4 +1310,4 @@ declare class QueryParser {
1228
1310
  */
1229
1311
  declare function createRepository<TDoc>(Model: mongoose.Model<TDoc, any, any, any>, plugins?: PluginType[], paginationConfig?: PaginationConfig, options?: RepositoryOptions): Repository<TDoc>;
1230
1312
 
1231
- export { AggregatePaginationResult, AggregationBuilder, AnyDocument, type FilterQuery, HttpError, type IController, type IControllerResponse, type IRequestContext, type IResponseFormatter, KeysetPaginationResult, LookupBuilder, LookupOptions, ObjectId, OffsetPaginationResult, PaginationConfig, PaginationEngine, PaginationResult, type ParsedQuery, PluginType, PopulateSpec, QueryParser, type QueryParserOptions, Repository, RepositoryContext, RepositoryOptions, SelectSpec, SortSpec$2 as SortSpec, UpdateOptions, WithTransactionOptions, createRepository, Repository as default };
1313
+ export { AggregatePaginationResult, AggregationBuilder, AnyDocument, type FilterQuery, HttpError, type IController, type IControllerResponse, type IRequestContext, type IResponseFormatter, KeysetPaginationResult, LookupBuilder, LookupOptions, ObjectId, OffsetPaginationResult, PaginationConfig, PaginationEngine, PaginationResult, type ParsedQuery, PluginType, PopulateSpec, QueryParser, type QueryParserOptions, Repository, RepositoryContext, RepositoryOptions, type SearchMode, SelectSpec, SortSpec$2 as SortSpec, UpdateOptions, WithTransactionOptions, createRepository, Repository as default };
package/dist/index.js CHANGED
@@ -815,11 +815,12 @@ var Repository = class {
815
815
  const limit = params.limit || params.pagination?.limit || this._pagination.config.defaultLimit;
816
816
  let query = { ...filters };
817
817
  if (search) query.$text = { $search: search };
818
+ const populateSpec = options.populateOptions || context.populate || options.populate;
818
819
  const paginationOptions = {
819
820
  filters: query,
820
821
  sort: this._parseSort(sort),
821
822
  limit,
822
- populate: this._parsePopulate(context.populate || options.populate),
823
+ populate: this._parsePopulate(populateSpec),
823
824
  select: context.select || options.select,
824
825
  lean: context.lean ?? options.lean ?? true,
825
826
  session: options.session
@@ -1194,8 +1195,14 @@ var QueryParser = class {
1194
1195
  maxLimit: options.maxLimit ?? 1e3,
1195
1196
  additionalDangerousOperators: options.additionalDangerousOperators ?? [],
1196
1197
  enableLookups: options.enableLookups ?? true,
1197
- enableAggregations: options.enableAggregations ?? false
1198
+ enableAggregations: options.enableAggregations ?? false,
1199
+ searchMode: options.searchMode ?? "text",
1200
+ searchFields: options.searchFields
1198
1201
  };
1202
+ if (this.options.searchMode === "regex" && (!this.options.searchFields || this.options.searchFields.length === 0)) {
1203
+ console.warn('[mongokit] searchMode "regex" requires searchFields to be specified. Falling back to "text" mode.');
1204
+ this.options.searchMode = "text";
1205
+ }
1199
1206
  this.dangerousOperators = [
1200
1207
  "$where",
1201
1208
  "$function",
@@ -1236,13 +1243,34 @@ var QueryParser = class {
1236
1243
  console.warn(`[mongokit] Limit ${parsedLimit} exceeds maximum ${this.options.maxLimit}, capping to max`);
1237
1244
  parsedLimit = this.options.maxLimit;
1238
1245
  }
1246
+ const sanitizedSearch = this._sanitizeSearch(search);
1247
+ const { simplePopulate, populateOptions } = this._parsePopulate(populate);
1239
1248
  const parsed = {
1240
1249
  filters: this._parseFilters(filters),
1241
1250
  limit: parsedLimit,
1242
1251
  sort: this._parseSort(sort),
1243
- populate,
1244
- search: this._sanitizeSearch(search)
1252
+ populate: simplePopulate,
1253
+ populateOptions,
1254
+ search: sanitizedSearch
1245
1255
  };
1256
+ if (sanitizedSearch && this.options.searchMode === "regex" && this.options.searchFields) {
1257
+ const regexSearchFilters = this._buildRegexSearch(sanitizedSearch);
1258
+ if (regexSearchFilters) {
1259
+ if (parsed.filters.$or) {
1260
+ parsed.filters = {
1261
+ ...parsed.filters,
1262
+ $and: [
1263
+ { $or: parsed.filters.$or },
1264
+ { $or: regexSearchFilters }
1265
+ ]
1266
+ };
1267
+ delete parsed.filters.$or;
1268
+ } else {
1269
+ parsed.filters.$or = regexSearchFilters;
1270
+ }
1271
+ parsed.search = void 0;
1272
+ }
1273
+ }
1246
1274
  if (select) {
1247
1275
  parsed.select = this._parseSelect(select);
1248
1276
  }
@@ -1261,7 +1289,16 @@ var QueryParser = class {
1261
1289
  }
1262
1290
  const orGroup = this._parseOr(query);
1263
1291
  if (orGroup) {
1264
- parsed.filters = { ...parsed.filters, $or: orGroup };
1292
+ if (parsed.filters.$or) {
1293
+ const existingOr = parsed.filters.$or;
1294
+ delete parsed.filters.$or;
1295
+ parsed.filters.$and = [
1296
+ { $or: existingOr },
1297
+ { $or: orGroup }
1298
+ ];
1299
+ } else {
1300
+ parsed.filters.$or = orGroup;
1301
+ }
1265
1302
  }
1266
1303
  parsed.filters = this._enhanceWithBetween(parsed.filters);
1267
1304
  return parsed;
@@ -1411,6 +1448,117 @@ var QueryParser = class {
1411
1448
  return void 0;
1412
1449
  }
1413
1450
  // ============================================================
1451
+ // POPULATE PARSING
1452
+ // ============================================================
1453
+ /**
1454
+ * Parse populate parameter - handles both simple string and advanced object format
1455
+ *
1456
+ * @example
1457
+ * ```typescript
1458
+ * // Simple: ?populate=author,category
1459
+ * // Returns: { simplePopulate: 'author,category', populateOptions: undefined }
1460
+ *
1461
+ * // Advanced: ?populate[author][select]=name,email
1462
+ * // Returns: { simplePopulate: undefined, populateOptions: [{ path: 'author', select: 'name email' }] }
1463
+ * ```
1464
+ */
1465
+ _parsePopulate(populate) {
1466
+ if (!populate) {
1467
+ return {};
1468
+ }
1469
+ if (typeof populate === "string") {
1470
+ return { simplePopulate: populate };
1471
+ }
1472
+ if (typeof populate === "object" && populate !== null) {
1473
+ const populateObj = populate;
1474
+ if (Object.keys(populateObj).length === 0) {
1475
+ return {};
1476
+ }
1477
+ const populateOptions = [];
1478
+ for (const [path, config] of Object.entries(populateObj)) {
1479
+ if (path.startsWith("$") || this.dangerousOperators.includes(path)) {
1480
+ console.warn(`[mongokit] Blocked dangerous populate path: ${path}`);
1481
+ continue;
1482
+ }
1483
+ const option = this._parseSinglePopulate(path, config);
1484
+ if (option) {
1485
+ populateOptions.push(option);
1486
+ }
1487
+ }
1488
+ return populateOptions.length > 0 ? { populateOptions } : {};
1489
+ }
1490
+ return {};
1491
+ }
1492
+ /**
1493
+ * Parse a single populate configuration
1494
+ */
1495
+ _parseSinglePopulate(path, config, depth = 0) {
1496
+ if (depth > 5) {
1497
+ console.warn(`[mongokit] Populate depth exceeds maximum (5), truncating at path: ${path}`);
1498
+ return { path };
1499
+ }
1500
+ if (typeof config === "string") {
1501
+ if (config === "true" || config === "1") {
1502
+ return { path };
1503
+ }
1504
+ return { path, select: config.split(",").join(" ") };
1505
+ }
1506
+ if (typeof config === "object" && config !== null) {
1507
+ const opts = config;
1508
+ const option = { path };
1509
+ if (opts.select && typeof opts.select === "string") {
1510
+ option.select = opts.select.split(",").map((s) => s.trim()).join(" ");
1511
+ }
1512
+ if (opts.match && typeof opts.match === "object") {
1513
+ option.match = this._convertPopulateMatch(opts.match);
1514
+ }
1515
+ if (opts.limit !== void 0) {
1516
+ const limit = parseInt(String(opts.limit), 10);
1517
+ if (!isNaN(limit) && limit > 0) {
1518
+ option.options = option.options || {};
1519
+ option.options.limit = limit;
1520
+ }
1521
+ }
1522
+ if (opts.sort && typeof opts.sort === "string") {
1523
+ const sortSpec = this._parseSort(opts.sort);
1524
+ if (sortSpec) {
1525
+ option.options = option.options || {};
1526
+ option.options.sort = sortSpec;
1527
+ }
1528
+ }
1529
+ if (opts.skip !== void 0) {
1530
+ const skip = parseInt(String(opts.skip), 10);
1531
+ if (!isNaN(skip) && skip >= 0) {
1532
+ option.options = option.options || {};
1533
+ option.options.skip = skip;
1534
+ }
1535
+ }
1536
+ if (opts.populate && typeof opts.populate === "object") {
1537
+ const nestedPopulate = opts.populate;
1538
+ const nestedEntries = Object.entries(nestedPopulate);
1539
+ if (nestedEntries.length > 0) {
1540
+ const [nestedPath, nestedConfig] = nestedEntries[0];
1541
+ const nestedOption = this._parseSinglePopulate(nestedPath, nestedConfig, depth + 1);
1542
+ if (nestedOption) {
1543
+ option.populate = nestedOption;
1544
+ }
1545
+ }
1546
+ }
1547
+ return option;
1548
+ }
1549
+ return null;
1550
+ }
1551
+ /**
1552
+ * Convert populate match values (handles boolean strings, etc.)
1553
+ */
1554
+ _convertPopulateMatch(match) {
1555
+ const converted = {};
1556
+ for (const [key, value] of Object.entries(match)) {
1557
+ converted[key] = this._convertValue(value);
1558
+ }
1559
+ return converted;
1560
+ }
1561
+ // ============================================================
1414
1562
  // FILTER PARSING (Enhanced from original)
1415
1563
  // ============================================================
1416
1564
  /**
@@ -1428,7 +1576,7 @@ var QueryParser = class {
1428
1576
  console.warn(`[mongokit] Blocked dangerous operator: ${key}`);
1429
1577
  continue;
1430
1578
  }
1431
- if (["page", "limit", "sort", "populate", "search", "select", "lean", "includeDeleted", "lookup", "aggregate"].includes(key)) {
1579
+ if (["page", "limit", "sort", "populate", "search", "select", "lean", "includeDeleted", "lookup", "aggregate", "or", "OR", "$or"].includes(key)) {
1432
1580
  continue;
1433
1581
  }
1434
1582
  const operatorMatch = key.match(/^(.+)\[(.+)\]$/);
@@ -1618,6 +1766,35 @@ var QueryParser = class {
1618
1766
  }
1619
1767
  return searchStr;
1620
1768
  }
1769
+ /**
1770
+ * Build regex-based multi-field search filters
1771
+ * Creates an $or query with case-insensitive regex across all searchFields
1772
+ *
1773
+ * @example
1774
+ * // searchFields: ['name', 'description', 'sku']
1775
+ * // search: 'azure'
1776
+ * // Returns: [
1777
+ * // { name: { $regex: /azure/i } },
1778
+ * // { description: { $regex: /azure/i } },
1779
+ * // { sku: { $regex: /azure/i } }
1780
+ * // ]
1781
+ */
1782
+ _buildRegexSearch(searchTerm) {
1783
+ if (!this.options.searchFields || this.options.searchFields.length === 0) {
1784
+ return null;
1785
+ }
1786
+ const safeRegex = this._createSafeRegex(searchTerm, "i");
1787
+ if (!safeRegex) {
1788
+ return null;
1789
+ }
1790
+ const orConditions = [];
1791
+ for (const field of this.options.searchFields) {
1792
+ orConditions.push({
1793
+ [field]: { $regex: safeRegex }
1794
+ });
1795
+ }
1796
+ return orConditions.length > 0 ? orConditions : null;
1797
+ }
1621
1798
  _convertValue(value) {
1622
1799
  if (value === null || value === void 0) return value;
1623
1800
  if (Array.isArray(value)) return value.map((v) => this._convertValue(v));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/mongokit",
3
- "version": "3.1.3",
3
+ "version": "3.1.5",
4
4
  "description": "Production-grade MongoDB repositories with zero dependencies - smart pagination, events, and plugins",
5
5
  "type": "module",
6
6
  "sideEffects": false,