@classytic/mongokit 3.1.4 → 3.1.6

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
@@ -777,6 +777,7 @@ declare class Repository<TDoc = AnyDocument> {
777
777
  getById(id: string | ObjectId, options?: {
778
778
  select?: SelectSpec;
779
779
  populate?: PopulateSpec;
780
+ populateOptions?: PopulateOptions[];
780
781
  lean?: boolean;
781
782
  session?: ClientSession;
782
783
  throwOnNotFound?: boolean;
@@ -789,6 +790,7 @@ declare class Repository<TDoc = AnyDocument> {
789
790
  getByQuery(query: Record<string, unknown>, options?: {
790
791
  select?: SelectSpec;
791
792
  populate?: PopulateSpec;
793
+ populateOptions?: PopulateOptions[];
792
794
  lean?: boolean;
793
795
  session?: ClientSession;
794
796
  throwOnNotFound?: boolean;
@@ -830,9 +832,12 @@ declare class Repository<TDoc = AnyDocument> {
830
832
  };
831
833
  limit?: number;
832
834
  search?: string;
835
+ /** Advanced populate options (from QueryParser or Arc's BaseController) */
836
+ populateOptions?: PopulateOptions[];
833
837
  }, options?: {
834
838
  select?: SelectSpec;
835
839
  populate?: PopulateSpec;
840
+ populateOptions?: PopulateOptions[];
836
841
  lean?: boolean;
837
842
  session?: ClientSession;
838
843
  skipCache?: boolean;
@@ -1069,14 +1074,46 @@ declare class Repository<TDoc = AnyDocument> {
1069
1074
 
1070
1075
  type SortSpec = Record<string, 1 | -1>;
1071
1076
  type FilterQuery = Record<string, unknown>;
1077
+ /**
1078
+ * Mongoose-compatible populate option
1079
+ * Supports advanced populate with select, match, limit, sort, and nested populate
1080
+ *
1081
+ * @example
1082
+ * ```typescript
1083
+ * // URL: ?populate[author][select]=name,email&populate[author][match][active]=true
1084
+ * // Generates: { path: 'author', select: 'name email', match: { active: true } }
1085
+ * ```
1086
+ */
1087
+ interface PopulateOption {
1088
+ /** Field path to populate */
1089
+ path: string;
1090
+ /** Fields to select (space-separated) */
1091
+ select?: string;
1092
+ /** Filter conditions for populated documents */
1093
+ match?: Record<string, unknown>;
1094
+ /** Query options (limit, sort, skip) */
1095
+ options?: {
1096
+ limit?: number;
1097
+ sort?: SortSpec;
1098
+ skip?: number;
1099
+ };
1100
+ /** Nested populate configuration */
1101
+ populate?: PopulateOption;
1102
+ }
1072
1103
  /** Parsed query result with optional lookup configuration */
1073
1104
  interface ParsedQuery {
1074
1105
  /** MongoDB filter query */
1075
1106
  filters: FilterQuery;
1076
1107
  /** Sort specification */
1077
1108
  sort?: SortSpec;
1078
- /** Fields to populate (ObjectId-based) */
1109
+ /** Fields to populate (simple comma-separated string) */
1079
1110
  populate?: string;
1111
+ /**
1112
+ * Advanced populate options (Mongoose-compatible)
1113
+ * When this is set, `populate` will be undefined
1114
+ * @example [{ path: 'author', select: 'name email' }]
1115
+ */
1116
+ populateOptions?: PopulateOption[];
1080
1117
  /** Page number for offset pagination */
1081
1118
  page?: number;
1082
1119
  /** Cursor for keyset pagination */
@@ -1092,6 +1129,8 @@ interface ParsedQuery {
1092
1129
  /** Select/project fields */
1093
1130
  select?: Record<string, 0 | 1>;
1094
1131
  }
1132
+ /** Search mode for query parser */
1133
+ type SearchMode = 'text' | 'regex';
1095
1134
  interface QueryParserOptions {
1096
1135
  /** Maximum allowed regex pattern length (default: 500) */
1097
1136
  maxRegexLength?: number;
@@ -1107,6 +1146,18 @@ interface QueryParserOptions {
1107
1146
  enableLookups?: boolean;
1108
1147
  /** Enable aggregation parsing (default: false - requires explicit opt-in) */
1109
1148
  enableAggregations?: boolean;
1149
+ /**
1150
+ * Search mode (default: 'text')
1151
+ * - 'text': Uses MongoDB $text search (requires text index)
1152
+ * - 'regex': Uses $or with $regex across searchFields (no index required)
1153
+ */
1154
+ searchMode?: SearchMode;
1155
+ /**
1156
+ * Fields to search when searchMode is 'regex'
1157
+ * Required when searchMode is 'regex'
1158
+ * @example ['name', 'description', 'sku', 'tags']
1159
+ */
1160
+ searchFields?: string[];
1110
1161
  }
1111
1162
  /**
1112
1163
  * Modern Query Parser
@@ -1187,6 +1238,27 @@ declare class QueryParser {
1187
1238
  * ```
1188
1239
  */
1189
1240
  private _parseSelect;
1241
+ /**
1242
+ * Parse populate parameter - handles both simple string and advanced object format
1243
+ *
1244
+ * @example
1245
+ * ```typescript
1246
+ * // Simple: ?populate=author,category
1247
+ * // Returns: { simplePopulate: 'author,category', populateOptions: undefined }
1248
+ *
1249
+ * // Advanced: ?populate[author][select]=name,email
1250
+ * // Returns: { simplePopulate: undefined, populateOptions: [{ path: 'author', select: 'name email' }] }
1251
+ * ```
1252
+ */
1253
+ private _parsePopulate;
1254
+ /**
1255
+ * Parse a single populate configuration
1256
+ */
1257
+ private _parseSinglePopulate;
1258
+ /**
1259
+ * Convert populate match values (handles boolean strings, etc.)
1260
+ */
1261
+ private _convertPopulateMatch;
1190
1262
  /**
1191
1263
  * Parse filter parameters
1192
1264
  */
@@ -1209,6 +1281,20 @@ declare class QueryParser {
1209
1281
  */
1210
1282
  private _sanitizeMatchConfig;
1211
1283
  private _sanitizeSearch;
1284
+ /**
1285
+ * Build regex-based multi-field search filters
1286
+ * Creates an $or query with case-insensitive regex across all searchFields
1287
+ *
1288
+ * @example
1289
+ * // searchFields: ['name', 'description', 'sku']
1290
+ * // search: 'azure'
1291
+ * // Returns: [
1292
+ * // { name: { $regex: /azure/i } },
1293
+ * // { description: { $regex: /azure/i } },
1294
+ * // { sku: { $regex: /azure/i } }
1295
+ * // ]
1296
+ */
1297
+ private _buildRegexSearch;
1212
1298
  private _convertValue;
1213
1299
  private _parseOr;
1214
1300
  private _enhanceWithBetween;
@@ -1228,4 +1314,4 @@ declare class QueryParser {
1228
1314
  */
1229
1315
  declare function createRepository<TDoc>(Model: mongoose.Model<TDoc, any, any, any>, plugins?: PluginType[], paginationConfig?: PaginationConfig, options?: RepositoryOptions): Repository<TDoc>;
1230
1316
 
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 };
1317
+ 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, type PopulateOption, 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
@@ -756,7 +756,8 @@ var Repository = class {
756
756
  * Get document by ID
757
757
  */
758
758
  async getById(id, options = {}) {
759
- const context = await this._buildContext("getById", { id, ...options });
759
+ const populateSpec = options.populateOptions || options.populate;
760
+ const context = await this._buildContext("getById", { id, ...options, populate: populateSpec });
760
761
  if (context._cacheHit) {
761
762
  return context._cachedResult;
762
763
  }
@@ -768,7 +769,8 @@ var Repository = class {
768
769
  * Get single document by query
769
770
  */
770
771
  async getByQuery(query, options = {}) {
771
- const context = await this._buildContext("getByQuery", { query, ...options });
772
+ const populateSpec = options.populateOptions || options.populate;
773
+ const context = await this._buildContext("getByQuery", { query, ...options, populate: populateSpec });
772
774
  if (context._cacheHit) {
773
775
  return context._cachedResult;
774
776
  }
@@ -815,11 +817,12 @@ var Repository = class {
815
817
  const limit = params.limit || params.pagination?.limit || this._pagination.config.defaultLimit;
816
818
  let query = { ...filters };
817
819
  if (search) query.$text = { $search: search };
820
+ const populateSpec = options.populateOptions || params.populateOptions || context.populate || options.populate;
818
821
  const paginationOptions = {
819
822
  filters: query,
820
823
  sort: this._parseSort(sort),
821
824
  limit,
822
- populate: this._parsePopulate(context.populate || options.populate),
825
+ populate: this._parsePopulate(populateSpec),
823
826
  select: context.select || options.select,
824
827
  lean: context.lean ?? options.lean ?? true,
825
828
  session: options.session
@@ -1194,8 +1197,14 @@ var QueryParser = class {
1194
1197
  maxLimit: options.maxLimit ?? 1e3,
1195
1198
  additionalDangerousOperators: options.additionalDangerousOperators ?? [],
1196
1199
  enableLookups: options.enableLookups ?? true,
1197
- enableAggregations: options.enableAggregations ?? false
1200
+ enableAggregations: options.enableAggregations ?? false,
1201
+ searchMode: options.searchMode ?? "text",
1202
+ searchFields: options.searchFields
1198
1203
  };
1204
+ if (this.options.searchMode === "regex" && (!this.options.searchFields || this.options.searchFields.length === 0)) {
1205
+ console.warn('[mongokit] searchMode "regex" requires searchFields to be specified. Falling back to "text" mode.');
1206
+ this.options.searchMode = "text";
1207
+ }
1199
1208
  this.dangerousOperators = [
1200
1209
  "$where",
1201
1210
  "$function",
@@ -1236,13 +1245,34 @@ var QueryParser = class {
1236
1245
  console.warn(`[mongokit] Limit ${parsedLimit} exceeds maximum ${this.options.maxLimit}, capping to max`);
1237
1246
  parsedLimit = this.options.maxLimit;
1238
1247
  }
1248
+ const sanitizedSearch = this._sanitizeSearch(search);
1249
+ const { simplePopulate, populateOptions } = this._parsePopulate(populate);
1239
1250
  const parsed = {
1240
1251
  filters: this._parseFilters(filters),
1241
1252
  limit: parsedLimit,
1242
1253
  sort: this._parseSort(sort),
1243
- populate,
1244
- search: this._sanitizeSearch(search)
1254
+ populate: simplePopulate,
1255
+ populateOptions,
1256
+ search: sanitizedSearch
1245
1257
  };
1258
+ if (sanitizedSearch && this.options.searchMode === "regex" && this.options.searchFields) {
1259
+ const regexSearchFilters = this._buildRegexSearch(sanitizedSearch);
1260
+ if (regexSearchFilters) {
1261
+ if (parsed.filters.$or) {
1262
+ parsed.filters = {
1263
+ ...parsed.filters,
1264
+ $and: [
1265
+ { $or: parsed.filters.$or },
1266
+ { $or: regexSearchFilters }
1267
+ ]
1268
+ };
1269
+ delete parsed.filters.$or;
1270
+ } else {
1271
+ parsed.filters.$or = regexSearchFilters;
1272
+ }
1273
+ parsed.search = void 0;
1274
+ }
1275
+ }
1246
1276
  if (select) {
1247
1277
  parsed.select = this._parseSelect(select);
1248
1278
  }
@@ -1261,7 +1291,16 @@ var QueryParser = class {
1261
1291
  }
1262
1292
  const orGroup = this._parseOr(query);
1263
1293
  if (orGroup) {
1264
- parsed.filters = { ...parsed.filters, $or: orGroup };
1294
+ if (parsed.filters.$or) {
1295
+ const existingOr = parsed.filters.$or;
1296
+ delete parsed.filters.$or;
1297
+ parsed.filters.$and = [
1298
+ { $or: existingOr },
1299
+ { $or: orGroup }
1300
+ ];
1301
+ } else {
1302
+ parsed.filters.$or = orGroup;
1303
+ }
1265
1304
  }
1266
1305
  parsed.filters = this._enhanceWithBetween(parsed.filters);
1267
1306
  return parsed;
@@ -1411,6 +1450,117 @@ var QueryParser = class {
1411
1450
  return void 0;
1412
1451
  }
1413
1452
  // ============================================================
1453
+ // POPULATE PARSING
1454
+ // ============================================================
1455
+ /**
1456
+ * Parse populate parameter - handles both simple string and advanced object format
1457
+ *
1458
+ * @example
1459
+ * ```typescript
1460
+ * // Simple: ?populate=author,category
1461
+ * // Returns: { simplePopulate: 'author,category', populateOptions: undefined }
1462
+ *
1463
+ * // Advanced: ?populate[author][select]=name,email
1464
+ * // Returns: { simplePopulate: undefined, populateOptions: [{ path: 'author', select: 'name email' }] }
1465
+ * ```
1466
+ */
1467
+ _parsePopulate(populate) {
1468
+ if (!populate) {
1469
+ return {};
1470
+ }
1471
+ if (typeof populate === "string") {
1472
+ return { simplePopulate: populate };
1473
+ }
1474
+ if (typeof populate === "object" && populate !== null) {
1475
+ const populateObj = populate;
1476
+ if (Object.keys(populateObj).length === 0) {
1477
+ return {};
1478
+ }
1479
+ const populateOptions = [];
1480
+ for (const [path, config] of Object.entries(populateObj)) {
1481
+ if (path.startsWith("$") || this.dangerousOperators.includes(path)) {
1482
+ console.warn(`[mongokit] Blocked dangerous populate path: ${path}`);
1483
+ continue;
1484
+ }
1485
+ const option = this._parseSinglePopulate(path, config);
1486
+ if (option) {
1487
+ populateOptions.push(option);
1488
+ }
1489
+ }
1490
+ return populateOptions.length > 0 ? { populateOptions } : {};
1491
+ }
1492
+ return {};
1493
+ }
1494
+ /**
1495
+ * Parse a single populate configuration
1496
+ */
1497
+ _parseSinglePopulate(path, config, depth = 0) {
1498
+ if (depth > 5) {
1499
+ console.warn(`[mongokit] Populate depth exceeds maximum (5), truncating at path: ${path}`);
1500
+ return { path };
1501
+ }
1502
+ if (typeof config === "string") {
1503
+ if (config === "true" || config === "1") {
1504
+ return { path };
1505
+ }
1506
+ return { path, select: config.split(",").join(" ") };
1507
+ }
1508
+ if (typeof config === "object" && config !== null) {
1509
+ const opts = config;
1510
+ const option = { path };
1511
+ if (opts.select && typeof opts.select === "string") {
1512
+ option.select = opts.select.split(",").map((s) => s.trim()).join(" ");
1513
+ }
1514
+ if (opts.match && typeof opts.match === "object") {
1515
+ option.match = this._convertPopulateMatch(opts.match);
1516
+ }
1517
+ if (opts.limit !== void 0) {
1518
+ const limit = parseInt(String(opts.limit), 10);
1519
+ if (!isNaN(limit) && limit > 0) {
1520
+ option.options = option.options || {};
1521
+ option.options.limit = limit;
1522
+ }
1523
+ }
1524
+ if (opts.sort && typeof opts.sort === "string") {
1525
+ const sortSpec = this._parseSort(opts.sort);
1526
+ if (sortSpec) {
1527
+ option.options = option.options || {};
1528
+ option.options.sort = sortSpec;
1529
+ }
1530
+ }
1531
+ if (opts.skip !== void 0) {
1532
+ const skip = parseInt(String(opts.skip), 10);
1533
+ if (!isNaN(skip) && skip >= 0) {
1534
+ option.options = option.options || {};
1535
+ option.options.skip = skip;
1536
+ }
1537
+ }
1538
+ if (opts.populate && typeof opts.populate === "object") {
1539
+ const nestedPopulate = opts.populate;
1540
+ const nestedEntries = Object.entries(nestedPopulate);
1541
+ if (nestedEntries.length > 0) {
1542
+ const [nestedPath, nestedConfig] = nestedEntries[0];
1543
+ const nestedOption = this._parseSinglePopulate(nestedPath, nestedConfig, depth + 1);
1544
+ if (nestedOption) {
1545
+ option.populate = nestedOption;
1546
+ }
1547
+ }
1548
+ }
1549
+ return option;
1550
+ }
1551
+ return null;
1552
+ }
1553
+ /**
1554
+ * Convert populate match values (handles boolean strings, etc.)
1555
+ */
1556
+ _convertPopulateMatch(match) {
1557
+ const converted = {};
1558
+ for (const [key, value] of Object.entries(match)) {
1559
+ converted[key] = this._convertValue(value);
1560
+ }
1561
+ return converted;
1562
+ }
1563
+ // ============================================================
1414
1564
  // FILTER PARSING (Enhanced from original)
1415
1565
  // ============================================================
1416
1566
  /**
@@ -1428,7 +1578,7 @@ var QueryParser = class {
1428
1578
  console.warn(`[mongokit] Blocked dangerous operator: ${key}`);
1429
1579
  continue;
1430
1580
  }
1431
- if (["page", "limit", "sort", "populate", "search", "select", "lean", "includeDeleted", "lookup", "aggregate"].includes(key)) {
1581
+ if (["page", "limit", "sort", "populate", "search", "select", "lean", "includeDeleted", "lookup", "aggregate", "or", "OR", "$or"].includes(key)) {
1432
1582
  continue;
1433
1583
  }
1434
1584
  const operatorMatch = key.match(/^(.+)\[(.+)\]$/);
@@ -1618,6 +1768,35 @@ var QueryParser = class {
1618
1768
  }
1619
1769
  return searchStr;
1620
1770
  }
1771
+ /**
1772
+ * Build regex-based multi-field search filters
1773
+ * Creates an $or query with case-insensitive regex across all searchFields
1774
+ *
1775
+ * @example
1776
+ * // searchFields: ['name', 'description', 'sku']
1777
+ * // search: 'azure'
1778
+ * // Returns: [
1779
+ * // { name: { $regex: /azure/i } },
1780
+ * // { description: { $regex: /azure/i } },
1781
+ * // { sku: { $regex: /azure/i } }
1782
+ * // ]
1783
+ */
1784
+ _buildRegexSearch(searchTerm) {
1785
+ if (!this.options.searchFields || this.options.searchFields.length === 0) {
1786
+ return null;
1787
+ }
1788
+ const safeRegex = this._createSafeRegex(searchTerm, "i");
1789
+ if (!safeRegex) {
1790
+ return null;
1791
+ }
1792
+ const orConditions = [];
1793
+ for (const field of this.options.searchFields) {
1794
+ orConditions.push({
1795
+ [field]: { $regex: safeRegex }
1796
+ });
1797
+ }
1798
+ return orConditions.length > 0 ? orConditions : null;
1799
+ }
1621
1800
  _convertValue(value) {
1622
1801
  if (value === null || value === void 0) return value;
1623
1802
  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.4",
3
+ "version": "3.1.6",
4
4
  "description": "Production-grade MongoDB repositories with zero dependencies - smart pagination, events, and plugins",
5
5
  "type": "module",
6
6
  "sideEffects": false,