@rapidrest/service-core 1.8.0 → 2.0.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.
@@ -4,6 +4,7 @@ import "reflect-metadata";
4
4
  import { isEmpty } from "lodash-es";
5
5
  import { RecoverableBaseEntity } from "./RecoverableBaseEntity.js";
6
6
  import { ApiErrorMessages, ApiErrors } from "../ApiErrors.js";
7
+ import { getColumnMetadata } from "../decorators/PersistenceDecorators.js";
7
8
  const logger = Logger();
8
9
  const REGEX_QUERY_PARAM_VALUE = new RegExp(/^([a-zA-Z]+)\((.*)\)$/, "i");
9
10
  // Anchored at the start so these only match the intended reserved parameter names/prefixes and not any
@@ -11,10 +12,30 @@ const REGEX_QUERY_PARAM_VALUE = new RegExp(/^([a-zA-Z]+)\((.*)\)$/, "i");
11
12
  const REGEX_RESERVED_QUERY_PARAMS = new RegExp("^(jwt_|oauth_|auth_|cache).*", "i");
12
13
  const REGEX_QUERY_LIMITS = new RegExp("^(limit|page|sort)$", "i");
13
14
  const REGEX_QUERY_SORT_STRING = new RegExp(/^\{.*\}$/, "i");
14
- // Apparently calling JSON.stringify on RegExp returns an empty set. So the recommended way to
15
- // overcome this is by adding a `toJSON` method that uses the `toString` instead which will
16
- // give us what we want.
17
- RegExp.prototype.toJSON = RegExp.prototype.toString;
15
+ /** Default number of records returned by a search query when no `limit` is specified. Shared by both backends. */
16
+ export const DEFAULT_PAGE_SIZE = 100;
17
+ /** Maximum number of records a search query may request via `limit`, regardless of provider. Shared by both backends. */
18
+ export const MAX_PAGE_SIZE = 1000;
19
+ /** The operator names recognized by the `op(value)` query syntax. Anything else is rejected with a 400. */
20
+ const KNOWN_OPERATORS = new Set([
21
+ "eq",
22
+ "ne",
23
+ "not",
24
+ "gt",
25
+ "gte",
26
+ "lt",
27
+ "lte",
28
+ "in",
29
+ "nin",
30
+ "like",
31
+ "regex",
32
+ "range",
33
+ "exists",
34
+ ]);
35
+ /** Maximum nesting depth accepted for a `$or` array or a `QueryNode` tree, to bound recursion. */
36
+ const MAX_QUERY_DEPTH = 8;
37
+ /** Maximum number of OR branches / predicate nodes a single query may expand to, to bound total work. */
38
+ const MAX_QUERY_NODES = 256;
18
39
  /**
19
40
  * Utility class for working with data model classes.
20
41
  *
@@ -95,6 +116,41 @@ export class ModelUtils {
95
116
  ModelUtils.readOnlyPropertyCache.set(modelClass, results);
96
117
  return results;
97
118
  }
119
+ /**
120
+ * Resolves the declared type of a model property, from an explicit `type` override on `@Column` or (falling
121
+ * back) the TypeScript design-time type reflected at decoration time. Returns `undefined` when `modelClass`
122
+ * is not provided or declares no column metadata for `property` - callers must fall back to a heuristic in
123
+ * that case, the same way `coerceOperand` does.
124
+ */
125
+ static resolvePropertyType(modelClass, property) {
126
+ if (!modelClass || !property) {
127
+ return undefined;
128
+ }
129
+ let byProperty = ModelUtils.columnTypeCache.get(modelClass);
130
+ if (!byProperty) {
131
+ byProperty = new Map();
132
+ for (const column of getColumnMetadata(modelClass)) {
133
+ byProperty.set(column.propertyName, column.options.type ?? column.designType);
134
+ }
135
+ ModelUtils.columnTypeCache.set(modelClass, byProperty);
136
+ }
137
+ return byProperty.get(property);
138
+ }
139
+ /**
140
+ * Returns the set of property names a `sort` query parameter may reference for `modelClass`, or `undefined`
141
+ * if `modelClass` declares no column metadata at all - in which case sort keys are accepted unvalidated
142
+ * (the same permissive fallback `coerceOperand` uses when no type metadata is available).
143
+ */
144
+ static getSortablePropertyNames(modelClass) {
145
+ if (!modelClass) {
146
+ return undefined;
147
+ }
148
+ const columns = getColumnMetadata(modelClass);
149
+ if (columns.length === 0) {
150
+ return undefined;
151
+ }
152
+ return new Set(columns.map((c) => c.propertyName));
153
+ }
98
154
  /**
99
155
  * Builds a query object for use with `find` functions of the given repository for retrieving objects matching the
100
156
  * specified unique identifier.
@@ -190,6 +246,206 @@ export class ModelUtils {
190
246
  }
191
247
  return { $or: query };
192
248
  }
249
+ /**
250
+ * Resolves a raw, single-value operand (already unwrapped from any `op(...)` syntax) to a properly-typed
251
+ * native value: `me` is substituted for the requesting user's uid, and the result is otherwise coerced
252
+ * according to `property`'s declared type on `modelClass` (falling back to a JSON/Date/string heuristic when
253
+ * no column metadata is available for it). Used for every scalar operand on both backends - including each
254
+ * element of `in()`/`nin()`/`range()` - so type coercion, `me` substitution and operator-injection rejection
255
+ * are applied uniformly everywhere a client-supplied value enters a query, on both backends.
256
+ *
257
+ * @throws {ApiError} If `raw` is `me` with no authenticated user, if a typed column rejects an unparseable
258
+ * operand, or if the coerced value contains a hidden MongoDB operator/dotted key.
259
+ */
260
+ static coerceOperand(raw, modelClass, property, user) {
261
+ if (raw === "me") {
262
+ if (!user) {
263
+ throw new ApiError(ApiErrors.SEARCH_INVALID_ME_REFERENCE, 403, ApiErrorMessages.SEARCH_INVALID_ME_REFERENCE);
264
+ }
265
+ return user.uid;
266
+ }
267
+ const type = ModelUtils.resolvePropertyType(modelClass, property);
268
+ let result;
269
+ if (type !== undefined) {
270
+ result = ModelUtils.coerceToType(raw, type, property);
271
+ }
272
+ else {
273
+ // No column metadata available for this property - fall back to the legacy heuristic: try JSON
274
+ // (covers numbers/booleans/null/objects/arrays), then a date, then leave it as a plain string. This
275
+ // is the one path that still risks the "Mar 5" ambiguity (a text value that happens to look like a
276
+ // date gets silently reinterpreted as one) - kept only for callers that don't supply column
277
+ // metadata (e.g. `modelClass` is undefined, or the property isn't declared via `@Column`). A
278
+ // string-typed column skips the Date attempt entirely once metadata IS available - see
279
+ // `coerceToType`.
280
+ try {
281
+ result = JSON.parse(raw);
282
+ }
283
+ catch (err) {
284
+ const asDate = new Date(raw);
285
+ result = isNaN(asDate.valueOf()) ? raw : asDate;
286
+ }
287
+ }
288
+ ModelUtils.assertNoOperatorInjection(result);
289
+ return result;
290
+ }
291
+ /**
292
+ * Coerces a raw operand string to `type` (an explicit `@Column({type})` override or a reflected TypeScript
293
+ * design type), rejecting operands that don't parse as that type rather than silently guessing.
294
+ */
295
+ static coerceToType(raw, type, property) {
296
+ if (raw === "null") {
297
+ // The JSON `null` literal is valid for any declared type (e.g. `eq(null)`); the eq/ne callers
298
+ // special-case the resulting `null` via IsNull()/$eq:null rather than this function.
299
+ return null;
300
+ }
301
+ if (type === Date || type === "date" || type === "datetime" || type === "timestamp") {
302
+ const value = new Date(raw);
303
+ if (isNaN(value.valueOf())) {
304
+ throw ModelUtils.invalidOperandError(raw, property, "date");
305
+ }
306
+ return value;
307
+ }
308
+ if (type === Number ||
309
+ ["int", "integer", "float", "double", "decimal", "numeric", "bigint", "smallint", "tinyint"].includes(type)) {
310
+ const value = Number(raw);
311
+ if (raw.trim() === "" || isNaN(value)) {
312
+ throw ModelUtils.invalidOperandError(raw, property, "number");
313
+ }
314
+ return value;
315
+ }
316
+ if (type === Boolean || type === "boolean" || type === "bool") {
317
+ const lower = raw.toLowerCase();
318
+ if (lower === "true")
319
+ return true;
320
+ if (lower === "false")
321
+ return false;
322
+ throw ModelUtils.invalidOperandError(raw, property, "boolean");
323
+ }
324
+ // String (and any other/unrecognized declared type): never attempt Date/number parsing - this is what
325
+ // fixes the "Mar 5" bug, where a text search value that happened to look like a date was silently
326
+ // reinterpreted as one.
327
+ return raw;
328
+ }
329
+ static invalidOperandError(raw, property, type) {
330
+ return new ApiError(ApiErrors.SEARCH_INVALID_OPERAND_TYPE, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_OPERAND_TYPE, { value: raw, field: property, type }));
331
+ }
332
+ /**
333
+ * Coerces an already-typed AST predicate value (see `QueryNode`): a string operand is routed through
334
+ * `coerceOperand` (type coercion, `me` substitution, injection guard) exactly like the flat `op(value)`
335
+ * form; any other value is assumed to already be correctly typed by the caller and is only checked for a
336
+ * hidden operator/dotted key.
337
+ */
338
+ static coerceNodeValue(value, modelClass, property, user) {
339
+ if (typeof value === "string") {
340
+ return ModelUtils.coerceOperand(value, modelClass, property, user);
341
+ }
342
+ ModelUtils.assertNoOperatorInjection(value);
343
+ return value;
344
+ }
345
+ /**
346
+ * Recursively verifies that no key in the given value (at any depth, including keys of objects nested inside
347
+ * arrays) is a MongoDB operator (starts with `$`) or uses dot-notation field addressing (contains `.`). Client
348
+ * input is only ever meant to supply plain field values/comparison operands — never raw Mongo query operators —
349
+ * so any such key indicates an attempt to inject arbitrary query behavior (e.g. `$where`, `$expr`, or reaching
350
+ * into a field the API doesn't expose via dot-notation). Applied to every coerced operand on both backends -
351
+ * `Equal(JSON.parse(param))`-style SQL operators are constructed by TypeORM rather than interpreted from the
352
+ * operand directly, but a client-supplied object operand should still be rejected consistently on both
353
+ * backends rather than left to whatever TypeORM happens to do with it.
354
+ *
355
+ * @param value The value to check, typically a parsed query parameter.
356
+ * @throws {ApiError} If an operator-like or dotted key is found anywhere in `value`.
357
+ */
358
+ static assertNoOperatorInjection(value) {
359
+ if (Array.isArray(value)) {
360
+ for (const item of value) {
361
+ ModelUtils.assertNoOperatorInjection(item);
362
+ }
363
+ }
364
+ else if (value && typeof value === "object") {
365
+ for (const key of Object.keys(value)) {
366
+ if (key.startsWith("$") || key.includes(".")) {
367
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
368
+ }
369
+ ModelUtils.assertNoOperatorInjection(value[key]);
370
+ }
371
+ }
372
+ }
373
+ /**
374
+ * Best-effort check for regex patterns vulnerable to catastrophic backtracking (ReDoS): patterns that are
375
+ * unreasonably long, that contain a quantified group whose contents are themselves quantified (e.g. `(a+)+`,
376
+ * `(a*)*`), or a quantified group containing alternation (e.g. `(a|a)*`, `(a|ab)*`) - both classic shapes
377
+ * that cause exponential backtracking in JS's (and SQLite's, since the `regex()` SQL operator is backed by a
378
+ * JS `RegExp` - see `registerRegexpFunction` in `TypeOrmSupport.ts`) regex engine. This is not an exhaustive
379
+ * defense; it catches the common cases a client would realistically send. `like()` no longer accepts raw
380
+ * regex (it compiles glob syntax instead - see `globToRegExpSource`), so this now guards only the explicit
381
+ * `regex()` operator.
382
+ *
383
+ * Public so the SQLite `REGEXP` custom function (registered per-connection in `TypeOrmSupport.ts`) can apply
384
+ * the same guard at query-execution time, since a pattern reaching that function didn't necessarily pass
385
+ * through this class's own query builders (e.g. a raw `Raw()`/QueryBuilder use elsewhere).
386
+ */
387
+ static isUnsafeRegexPattern(pattern) {
388
+ if (pattern.length > ModelUtils.MAX_PATTERN_LENGTH) {
389
+ return true;
390
+ }
391
+ return /\([^()]*[+*]\)[+*{]/.test(pattern) || /\([^()]*\|[^()]*\)[+*{]/.test(pattern);
392
+ }
393
+ /**
394
+ * Translates a client-supplied glob pattern (`*` = any sequence, `?` = any single character) to a SQL
395
+ * `LIKE` pattern. Any `%`/`_` already present in the glob source is passed through unescaped (matching this
396
+ * operator's pre-existing behavior before glob support was added) - a client wanting to match a literal `%`
397
+ * or `_` cannot fully escape it, a narrow, documented limitation rather than a regression.
398
+ */
399
+ static globToLike(glob) {
400
+ let result = "";
401
+ for (const ch of glob) {
402
+ if (ch === "*")
403
+ result += "%";
404
+ else if (ch === "?")
405
+ result += "_";
406
+ else
407
+ result += ch;
408
+ }
409
+ return result;
410
+ }
411
+ /**
412
+ * Translates a client-supplied glob pattern into a fully-escaped, anchored regular expression source string
413
+ * for use with MongoDB's `$regex`, so glob syntax behaves identically on both backends.
414
+ */
415
+ static globToRegExpSource(glob) {
416
+ let result = "";
417
+ for (const ch of glob) {
418
+ if (ch === "*")
419
+ result += ".*";
420
+ else if (ch === "?")
421
+ result += ".";
422
+ else
423
+ result += StringUtils.escapeRegExp(ch);
424
+ }
425
+ return `^${result}$`;
426
+ }
427
+ /**
428
+ * Compiles a validated `regex()` pattern to a driver-appropriate case-insensitive match expression. Only
429
+ * PostgreSQL (`~*`), MySQL/MariaDB (`REGEXP`) and the `better-sqlite3` driver (via a `REGEXP` function
430
+ * registered per-connection - see `registerRegexpFunction` in `TypeOrmSupport.ts`) are supported; any other
431
+ * driver rejects the operator outright rather than silently falling back to something incorrect.
432
+ */
433
+ static compileSqlRegex(pattern, driverType) {
434
+ const { Raw } = ModelUtils.orm;
435
+ switch (driverType) {
436
+ case "postgres":
437
+ case "cockroachdb":
438
+ return Raw((alias) => `${alias} ~* :pattern`, { pattern });
439
+ case "mysql":
440
+ case "mariadb":
441
+ return Raw((alias) => `${alias} REGEXP :pattern`, { pattern });
442
+ case "better-sqlite3":
443
+ case "sqlite":
444
+ return Raw((alias) => `${alias} REGEXP :pattern`, { pattern });
445
+ default:
446
+ throw new ApiError(ApiErrors.SEARCH_OPERATOR_NOT_SUPPORTED, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_OPERATOR_NOT_SUPPORTED, { operator: "regex" }));
447
+ }
448
+ }
193
449
  /**
194
450
  * Given a string containing a parameter value and/or a comparison operation return a TypeORM compatible find value.
195
451
  * e.g.
@@ -198,7 +454,7 @@ export class ModelUtils {
198
454
  *
199
455
  * @param param
200
456
  */
201
- static getQueryParamValue(param) {
457
+ static getQueryParamValue(param, modelClass, property, user, exactMatch, driverType) {
202
458
  if (typeof param === "string") {
203
459
  const { Equal, MoreThan, MoreThanOrEqual, In, ILike, LessThan, LessThanOrEqual, Not, Between, IsNull } = ModelUtils.orm;
204
460
  // The value of each param can optionally have the operation included. If no operator is included Eq is
@@ -207,140 +463,103 @@ export class ModelUtils {
207
463
  const matches = param.match(REGEX_QUERY_PARAM_VALUE);
208
464
  if (matches) {
209
465
  const opName = matches[1].toLowerCase();
210
- let value = matches[2];
211
- try {
212
- // Attempt to parse the value to a native type
213
- value = JSON.parse(matches[2]);
214
- }
215
- catch (err) {
216
- // If an error occurred it's because the value is a string or date, not another type.
217
- value = new Date(matches[2]);
218
- if (isNaN(value)) {
219
- value = matches[2];
220
- }
466
+ const operand = matches[2];
467
+ if (!KNOWN_OPERATORS.has(opName)) {
468
+ // The literal-value escape hatch is `eq(...)`: `?title=eq(Report(final))` still parses
469
+ // correctly here since REGEX_QUERY_PARAM_VALUE is greedy, so a field value that happens to
470
+ // look like `name(args)` remains searchable.
471
+ throw new ApiError(ApiErrors.SEARCH_UNKNOWN_OPERATOR, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_UNKNOWN_OPERATOR, { operator: matches[1] }));
221
472
  }
222
473
  switch (opName) {
223
- case "eq":
474
+ case "eq": {
475
+ const value = ModelUtils.coerceOperand(operand, modelClass, property, user);
224
476
  // `Equal(null)` compiles to `column = NULL`, which standard SQL NULL semantics always
225
477
  // evaluate to unknown/false (never true), regardless of the column's actual value -
226
478
  // TypeORM requires the dedicated `IsNull()` operator to produce `column IS NULL`.
227
479
  return value === null ? IsNull() : Equal(value);
480
+ }
228
481
  case "gt":
229
- return MoreThan(value);
482
+ return MoreThan(ModelUtils.coerceOperand(operand, modelClass, property, user));
230
483
  case "gte":
231
- return MoreThanOrEqual(value);
484
+ return MoreThanOrEqual(ModelUtils.coerceOperand(operand, modelClass, property, user));
232
485
  case "in": {
233
- // Split the raw match text, not `value`: a comma-joined list like "1,5" can itself parse
234
- // as a valid `Date` above (e.g. "1,5" -> Jan 5), leaving `value` a Date with no `.split()`.
235
- const args = matches[2].split(",");
486
+ const args = operand
487
+ .split(",")
488
+ .map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
236
489
  return In(args);
237
490
  }
238
491
  case "like":
239
- return ILike(value);
492
+ return ILike(ModelUtils.globToLike(operand));
493
+ case "regex": {
494
+ if (ModelUtils.isUnsafeRegexPattern(operand)) {
495
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
496
+ }
497
+ return ModelUtils.compileSqlRegex(operand, driverType);
498
+ }
240
499
  case "lt":
241
- return LessThan(value);
500
+ return LessThan(ModelUtils.coerceOperand(operand, modelClass, property, user));
242
501
  case "lte":
243
- return LessThanOrEqual(value);
502
+ return LessThanOrEqual(ModelUtils.coerceOperand(operand, modelClass, property, user));
244
503
  case "ne":
245
- case "not":
504
+ case "not": {
246
505
  // Same NULL-semantics gap as "eq" above, mirrored: `Not(null)` compiles to
247
506
  // `column != NULL`, which SQL also always evaluates to unknown/false - the correct
248
- // "has a value" query is `Not(IsNull())`, producing `column IS NOT NULL`. Confirmed via
249
- // a real-database (SQLite) test in @rapidmx/restapi that `ne(null)`/`not(null)`
250
- // silently matched zero rows before this fix, even though the identical query already
251
- // worked correctly against MongoDB (`$ne: null` has no equivalent gap there).
507
+ // "has a value" query is `Not(IsNull())`, producing `column IS NOT NULL`.
508
+ const value = ModelUtils.coerceOperand(operand, modelClass, property, user);
252
509
  return value === null ? Not(IsNull()) : Not(value);
510
+ }
253
511
  case "nin": {
254
- // See "in" above for why this splits `matches[2]` instead of `value`.
255
- const args = matches[2].split(",");
512
+ const args = operand
513
+ .split(",")
514
+ .map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
256
515
  return Not(In(args));
257
516
  }
258
517
  case "range": {
259
- // See "in" above for why this splits `matches[2]` instead of `value`.
260
- const args = matches[2].split(",");
518
+ const args = operand.split(",");
261
519
  if (args.length !== 2) {
262
520
  const msg = StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_RANGE, {
263
- value,
521
+ value: operand,
264
522
  length: args.length,
265
523
  });
266
524
  throw new ApiError(ApiErrors.SEARCH_INVALID_RANGE, 400, msg);
267
525
  }
268
- try {
269
- // Attempt to parse the range values to native types
270
- return Between(JSON.parse(args[0]), JSON.parse(args[1]));
271
- }
272
- catch (err) {
273
- return Between(args[0], args[1]);
274
- }
526
+ const lower = ModelUtils.coerceOperand(args[0], modelClass, property, user);
527
+ const upper = ModelUtils.coerceOperand(args[1], modelClass, property, user);
528
+ return Between(lower, upper);
529
+ }
530
+ case "exists": {
531
+ const wantsExists = operand.trim().toLowerCase() === "true";
532
+ return wantsExists ? Not(IsNull()) : IsNull();
275
533
  }
276
534
  default:
277
- return Equal(value);
535
+ // Unreachable: opName was already validated against KNOWN_OPERATORS above.
536
+ throw new Error(`Unhandled search operator: ${opName}`);
278
537
  }
279
538
  }
280
539
  else {
281
- try {
282
- // Attempt to parse the value to a native type
283
- return Equal(JSON.parse(param));
284
- }
285
- catch (err) {
286
- // If an error occurred it's because the value is a string, not another type.
287
- const date = new Date(param);
288
- return Equal(!isNaN(date.valueOf()) ? date : param);
540
+ const coerced = ModelUtils.coerceOperand(param, modelClass, property, user);
541
+ if (!exactMatch && typeof coerced === "string") {
542
+ return ILike(`%${coerced}%`);
289
543
  }
544
+ return coerced === null ? IsNull() : Equal(coerced);
290
545
  }
291
546
  }
292
547
  else {
548
+ // A non-string value only reaches here when the caller already parsed the raw query into native
549
+ // types itself (mirrors the equivalent Mongo case below) - still validated for a hidden operator.
550
+ ModelUtils.assertNoOperatorInjection(param);
293
551
  return param;
294
552
  }
295
553
  }
296
- /**
297
- * Recursively verifies that no key in the given value (at any depth, including keys of objects nested inside
298
- * arrays) is a MongoDB operator (starts with `$`) or uses dot-notation field addressing (contains `.`). Client
299
- * input is only ever meant to supply plain field values/comparison operands — never raw Mongo query operators —
300
- * so any such key indicates an attempt to inject arbitrary query behavior (e.g. `$where`, `$expr`, or reaching
301
- * into a field the API doesn't expose via dot-notation).
302
- *
303
- * @param value The value to check, typically a parsed query parameter.
304
- * @throws {ApiError} If an operator-like or dotted key is found anywhere in `value`.
305
- */
306
- static assertNoOperatorInjection(value) {
307
- if (Array.isArray(value)) {
308
- for (const item of value) {
309
- ModelUtils.assertNoOperatorInjection(item);
310
- }
311
- }
312
- else if (value && typeof value === "object") {
313
- for (const key of Object.keys(value)) {
314
- if (key.startsWith("$") || key.includes(".")) {
315
- throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
316
- }
317
- ModelUtils.assertNoOperatorInjection(value[key]);
318
- }
319
- }
320
- }
321
- /**
322
- * Best-effort check for regex patterns vulnerable to catastrophic backtracking (ReDoS). Rejects patterns that
323
- * are unreasonably long, or that contain a quantified group whose contents are themselves quantified (e.g.
324
- * `(a+)+`, `(a*)*`) — the classic shape that causes exponential backtracking in JS's regex engine. This is not
325
- * an exhaustive defense; it catches the common cases a client would realistically send.
326
- *
327
- * @param pattern The user-supplied `like()` pattern.
328
- */
329
- static isUnsafeRegexPattern(pattern) {
330
- if (pattern.length > ModelUtils.MAX_LIKE_PATTERN_LENGTH) {
331
- return true;
332
- }
333
- return /\([^()]*[+*]\)[+*{]/.test(pattern);
334
- }
335
554
  /**
336
555
  * Given a string containing a parameter value and/or a comparison operation return a MongoDB compatible find value.
337
556
  * e.g.
338
557
  * Given the string "myvalue" will return an `"myvalue"` object.
339
- * Given the string "not(myvalue)" will return an `{ $not: "myvalue" }` object.
558
+ * Given the string "not(myvalue)" will return an `{ $ne: "myvalue" }` object.
340
559
  *
341
560
  * @param param
342
561
  */
343
- static getQueryParamValueMongo(param) {
562
+ static getQueryParamValueMongo(param, modelClass, property, user, exactMatch) {
344
563
  if (typeof param === "string") {
345
564
  // The value of each param can optionally have the operation included. If no operator is included Eq is
346
565
  // always assumed.
@@ -348,96 +567,82 @@ export class ModelUtils {
348
567
  const matches = param.match(REGEX_QUERY_PARAM_VALUE);
349
568
  if (matches) {
350
569
  const opName = matches[1].toLowerCase();
351
- let value = matches[2];
352
- try {
353
- // Attempt to parse the value to a native type
354
- value = JSON.parse(matches[2]);
355
- }
356
- catch (err) {
357
- // If an error occurred it's because the value is a string or date, not another type.
358
- value = new Date(matches[2]);
359
- if (isNaN(value)) {
360
- value = matches[2];
361
- }
570
+ const operand = matches[2];
571
+ if (!KNOWN_OPERATORS.has(opName)) {
572
+ throw new ApiError(ApiErrors.SEARCH_UNKNOWN_OPERATOR, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_UNKNOWN_OPERATOR, { operator: matches[1] }));
362
573
  }
363
- // `value` is the client-supplied comparison operand — reject any Mongo operator/dotted key hidden
364
- // inside it, regardless of which (trusted, framework-constructed) operator wrapper it ends up
365
- // under below. Must run *after* the parse fallback above, not inside its try/catch, so a
366
- // rejection here isn't silently swallowed as a "not valid JSON" case.
367
- ModelUtils.assertNoOperatorInjection(value);
368
574
  switch (opName) {
369
575
  case "eq":
370
- return value;
576
+ return ModelUtils.coerceOperand(operand, modelClass, property, user);
371
577
  case "gt":
372
- return { $gt: value };
578
+ return { $gt: ModelUtils.coerceOperand(operand, modelClass, property, user) };
373
579
  case "gte":
374
- return { $gte: value };
580
+ return { $gte: ModelUtils.coerceOperand(operand, modelClass, property, user) };
375
581
  case "in": {
376
- // Split the raw match text, not `value`: a comma-joined list like "1,5" can itself parse
377
- // as a valid `Date` above (e.g. "1,5" -> Jan 5), leaving `value` a Date with no `.split()`.
378
- const args = matches[2].split(",");
582
+ const args = operand
583
+ .split(",")
584
+ .map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
379
585
  return { $in: args };
380
586
  }
381
587
  case "nin": {
382
- // See "in" above for why this splits `matches[2]` instead of `value`.
383
- const args = matches[2].split(",");
588
+ const args = operand
589
+ .split(",")
590
+ .map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
384
591
  return { $nin: args };
385
592
  }
386
593
  case "like": {
387
- const pattern = String(value);
388
- if (ModelUtils.isUnsafeRegexPattern(pattern)) {
594
+ const pattern = ModelUtils.globToRegExpSource(operand);
595
+ return { $regex: pattern, $options: "i" };
596
+ }
597
+ case "regex": {
598
+ if (ModelUtils.isUnsafeRegexPattern(operand)) {
389
599
  throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
390
600
  }
391
- return { $regex: pattern, $options: "i" };
601
+ return { $regex: operand, $options: "i" };
392
602
  }
393
603
  case "lt":
394
- return { $lt: value };
604
+ return { $lt: ModelUtils.coerceOperand(operand, modelClass, property, user) };
395
605
  case "lte":
396
- return { $lte: value };
606
+ return { $lte: ModelUtils.coerceOperand(operand, modelClass, property, user) };
397
607
  case "ne":
398
- return { $ne: value };
399
- case "not":
400
- return { $not: value };
608
+ return { $ne: ModelUtils.coerceOperand(operand, modelClass, property, user) };
609
+ case "not": {
610
+ // MongoDB's `$not` accepts only an operator expression or a regex - a bare scalar (e.g.
611
+ // `{ $not: "somestring" }`) is rejected by the server rather than matching zero rows.
612
+ // Compile scalar negation to `$ne` instead, reserving `$not` for an actual `RegExp`
613
+ // operand (kept for defensiveness; `like()`/`regex()` above compile to `$regex` objects,
614
+ // not live `RegExp` instances, so this branch is not normally reached in practice).
615
+ const value = ModelUtils.coerceOperand(operand, modelClass, property, user);
616
+ return value instanceof RegExp ? { $not: value } : { $ne: value };
617
+ }
401
618
  case "range": {
402
- // See "in" above for why this splits `matches[2]` instead of `value`.
403
- const args = matches[2].split(",");
619
+ const args = operand.split(",");
404
620
  if (args.length !== 2) {
405
621
  const msg = StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_RANGE, {
406
- value,
622
+ value: operand,
407
623
  length: args.length,
408
624
  });
409
625
  throw new ApiError(ApiErrors.SEARCH_INVALID_RANGE, 400, msg);
410
626
  }
411
- // Attempt to parse the range values to native types, falling back to the raw strings
412
- let gte = args[0];
413
- let lte = args[1];
414
- try {
415
- gte = JSON.parse(args[0]);
416
- lte = JSON.parse(args[1]);
417
- }
418
- catch (err) {
419
- // Not valid JSON — fall back to the raw strings assigned above.
420
- }
421
- ModelUtils.assertNoOperatorInjection(gte);
422
- ModelUtils.assertNoOperatorInjection(lte);
627
+ const gte = ModelUtils.coerceOperand(args[0], modelClass, property, user);
628
+ const lte = ModelUtils.coerceOperand(args[1], modelClass, property, user);
423
629
  return { $gte: gte, $lte: lte };
424
630
  }
631
+ case "exists": {
632
+ const wantsExists = operand.trim().toLowerCase() === "true";
633
+ return { $exists: wantsExists };
634
+ }
425
635
  default:
426
- return value;
636
+ // Unreachable: opName was already validated against KNOWN_OPERATORS above.
637
+ throw new Error(`Unhandled search operator: ${opName}`);
427
638
  }
428
639
  }
429
640
  else {
430
- // Attempt to parse the value to a native type, falling back to a date/string if it's not valid JSON
431
- let parsed;
432
- try {
433
- parsed = JSON.parse(param);
641
+ const coerced = ModelUtils.coerceOperand(param, modelClass, property, user);
642
+ if (!exactMatch && typeof coerced === "string") {
643
+ return { $regex: StringUtils.escapeRegExp(coerced), $options: "i" };
434
644
  }
435
- catch (err) {
436
- const date = new Date(param);
437
- return !isNaN(date.valueOf()) ? date : param;
438
- }
439
- ModelUtils.assertNoOperatorInjection(parsed);
440
- return parsed;
645
+ return coerced;
441
646
  }
442
647
  }
443
648
  else {
@@ -450,6 +655,50 @@ export class ModelUtils {
450
655
  return param;
451
656
  }
452
657
  }
658
+ /**
659
+ * Extracts the `$match` stage from either shape `buildSearchQueryMongo` can return (a pipeline array or a
660
+ * flattened `{$match, $sort}` object).
661
+ */
662
+ static extractMatch(pipelineOrObject) {
663
+ if (Array.isArray(pipelineOrObject)) {
664
+ return pipelineOrObject.length > 0 ? pipelineOrObject[0]["$match"] : undefined;
665
+ }
666
+ return pipelineOrObject ? pipelineOrObject["$match"] : undefined;
667
+ }
668
+ /**
669
+ * Normalizes the return value of `buildSearchQueryMongo` to a single shape: a full aggregation pipeline.
670
+ * `buildSearchQueryMongo` itself still returns either a pipeline array or a flattened `{$match, $sort}`
671
+ * object depending on how many stages it produced (existing callers, e.g. `RepoUtils`, already branch on
672
+ * `Array.isArray()` to handle both) - use this instead at any new call site that wants one consistent shape.
673
+ */
674
+ static toFindQuery(pipelineOrObject) {
675
+ if (Array.isArray(pipelineOrObject)) {
676
+ return pipelineOrObject;
677
+ }
678
+ const stages = [];
679
+ if (pipelineOrObject?.$match !== undefined) {
680
+ stages.push({ $match: pipelineOrObject.$match });
681
+ }
682
+ if (pipelineOrObject?.$sort !== undefined) {
683
+ stages.push({ $sort: pipelineOrObject.$sort });
684
+ }
685
+ return stages;
686
+ }
687
+ /**
688
+ * Resolves the `limit`/`page` reserved query parameters to a bounded `take`/`skip` pair, applying the same
689
+ * default (`DEFAULT_PAGE_SIZE`) and ceiling (`MAX_PAGE_SIZE`) that `buildSearchQuerySQL` already bakes into
690
+ * its own return value (as `take`/`page`). `buildSearchQueryMongo` does NOT bake pagination into its own
691
+ * pipeline - doing so would execute as `$skip`/`$limit` aggregation stages, which would double up with (and
692
+ * corrupt) any cursor-level `.skip()/.limit()` a caller applies on top, as `RepoUtils` already does for its
693
+ * own route-level pagination. A caller building a Mongo query directly - rather than going through
694
+ * `RepoUtils` - should call this explicitly to get the same bounded pagination the SQL path enforces
695
+ * automatically, rather than an unbounded result set.
696
+ */
697
+ static resolvePagination(query = {}) {
698
+ const take = query?.limit ? Math.min(Number(query.limit), MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE;
699
+ const page = query?.page ? Number(query.page) : 0;
700
+ return { take, page, skip: page * take };
701
+ }
453
702
  /**
454
703
  * Builds a query object for the given criteria and repository. Query params can have a value containing a
455
704
  * conditional operator to apply for the search. The operator is encoded with the format `op(value)`. The following
@@ -458,13 +707,22 @@ export class ModelUtils {
458
707
  * * `gt` - Returns matches whose parameter is greater than the given value. e.g. `param > value`
459
708
  * * `gte` - Returns matches whose parameter is greater than or equal to the given value. e.g. `param >= value`
460
709
  * * `in` - Returns matches whose parameter includes one of the given values. e.g. `param in ('value1', 'value2', 'value3', ...)`
461
- * * `like` - Returns matches whose parameter is lexographically similar to the given value. `param like value`
710
+ * * `like` - Returns matches whose parameter matches the given glob pattern (`*` = any sequence, `?` = any single character), case-insensitively. e.g. `like(*.txt)`
711
+ * * `regex` - Returns matches whose parameter matches the given regular expression, case-insensitively.
462
712
  * * `lt` - Returns matches whose parameter is less than the given value. e.g. `param < value`
463
713
  * * `lte` - Returns matches whose parameter is less than or equal to than the given value. e.g. `param < value`
464
- * * `not` - Returns matches whose parameter is not equal to the given value. e.g. `param not value`
714
+ * * `not` / `ne` - Returns matches whose parameter is not equal to the given value. e.g. `param != value`
465
715
  * * `range` - Returns matches whose parameter is greater than or equal to first given value and less than or equal to the second. e.g. `param between(1,100)`
716
+ * * `exists` - Returns matches whose parameter is (`exists(true)`) or is not (`exists(false)`) set.
466
717
  *
467
- * When no operator is provided the comparison will always be evaluated as `eq`.
718
+ * When no operator is provided the comparison is evaluated as `eq`, unless `exactMatch` is `false`, in which
719
+ * case a string-valued parameter is instead matched as a case-insensitive "contains" search.
720
+ *
721
+ * A repeated query parameter name (e.g. `?a=1&a=2`) OR-combines its values, "zipped" positionally against
722
+ * every other repeated parameter rather than as a cartesian product: `?a=1&a=2&b=3&b=4` compiles to
723
+ * `(a=1 AND b=3) OR (a=2 AND b=4)`, not `a IN (1,2)` and not all four combinations. A shorter array is padded
724
+ * by repeating its own last value against the longer one(s), rather than leaving the key unset for the extra
725
+ * branches (which would match ANY value there, silently dropping that filter).
468
726
  *
469
727
  * NOTE: The result of this function is only compatible with the `aggregate()` function when MongoDB is used.
470
728
  *
@@ -487,50 +745,44 @@ export class ModelUtils {
487
745
  return ModelUtils.buildSearchQueryMongo(modelClass, query, exactMatch, user);
488
746
  }
489
747
  else {
490
- return ModelUtils.buildSearchQuerySQL(modelClass, query, exactMatch, user);
748
+ const driverType = repo?.manager?.connection?.options?.type;
749
+ return ModelUtils.buildSearchQuerySQL(modelClass, query, exactMatch, user, driverType);
491
750
  }
492
751
  }
493
752
  /**
494
- * Builds a TypeORM compatible query object for the given criteria. Query params can have a value containing a
495
- * conditional operator to apply for the search. The operator is encoded with the format `op(value)`. The following
496
- * operators are supported:
497
- * * `eq` - Returns matches whose parameter exactly matches of the given value. e.g. `param = value`
498
- * * `gt` - Returns matches whose parameter is greater than the given value. e.g. `param > value`
499
- * * `gte` - Returns matches whose parameter is greater than or equal to the given value. e.g. `param >= value`
500
- * * `in` - Returns matches whose parameter includes one of the given values. e.g. `param in ('value1', 'value2', 'value3', ...)`
501
- * * `like` - Returns matches whose parameter is lexographically similar to the given value. `param like value`
502
- * * `lt` - Returns matches whose parameter is less than the given value. e.g. `param < value`
503
- * * `lte` - Returns matches whose parameter is less than or equal to than the given value. e.g. `param < value`
504
- * * `not` - Returns matches whose parameter is not equal to the given value. e.g. `param not value`
505
- * * `range` - Returns matches whose parameter is greater than or equal to first given value and less than or equal to the second. e.g. `param between(1,100)`
753
+ * Builds a TypeORM compatible query object for the given criteria. See `buildSearchQuery` for the supported
754
+ * `op(value)` operators and multi-value "zip" semantics.
506
755
  *
507
- * When no operator is provided the comparison will always be evaluated as `eq`.
756
+ * Unlike `buildSearchQuery` (which always injects a `deleted: false` filter for a `RecoverableBaseEntity`
757
+ * before delegating here), this function applies no soft-delete filtering of its own - a caller invoking it
758
+ * directly, bypassing `buildSearchQuery`, will not get that default exclusion.
508
759
  *
509
760
  * @param modelClass The class definition of the data model to build a search query for.
510
761
  * @param {any} query The search query parameters to include.
511
762
  * @param {bool} exactMatch Set to true to create a query where parameters are to be matched exactly, otherwise set to false to use a 'contains' search.
512
763
  * @param {any} user The user that is performing the request.
764
+ * @param {string} driverType The TypeORM driver type (`connection.options.type`) of the target datasource, used
765
+ * to select a compatible SQL translation for the `regex()` operator. Only needed when `regex()` may appear in
766
+ * `query`.
767
+ * @param {number} depth Internal recursion-depth counter for nested `$or` groups - do not pass explicitly.
513
768
  * @returns {object} The TypeORM compatible query object.
514
769
  */
515
- static buildSearchQuerySQL(modelClass, query = {}, exactMatch = false, user) {
770
+ static buildSearchQuerySQL(modelClass, query = {}, exactMatch = false, user, driverType, depth = 0) {
771
+ if (depth > MAX_QUERY_DEPTH) {
772
+ throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
773
+ }
516
774
  const result = {};
517
775
  result.where = [];
518
- // Pre-process any query values (e.g. the `me` identifier)
519
- for (const key in query) {
520
- // If the value is 'me' that's a special keyword to reference the user ID.
521
- if (query[key] === "me") {
522
- if (!user) {
523
- throw new ApiError(ApiErrors.SEARCH_INVALID_ME_REFERENCE, 403, ApiErrorMessages.SEARCH_INVALID_ME_REFERENCE);
524
- }
525
- query[key] = user.uid;
526
- }
527
- }
776
+ const sortableFields = ModelUtils.getSortablePropertyNames(modelClass);
528
777
  // Query parameters can be a single value or multiple. In the case of multiple we want to perform an OR
529
778
  // operation for each value. But to do that we need to build a separate object for each value containing all
530
779
  // the parameters as well.
531
780
  // So first let's find out how many queries in total we are going to need.
532
781
  let numQueries = 1;
533
782
  for (const key in query) {
783
+ if (key === "$or" || key.match(REGEX_RESERVED_QUERY_PARAMS) || key.match(REGEX_QUERY_LIMITS)) {
784
+ continue;
785
+ }
534
786
  const value = query[key];
535
787
  if (Array.isArray(value)) {
536
788
  if (value.length > numQueries) {
@@ -542,6 +794,10 @@ export class ModelUtils {
542
794
  // Now go through each query paramater. If the parameter is a single value, add it to each query object. If it's an array,
543
795
  // add only one value to each query object.
544
796
  for (let key in query) {
797
+ // `$or` is composed after the main loop, cross-producted against everything else built here.
798
+ if (key === "$or") {
799
+ continue;
800
+ }
545
801
  // Ignore reserved query parameters
546
802
  if (key.match(REGEX_RESERVED_QUERY_PARAMS)) {
547
803
  continue;
@@ -563,10 +819,19 @@ export class ModelUtils {
563
819
  value = JSON.parse(value);
564
820
  }
565
821
  else {
566
- let newValue = value;
567
- newValue = {};
568
- newValue[value] = "ASC";
569
- value = newValue;
822
+ // Supports the conventional `sort=-fieldName` shorthand for descending order.
823
+ const descending = value.startsWith("-");
824
+ const field = descending ? value.slice(1) : value;
825
+ value = { [field]: descending ? "DESC" : "ASC" };
826
+ }
827
+ }
828
+ if (sortableFields) {
829
+ for (const sortKey of Object.keys(value)) {
830
+ if (!sortableFields.has(sortKey)) {
831
+ throw new ApiError(ApiErrors.SEARCH_INVALID_SORT_FIELD, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_SORT_FIELD, {
832
+ field: sortKey,
833
+ }));
834
+ }
570
835
  }
571
836
  }
572
837
  result[key] = value;
@@ -584,7 +849,7 @@ export class ModelUtils {
584
849
  result.where[i] = {};
585
850
  }
586
851
  const value = i < values.length ? values[i] : values[values.length - 1];
587
- result.where[i][key] = ModelUtils.getQueryParamValue(value);
852
+ result.where[i][key] = ModelUtils.getQueryParamValue(value, modelClass, key, user, exactMatch, driverType);
588
853
  }
589
854
  }
590
855
  else {
@@ -593,37 +858,57 @@ export class ModelUtils {
593
858
  if (!result.where[i]) {
594
859
  result.where[i] = {};
595
860
  }
596
- result.where[i][key] = ModelUtils.getQueryParamValue(query[key]);
861
+ result.where[i][key] = ModelUtils.getQueryParamValue(query[key], modelClass, key, user, exactMatch, driverType);
597
862
  }
598
863
  }
599
864
  }
865
+ // A `$or` key is composed via a distinct pass, after every other key: since a TypeORM `find()`-based
866
+ // `where` only supports OR as a top-level array (no nested-OR expressible within one branch), each
867
+ // sub-query's own OR-branches are cross-producted (distributed) against the branches already built above
868
+ // - (A) AND ($or: [X,Y]) is equivalent to (A AND X) OR (A AND Y), which composes correctly with the
869
+ // existing "zip" array regardless of processing order.
870
+ if (query.$or && Array.isArray(query.$or)) {
871
+ const branchesPerChild = [];
872
+ for (const sub of query.$or) {
873
+ const compiled = ModelUtils.buildSearchQuerySQL(modelClass, sub, exactMatch, user, driverType, depth + 1);
874
+ branchesPerChild.push(compiled.where && compiled.where.length > 0 ? compiled.where : [{}]);
875
+ }
876
+ const orBranches = [].concat(...branchesPerChild);
877
+ const base = result.where.length > 0 ? result.where : [{}];
878
+ const combined = [];
879
+ for (const existing of base) {
880
+ for (const orClause of orBranches) {
881
+ combined.push({ ...existing, ...orClause });
882
+ }
883
+ }
884
+ if (combined.length > MAX_QUERY_NODES) {
885
+ throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
886
+ }
887
+ result.where = combined;
888
+ }
600
889
  if (result.where.length === 0) {
601
890
  delete result.where;
602
891
  }
603
892
  if (result.take) {
604
- result.take = Math.min(result.take, 1000);
893
+ result.take = Math.min(result.take, MAX_PAGE_SIZE);
605
894
  }
606
895
  else {
607
- result.take = 100;
896
+ result.take = DEFAULT_PAGE_SIZE;
608
897
  }
609
898
  result.page = result.page ? result.page : 0;
610
899
  return result;
611
900
  }
612
901
  /**
613
- * Builds a MongoDB compatible query object for the given criteria. Query params can have a value containing a
614
- * conditional operator to apply for the search. The operator is encoded with the format `op(value)`. The following
615
- * operators are supported:
616
- * * `eq` - Returns matches whose parameter exactly matches of the given value. e.g. `param = value`
617
- * * `gt` - Returns matches whose parameter is greater than the given value. e.g. `param > value`
618
- * * `gte` - Returns matches whose parameter is greater than or equal to the given value. e.g. `param >= value`
619
- * * `in` - Returns matches whose parameter includes one of the given values. e.g. `param in ('value1', 'value2', 'value3', ...)`
620
- * * `like` - Returns matches whose parameter is lexographically similar to the given value. `param like value`
621
- * * `lt` - Returns matches whose parameter is less than the given value. e.g. `param < value`
622
- * * `lte` - Returns matches whose parameter is less than or equal to than the given value. e.g. `param < value`
623
- * * `not` - Returns matches whose parameter is not equal to the given value. e.g. `param not value`
624
- * * `range` - Returns matches whose parameter is greater than or equal to first given value and less than or equal to the second. e.g. `param between(1,100)`
902
+ * Builds a MongoDB compatible query object for the given criteria. See `buildSearchQuery` for the supported
903
+ * `op(value)` operators and multi-value "zip" semantics.
625
904
  *
626
- * When no operator is provided the comparison will always be evaluated as `eq`.
905
+ * Unlike `buildSearchQuery` (which always injects a `deleted: false` filter for a `RecoverableBaseEntity`
906
+ * before delegating here), this function applies no soft-delete filtering of its own - a caller invoking it
907
+ * directly, bypassing `buildSearchQuery`, will not get that default exclusion.
908
+ *
909
+ * Does NOT bound `limit`/`page` into the returned pipeline (see `resolvePagination`) and returns either an
910
+ * aggregation pipeline array or a flattened `{$match, $sort}` object depending on how many stages it
911
+ * produced (see `toFindQuery` to normalize to one shape).
627
912
  *
628
913
  * NOTE: The result of this function is only compatible with the `aggregate()` function.
629
914
  *
@@ -631,11 +916,16 @@ export class ModelUtils {
631
916
  * @param {any} query The search query parameters to include.
632
917
  * @param {bool} exactMatch Set to true to create a query where parameters are to be matched exactly, otherwise set to false to use a 'contains' search.
633
918
  * @param {any} user The user that is performing the request.
919
+ * @param {number} depth Internal recursion-depth counter for nested `$or` groups - do not pass explicitly.
634
920
  * @returns {object} The MongoDB compatible query object.
635
921
  */
636
- static buildSearchQueryMongo(modelClass, query = {}, exactMatch = false, user) {
922
+ static buildSearchQueryMongo(modelClass, query = {}, exactMatch = false, user, depth = 0) {
923
+ if (depth > MAX_QUERY_DEPTH) {
924
+ throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
925
+ }
637
926
  const queries = [{}];
638
927
  let sort = undefined;
928
+ const sortableFields = ModelUtils.getSortablePropertyNames(modelClass);
639
929
  // logger?.debug(`Query params: ${JSON.stringify(queryParams)}`);
640
930
  // Query parameters can be a single value or multiple. In the case of multiple we want to perform an OR
641
931
  // operation for each value, "zipped" together with any other multi-valued parameters (see the equivalent
@@ -659,13 +949,6 @@ export class ModelUtils {
659
949
  if (key.match(REGEX_RESERVED_QUERY_PARAMS)) {
660
950
  continue;
661
951
  }
662
- // If the value is 'me' that's a special keyword to reference the user ID.
663
- if (query[key] === "me") {
664
- if (!user) {
665
- throw new ApiError(ApiErrors.SEARCH_INVALID_ME_REFERENCE, 403, ApiErrorMessages.SEARCH_INVALID_ME_REFERENCE);
666
- }
667
- query[key] = user.uid;
668
- }
669
952
  // Limit, page and sort are reserved for specifying query limits
670
953
  if (key.match(REGEX_QUERY_LIMITS)) {
671
954
  let value = query[key];
@@ -675,10 +958,19 @@ export class ModelUtils {
675
958
  value = JSON.parse(value);
676
959
  }
677
960
  else {
678
- let newValue = value;
679
- newValue = {};
680
- newValue[value] = 1;
681
- value = newValue;
961
+ // Supports the conventional `sort=-fieldName` shorthand for descending order.
962
+ const descending = value.startsWith("-");
963
+ const field = descending ? value.slice(1) : value;
964
+ value = { [field]: descending ? -1 : 1 };
965
+ }
966
+ }
967
+ if (sortableFields) {
968
+ for (const sortKey of Object.keys(value)) {
969
+ if (!sortableFields.has(sortKey)) {
970
+ throw new ApiError(ApiErrors.SEARCH_INVALID_SORT_FIELD, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_SORT_FIELD, {
971
+ field: sortKey,
972
+ }));
973
+ }
682
974
  }
683
975
  }
684
976
  let resolvedSort = {
@@ -722,12 +1014,13 @@ export class ModelUtils {
722
1014
  // Array of OR queries
723
1015
  let orResults = [];
724
1016
  for (const q of query[key]) {
725
- const subQueryOrResult = this.buildSearchQueryMongo(modelClass, q, exactMatch, user);
726
- const validSubQueryResult = Array.isArray(subQueryOrResult) && subQueryOrResult.length > 0
727
- ? subQueryOrResult[0]["$match"]
728
- : subQueryOrResult["$match"];
1017
+ const subQueryOrResult = this.buildSearchQueryMongo(modelClass, q, exactMatch, user, depth + 1);
1018
+ const validSubQueryResult = ModelUtils.extractMatch(subQueryOrResult);
729
1019
  validSubQueryResult && orResults.push(validSubQueryResult);
730
1020
  }
1021
+ if (orResults.length > MAX_QUERY_NODES) {
1022
+ throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
1023
+ }
731
1024
  // Merge into whatever conditions earlier keys (including the injected soft-delete filter)
732
1025
  // already placed on each branch — replacing outright would silently discard them, and merging
733
1026
  // only into queries[0] would silently drop the $or constraint from any other zipped branch.
@@ -745,11 +1038,11 @@ export class ModelUtils {
745
1038
  const values = query[key];
746
1039
  for (let i = 0; i < numQueries; i++) {
747
1040
  const raw = i < values.length ? values[i] : values[values.length - 1];
748
- queries[i][key] = ModelUtils.getQueryParamValueMongo(raw);
1041
+ queries[i][key] = ModelUtils.getQueryParamValueMongo(raw, modelClass, key, user, exactMatch);
749
1042
  }
750
1043
  }
751
1044
  else {
752
- const value = ModelUtils.getQueryParamValueMongo(query[key]);
1045
+ const value = ModelUtils.getQueryParamValueMongo(query[key], modelClass, key, user, exactMatch);
753
1046
  for (let i = 0; i < numQueries; i++) {
754
1047
  queries[i][key] = value;
755
1048
  }
@@ -780,6 +1073,221 @@ export class ModelUtils {
780
1073
  }
781
1074
  return result;
782
1075
  }
1076
+ /**
1077
+ * Compiles a single `PredicateNode` leaf to a MongoDB filter fragment (`{field: ...}`).
1078
+ */
1079
+ static compilePredicateMongo(node, modelClass, user) {
1080
+ const { field, op } = node;
1081
+ switch (op) {
1082
+ case "eq":
1083
+ return { [field]: ModelUtils.coerceNodeValue(node.value, modelClass, field, user) };
1084
+ case "ne":
1085
+ return { [field]: { $ne: ModelUtils.coerceNodeValue(node.value, modelClass, field, user) } };
1086
+ case "gt":
1087
+ return { [field]: { $gt: ModelUtils.coerceNodeValue(node.value, modelClass, field, user) } };
1088
+ case "gte":
1089
+ return { [field]: { $gte: ModelUtils.coerceNodeValue(node.value, modelClass, field, user) } };
1090
+ case "lt":
1091
+ return { [field]: { $lt: ModelUtils.coerceNodeValue(node.value, modelClass, field, user) } };
1092
+ case "lte":
1093
+ return { [field]: { $lte: ModelUtils.coerceNodeValue(node.value, modelClass, field, user) } };
1094
+ case "in": {
1095
+ const values = (Array.isArray(node.value) ? node.value : [node.value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user));
1096
+ return { [field]: { $in: values } };
1097
+ }
1098
+ case "nin": {
1099
+ const values = (Array.isArray(node.value) ? node.value : [node.value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user));
1100
+ return { [field]: { $nin: values } };
1101
+ }
1102
+ case "range": {
1103
+ if (!Array.isArray(node.value) || node.value.length !== 2) {
1104
+ throw new ApiError(ApiErrors.SEARCH_INVALID_RANGE, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_RANGE, {
1105
+ value: JSON.stringify(node.value),
1106
+ length: Array.isArray(node.value) ? node.value.length : 1,
1107
+ }));
1108
+ }
1109
+ const [lo, hi] = node.value;
1110
+ return {
1111
+ [field]: {
1112
+ $gte: ModelUtils.coerceNodeValue(lo, modelClass, field, user),
1113
+ $lte: ModelUtils.coerceNodeValue(hi, modelClass, field, user),
1114
+ },
1115
+ };
1116
+ }
1117
+ case "like": {
1118
+ const pattern = ModelUtils.globToRegExpSource(String(node.value));
1119
+ return { [field]: { $regex: pattern, $options: "i" } };
1120
+ }
1121
+ case "regex": {
1122
+ const pattern = String(node.value);
1123
+ if (ModelUtils.isUnsafeRegexPattern(pattern)) {
1124
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
1125
+ }
1126
+ return { [field]: { $regex: pattern, $options: "i" } };
1127
+ }
1128
+ case "exists":
1129
+ return { [field]: { $exists: !!node.value } };
1130
+ default:
1131
+ throw new ApiError(ApiErrors.SEARCH_UNKNOWN_OPERATOR, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_UNKNOWN_OPERATOR, {
1132
+ operator: String(node.op),
1133
+ }));
1134
+ }
1135
+ }
1136
+ static compileGroupMongo(node, modelClass, user, depth) {
1137
+ if (depth > MAX_QUERY_DEPTH) {
1138
+ throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
1139
+ }
1140
+ if (node.children.length > MAX_QUERY_NODES) {
1141
+ throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
1142
+ }
1143
+ const compiledChildren = node.children.map((child) => ModelUtils.compileNodeMongo(child, modelClass, user, depth + 1));
1144
+ const clause = node.op === "and" ? { $and: compiledChildren } : { $or: compiledChildren };
1145
+ return node.negated ? { $nor: [clause] } : clause;
1146
+ }
1147
+ static compileNodeMongo(node, modelClass, user, depth = 0) {
1148
+ return node.kind === "group"
1149
+ ? ModelUtils.compileGroupMongo(node, modelClass, user, depth)
1150
+ : ModelUtils.compilePredicateMongo(node, modelClass, user);
1151
+ }
1152
+ static compilePredicateSQLOperator(op, value, modelClass, field, user, driverType) {
1153
+ const { Equal, MoreThan, MoreThanOrEqual, In, ILike, LessThan, LessThanOrEqual, Not, Between, IsNull } = ModelUtils.orm;
1154
+ switch (op) {
1155
+ case "eq": {
1156
+ const v = ModelUtils.coerceNodeValue(value, modelClass, field, user);
1157
+ return v === null ? IsNull() : Equal(v);
1158
+ }
1159
+ case "ne": {
1160
+ const v = ModelUtils.coerceNodeValue(value, modelClass, field, user);
1161
+ return v === null ? Not(IsNull()) : Not(v);
1162
+ }
1163
+ case "gt":
1164
+ return MoreThan(ModelUtils.coerceNodeValue(value, modelClass, field, user));
1165
+ case "gte":
1166
+ return MoreThanOrEqual(ModelUtils.coerceNodeValue(value, modelClass, field, user));
1167
+ case "lt":
1168
+ return LessThan(ModelUtils.coerceNodeValue(value, modelClass, field, user));
1169
+ case "lte":
1170
+ return LessThanOrEqual(ModelUtils.coerceNodeValue(value, modelClass, field, user));
1171
+ case "in": {
1172
+ const values = (Array.isArray(value) ? value : [value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user));
1173
+ return In(values);
1174
+ }
1175
+ case "nin": {
1176
+ const values = (Array.isArray(value) ? value : [value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user));
1177
+ return Not(In(values));
1178
+ }
1179
+ case "range": {
1180
+ if (!Array.isArray(value) || value.length !== 2) {
1181
+ throw new ApiError(ApiErrors.SEARCH_INVALID_RANGE, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_RANGE, {
1182
+ value: JSON.stringify(value),
1183
+ length: Array.isArray(value) ? value.length : 1,
1184
+ }));
1185
+ }
1186
+ const [lo, hi] = value;
1187
+ return Between(ModelUtils.coerceNodeValue(lo, modelClass, field, user), ModelUtils.coerceNodeValue(hi, modelClass, field, user));
1188
+ }
1189
+ case "like":
1190
+ return ILike(ModelUtils.globToLike(String(value)));
1191
+ case "regex": {
1192
+ const pattern = String(value);
1193
+ if (ModelUtils.isUnsafeRegexPattern(pattern)) {
1194
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
1195
+ }
1196
+ return ModelUtils.compileSqlRegex(pattern, driverType);
1197
+ }
1198
+ case "exists":
1199
+ return value ? Not(IsNull()) : IsNull();
1200
+ default:
1201
+ throw new ApiError(ApiErrors.SEARCH_UNKNOWN_OPERATOR, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_UNKNOWN_OPERATOR, { operator: String(op) }));
1202
+ }
1203
+ }
1204
+ static compileNodeSQL(node, modelClass, user, driverType, depth) {
1205
+ if (depth > MAX_QUERY_DEPTH) {
1206
+ throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
1207
+ }
1208
+ if (node.kind === "predicate") {
1209
+ return [
1210
+ {
1211
+ [node.field]: ModelUtils.compilePredicateSQLOperator(node.op, node.value, modelClass, node.field, user, driverType),
1212
+ },
1213
+ ];
1214
+ }
1215
+ if (node.negated) {
1216
+ // De Morgan expansion of an arbitrarily-nested negated group isn't attempted against TypeORM's
1217
+ // `find()`-based `where` (no general boolean-algebra rewrite is implemented here) - only the Mongo
1218
+ // compiler, which can express negation natively via `$nor`, supports it.
1219
+ throw new ApiError(ApiErrors.SEARCH_OPERATOR_NOT_SUPPORTED, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_OPERATOR_NOT_SUPPORTED, {
1220
+ operator: "negated group (SQL)",
1221
+ }));
1222
+ }
1223
+ if (node.children.length > MAX_QUERY_NODES) {
1224
+ throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
1225
+ }
1226
+ const childBranches = node.children.map((child) => ModelUtils.compileNodeSQL(child, modelClass, user, driverType, depth + 1));
1227
+ let branches;
1228
+ if (node.op === "or") {
1229
+ branches = [].concat(...childBranches);
1230
+ }
1231
+ else {
1232
+ branches = childBranches.reduce((acc, branchesForChild) => {
1233
+ const combined = [];
1234
+ for (const existing of acc) {
1235
+ for (const clause of branchesForChild) {
1236
+ combined.push({ ...existing, ...clause });
1237
+ }
1238
+ }
1239
+ return combined;
1240
+ }, [{}]);
1241
+ }
1242
+ if (branches.length > MAX_QUERY_NODES) {
1243
+ throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
1244
+ }
1245
+ return branches;
1246
+ }
1247
+ /**
1248
+ * Compiles a `QueryNode` boolean tree into a query object for the given repository - the nested-condition
1249
+ * counterpart to `buildSearchQuery()`'s flat `op(value)` query-parameter form, for boolean shapes the flat
1250
+ * form can't express (e.g. `(a AND b) OR (c AND d)`, with no key forced into every branch). Reuses the same
1251
+ * operand coercion, `me` substitution and operator-injection guard as the flat form. Bounded by the same
1252
+ * `MAX_QUERY_DEPTH`/`MAX_QUERY_NODES` limits as `$or`. Negated groups are supported on MongoDB (via `$nor`)
1253
+ * but rejected against the SQL `find()`-based `where` (see `compileNodeSQL`).
1254
+ *
1255
+ * @param modelClass The class definition of the data model to build a search query for.
1256
+ * @param repo The repository to build a search query for.
1257
+ * @param node The root of the query tree.
1258
+ * @param user The user that is performing the request, resolved for any `field: "me"` predicate value.
1259
+ */
1260
+ static buildQueryFromNode(modelClass, repo, node, user) {
1261
+ if (repo instanceof MongoRepository) {
1262
+ return { $match: ModelUtils.compileNodeMongo(node, modelClass, user, 0) };
1263
+ }
1264
+ const driverType = repo?.manager?.connection?.options?.type;
1265
+ return { where: ModelUtils.compileNodeSQL(node, modelClass, user, driverType, 0) };
1266
+ }
1267
+ /**
1268
+ * Converts a `QueryNode` boolean tree into a PostgreSQL `tsquery` expression string (`AND` -> `&`, `OR` -> `|`,
1269
+ * negation -> `!`), so client input can drive full-text search without passing untrusted text straight to
1270
+ * `to_tsquery` (which throws on malformed input) while still supporting the boolean grouping
1271
+ * `websearch_to_tsquery` cannot express. Every predicate leaf's `value` is treated as a search term
1272
+ * (lexeme/phrase) regardless of its `field`/`op` - this framework has no notion of a full-text-indexed column,
1273
+ * so the caller is expected to route the resulting expression to whichever `tsvector` column it's searching,
1274
+ * e.g. `to_tsquery(ModelUtils.toTsQuery(node))`.
1275
+ */
1276
+ static toTsQuery(node) {
1277
+ if (node.kind === "predicate") {
1278
+ const term = String(node.value).replace(/'/g, "''");
1279
+ return `'${term}'`;
1280
+ }
1281
+ const joiner = node.op === "and" ? " & " : " | ";
1282
+ const inner = node.children
1283
+ .map((child) => {
1284
+ const compiled = ModelUtils.toTsQuery(child);
1285
+ return child.kind === "group" ? `(${compiled})` : compiled;
1286
+ })
1287
+ .join(joiner);
1288
+ const grouped = node.children.length > 1 ? `(${inner})` : inner;
1289
+ return node.negated ? `!${grouped}` : grouped;
1290
+ }
783
1291
  /**
784
1292
  * Loads all model schema files from the specified path and returns a map containing all the definitions.
785
1293
  *
@@ -808,6 +1316,7 @@ export class ModelUtils {
808
1316
  }
809
1317
  ModelUtils.idPropertyCache = new Map();
810
1318
  ModelUtils.readOnlyPropertyCache = new Map();
811
- /** Maximum accepted length of a client-supplied `like()` search pattern. */
812
- ModelUtils.MAX_LIKE_PATTERN_LENGTH = 100;
1319
+ ModelUtils.columnTypeCache = new Map();
1320
+ /** Maximum accepted length of a client-supplied `like()`/`regex()` search pattern. */
1321
+ ModelUtils.MAX_PATTERN_LENGTH = 100;
813
1322
  //# sourceMappingURL=ModelUtils.js.map