@rapidrest/service-core 2.0.0 → 2.1.1

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.
Files changed (85) hide show
  1. package/dist/lib/BackgroundServiceManager.js +51 -9
  2. package/dist/lib/BackgroundServiceManager.js.map +1 -1
  3. package/dist/lib/EventListenerManager.js +35 -2
  4. package/dist/lib/EventListenerManager.js.map +1 -1
  5. package/dist/lib/NetUtils.js +215 -21
  6. package/dist/lib/NetUtils.js.map +1 -1
  7. package/dist/lib/ObjectFactory.js +7 -2
  8. package/dist/lib/ObjectFactory.js.map +1 -1
  9. package/dist/lib/RateLimiter.js.map +1 -1
  10. package/dist/lib/Server.js +121 -80
  11. package/dist/lib/Server.js.map +1 -1
  12. package/dist/lib/auth/AuthMiddleware.js +160 -100
  13. package/dist/lib/auth/AuthMiddleware.js.map +1 -1
  14. package/dist/lib/auth/JWTStrategy.js +6 -2
  15. package/dist/lib/auth/JWTStrategy.js.map +1 -1
  16. package/dist/lib/database/ConnectionManager.js +50 -2
  17. package/dist/lib/database/ConnectionManager.js.map +1 -1
  18. package/dist/lib/database/DatabaseErrors.js +88 -0
  19. package/dist/lib/database/DatabaseErrors.js.map +1 -0
  20. package/dist/lib/database/MongoRepository.js +31 -3
  21. package/dist/lib/database/MongoRepository.js.map +1 -1
  22. package/dist/lib/database/MongoSchemaSync.js +7 -1
  23. package/dist/lib/database/MongoSchemaSync.js.map +1 -1
  24. package/dist/lib/database/TypeOrmSupport.js +49 -15
  25. package/dist/lib/database/TypeOrmSupport.js.map +1 -1
  26. package/dist/lib/database/index.js +1 -0
  27. package/dist/lib/database/index.js.map +1 -1
  28. package/dist/lib/decorators/PersistenceDecorators.js +23 -0
  29. package/dist/lib/decorators/PersistenceDecorators.js.map +1 -1
  30. package/dist/lib/decorators/RouteDecorators.js +4 -2
  31. package/dist/lib/decorators/RouteDecorators.js.map +1 -1
  32. package/dist/lib/http/bun/BunRouter.js +119 -9
  33. package/dist/lib/http/bun/BunRouter.js.map +1 -1
  34. package/dist/lib/http/index.js +1 -0
  35. package/dist/lib/http/index.js.map +1 -1
  36. package/dist/lib/http/session/sessionMiddleware.js +62 -14
  37. package/dist/lib/http/session/sessionMiddleware.js.map +1 -1
  38. package/dist/lib/http/types.js +10 -1
  39. package/dist/lib/http/types.js.map +1 -1
  40. package/dist/lib/http/uWS/Adapters.js +31 -13
  41. package/dist/lib/http/uWS/Adapters.js.map +1 -1
  42. package/dist/lib/http/uWS/Router.js +80 -16
  43. package/dist/lib/http/uWS/Router.js.map +1 -1
  44. package/dist/lib/http/uWS/WebSocket.js +4 -2
  45. package/dist/lib/http/uWS/WebSocket.js.map +1 -1
  46. package/dist/lib/models/ModelUtils.js +255 -81
  47. package/dist/lib/models/ModelUtils.js.map +1 -1
  48. package/dist/lib/models/RepoUtils.js +863 -266
  49. package/dist/lib/models/RepoUtils.js.map +1 -1
  50. package/dist/lib/routes/BaseAdminRoute.js +5 -4
  51. package/dist/lib/routes/BaseAdminRoute.js.map +1 -1
  52. package/dist/lib/routes/BasePushRoute.js +104 -40
  53. package/dist/lib/routes/BasePushRoute.js.map +1 -1
  54. package/dist/lib/routes/CRUDRoute.js +31 -17
  55. package/dist/lib/routes/CRUDRoute.js.map +1 -1
  56. package/dist/lib/routes/RouteUtils.js +120 -29
  57. package/dist/lib/routes/RouteUtils.js.map +1 -1
  58. package/dist/lib/security/ACLUtils.js +170 -34
  59. package/dist/lib/security/ACLUtils.js.map +1 -1
  60. package/dist/types/BackgroundServiceManager.d.ts +6 -0
  61. package/dist/types/EventListenerManager.d.ts +4 -0
  62. package/dist/types/NetUtils.d.ts +65 -6
  63. package/dist/types/RateLimiter.d.ts +4 -3
  64. package/dist/types/Server.d.ts +33 -2
  65. package/dist/types/auth/AuthMiddleware.d.ts +35 -4
  66. package/dist/types/database/ConnectionManager.d.ts +18 -0
  67. package/dist/types/database/DatabaseErrors.d.ts +26 -0
  68. package/dist/types/database/MongoRepository.d.ts +21 -2
  69. package/dist/types/database/TypeOrmSupport.d.ts +11 -2
  70. package/dist/types/database/index.d.ts +1 -0
  71. package/dist/types/decorators/PersistenceDecorators.d.ts +31 -0
  72. package/dist/types/decorators/RouteDecorators.d.ts +4 -2
  73. package/dist/types/http/bun/BunRouter.d.ts +23 -2
  74. package/dist/types/http/index.d.ts +2 -1
  75. package/dist/types/http/session/sessionMiddleware.d.ts +14 -4
  76. package/dist/types/http/types.d.ts +40 -0
  77. package/dist/types/http/uWS/Adapters.d.ts +10 -1
  78. package/dist/types/http/uWS/Router.d.ts +15 -2
  79. package/dist/types/models/ModelUtils.d.ts +96 -1
  80. package/dist/types/models/RepoUtils.d.ts +240 -4
  81. package/dist/types/routes/BasePushRoute.d.ts +5 -0
  82. package/dist/types/routes/CRUDRoute.d.ts +10 -0
  83. package/dist/types/routes/RouteUtils.d.ts +37 -1
  84. package/dist/types/security/ACLUtils.d.ts +68 -7
  85. package/package.json +1 -1
@@ -6,7 +6,9 @@ import { RecoverableBaseEntity } from "./RecoverableBaseEntity.js";
6
6
  import { ApiErrorMessages, ApiErrors } from "../ApiErrors.js";
7
7
  import { getColumnMetadata } from "../decorators/PersistenceDecorators.js";
8
8
  const logger = Logger();
9
- const REGEX_QUERY_PARAM_VALUE = new RegExp(/^([a-zA-Z]+)\((.*)\)$/, "i");
9
+ // `[\s\S]*` (rather than `.*`) so an operand containing a newline is still taken verbatim, between the first `(`
10
+ // and the final `)`, instead of silently falling through to the bare-value path.
11
+ const REGEX_QUERY_PARAM_VALUE = /^([a-zA-Z]+)\(([\s\S]*)\)$/;
10
12
  // Anchored at the start so these only match the intended reserved parameter names/prefixes and not any
11
13
  // field that merely contains one as a substring (e.g. "sortOrder", "rateLimit", "packageId", "homepage").
12
14
  const REGEX_RESERVED_QUERY_PARAMS = new RegExp("^(jwt_|oauth_|auth_|cache).*", "i");
@@ -36,12 +38,80 @@ const KNOWN_OPERATORS = new Set([
36
38
  const MAX_QUERY_DEPTH = 8;
37
39
  /** Maximum number of OR branches / predicate nodes a single query may expand to, to bound total work. */
38
40
  const MAX_QUERY_NODES = 256;
41
+ /**
42
+ * A search query value that is compared exactly as given rather than parsed as `op(value)` syntax. Create one via
43
+ * `ModelUtils.literal()`. Only code can produce one: a client's query string or `q` JSON can only ever yield plain
44
+ * strings, arrays and objects.
45
+ */
46
+ export class QueryLiteral {
47
+ constructor(value, op = "eq") {
48
+ this.op = op;
49
+ this.value = value;
50
+ Object.freeze(this);
51
+ }
52
+ /**
53
+ * Serializes under a `$`-prefixed key so a query containing a literal hashes (e.g. for `RepoUtils`' result
54
+ * cache) differently from any plain value, and so a client echoing the same JSON back is rejected by the
55
+ * query builders' operator-injection guard instead of being mistaken for a literal.
56
+ */
57
+ toJSON() {
58
+ return { $literal: { op: this.op, value: this.value } };
59
+ }
60
+ }
39
61
  /**
40
62
  * Utility class for working with data model classes.
41
63
  *
42
64
  * @author Jean-Philippe Steinmetz
43
65
  */
44
66
  export class ModelUtils {
67
+ /**
68
+ * Marks `value` as a literal search value for `buildSearchQuery()` (and so `RepoUtils.find/count/truncate`),
69
+ * so it is compared exactly as given instead of being parsed as `op(value)` syntax. Use this whenever a query
70
+ * value comes from outside the code (a client, an email header, an iCalendar UID, a display name): a raw string
71
+ * such as `ne(x)` would otherwise be read as an operator, `Support(EU)` would be rejected as an unknown
72
+ * operator, `me`/`null` would be substituted, and a comma inside an `in()` list would split the value.
73
+ *
74
+ * The value is not type-coerced, so pass it with the column's real type (number, boolean, `Date`, string).
75
+ * `null` still compiles to `IS NULL` on SQL. Object values are still checked for hidden `$`/dotted keys.
76
+ *
77
+ * ```
78
+ * repoUtils.find({ messageId: ModelUtils.literal(header) });
79
+ * repoUtils.find({ uid: ModelUtils.literal(["a,b", "c"], "in") });
80
+ * repoUtils.find({ name: ModelUtils.literal(displayName, "ne") });
81
+ * repoUtils.find({ size: ModelUtils.literal([10, 20], "range") });
82
+ * ```
83
+ *
84
+ * @param value The value to compare against. An array for `in`, `nin` and `range` (exactly two elements).
85
+ * @param op The comparison to apply. Defaults to `eq`.
86
+ */
87
+ static literal(value, op = "eq") {
88
+ return new QueryLiteral(value, op);
89
+ }
90
+ /**
91
+ * Splits the operand of a list operator (`in()`, `nin()`, `range()`) on unescaped commas. `\,` yields a literal
92
+ * comma and `\\` a literal backslash; a backslash before any other character (or at the end) is kept as is.
93
+ */
94
+ static splitListOperand(operand) {
95
+ const parts = [];
96
+ let current = "";
97
+ for (let i = 0; i < operand.length; i++) {
98
+ const ch = operand[i];
99
+ const next = operand[i + 1];
100
+ if (ch === "\\" && (next === "," || next === "\\")) {
101
+ current += next;
102
+ i++;
103
+ }
104
+ else if (ch === ",") {
105
+ parts.push(current);
106
+ current = "";
107
+ }
108
+ else {
109
+ current += ch;
110
+ }
111
+ }
112
+ parts.push(current);
113
+ return parts;
114
+ }
45
115
  /**
46
116
  * Provides the `typeorm` module to use when building SQL queries. This is called automatically when a SQL
47
117
  * datasource connection is established.
@@ -327,16 +397,20 @@ export class ModelUtils {
327
397
  return raw;
328
398
  }
329
399
  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 }));
400
+ return new ApiError(ApiErrors.SEARCH_INVALID_OPERAND_TYPE, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_OPERAND_TYPE, {
401
+ value: raw,
402
+ field: property,
403
+ type,
404
+ }));
331
405
  }
332
406
  /**
333
407
  * Coerces an already-typed AST predicate value (see `QueryNode`): a string operand is routed through
334
408
  * `coerceOperand` (type coercion, `me` substitution, injection guard) exactly like the flat `op(value)`
335
409
  * 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.
410
+ * hidden operator/dotted key. With `literal` set, a string operand is kept exactly as given too.
337
411
  */
338
- static coerceNodeValue(value, modelClass, property, user) {
339
- if (typeof value === "string") {
412
+ static coerceNodeValue(value, modelClass, property, user, literal = false) {
413
+ if (typeof value === "string" && !literal) {
340
414
  return ModelUtils.coerceOperand(value, modelClass, property, user);
341
415
  }
342
416
  ModelUtils.assertNoOperatorInjection(value);
@@ -432,16 +506,18 @@ export class ModelUtils {
432
506
  */
433
507
  static compileSqlRegex(pattern, driverType) {
434
508
  const { Raw } = ModelUtils.orm;
509
+ // TypeORM registers a `Raw()` expression's named parameters query-wide, so two regex() conditions in one
510
+ // query (on different fields, or ANDed on the same field) must not share a parameter name.
511
+ const name = `rrst_regex_${++ModelUtils.rawParamSeq}`;
435
512
  switch (driverType) {
436
513
  case "postgres":
437
514
  case "cockroachdb":
438
- return Raw((alias) => `${alias} ~* :pattern`, { pattern });
515
+ return Raw((alias) => `${alias} ~* :${name}`, { [name]: pattern });
439
516
  case "mysql":
440
517
  case "mariadb":
441
- return Raw((alias) => `${alias} REGEXP :pattern`, { pattern });
442
518
  case "better-sqlite3":
443
519
  case "sqlite":
444
- return Raw((alias) => `${alias} REGEXP :pattern`, { pattern });
520
+ return Raw((alias) => `${alias} REGEXP :${name}`, { [name]: pattern });
445
521
  default:
446
522
  throw new ApiError(ApiErrors.SEARCH_OPERATOR_NOT_SUPPORTED, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_OPERATOR_NOT_SUPPORTED, { operator: "regex" }));
447
523
  }
@@ -465,9 +541,9 @@ export class ModelUtils {
465
541
  const opName = matches[1].toLowerCase();
466
542
  const operand = matches[2];
467
543
  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.
544
+ // The HTTP escape hatch is `eq(...)`: `?title=eq(Report(final))` still parses correctly here
545
+ // since REGEX_QUERY_PARAM_VALUE is greedy, so a field value that happens to look like
546
+ // `name(args)` remains searchable. Code should use `ModelUtils.literal()` instead.
471
547
  throw new ApiError(ApiErrors.SEARCH_UNKNOWN_OPERATOR, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_UNKNOWN_OPERATOR, { operator: matches[1] }));
472
548
  }
473
549
  switch (opName) {
@@ -483,9 +559,7 @@ export class ModelUtils {
483
559
  case "gte":
484
560
  return MoreThanOrEqual(ModelUtils.coerceOperand(operand, modelClass, property, user));
485
561
  case "in": {
486
- const args = operand
487
- .split(",")
488
- .map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
562
+ const args = ModelUtils.splitListOperand(operand).map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
489
563
  return In(args);
490
564
  }
491
565
  case "like":
@@ -509,13 +583,11 @@ export class ModelUtils {
509
583
  return value === null ? Not(IsNull()) : Not(value);
510
584
  }
511
585
  case "nin": {
512
- const args = operand
513
- .split(",")
514
- .map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
586
+ const args = ModelUtils.splitListOperand(operand).map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
515
587
  return Not(In(args));
516
588
  }
517
589
  case "range": {
518
- const args = operand.split(",");
590
+ const args = ModelUtils.splitListOperand(operand);
519
591
  if (args.length !== 2) {
520
592
  const msg = StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_RANGE, {
521
593
  value: operand,
@@ -544,11 +616,15 @@ export class ModelUtils {
544
616
  return coerced === null ? IsNull() : Equal(coerced);
545
617
  }
546
618
  }
619
+ else if (param instanceof QueryLiteral) {
620
+ return ModelUtils.compilePredicateSQLOperator(param.op, param.value, modelClass, property, user, driverType, true);
621
+ }
547
622
  else {
548
623
  // A non-string value only reaches here when the caller already parsed the raw query into native
549
624
  // types itself (mirrors the equivalent Mongo case below) - still validated for a hidden operator.
550
625
  ModelUtils.assertNoOperatorInjection(param);
551
- return param;
626
+ // A bare `null` in a TypeORM `where` throws by default rather than matching `IS NULL`; Mongo matches it.
627
+ return param === null ? ModelUtils.orm.IsNull() : param;
552
628
  }
553
629
  }
554
630
  /**
@@ -579,15 +655,11 @@ export class ModelUtils {
579
655
  case "gte":
580
656
  return { $gte: ModelUtils.coerceOperand(operand, modelClass, property, user) };
581
657
  case "in": {
582
- const args = operand
583
- .split(",")
584
- .map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
658
+ const args = ModelUtils.splitListOperand(operand).map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
585
659
  return { $in: args };
586
660
  }
587
661
  case "nin": {
588
- const args = operand
589
- .split(",")
590
- .map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
662
+ const args = ModelUtils.splitListOperand(operand).map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
591
663
  return { $nin: args };
592
664
  }
593
665
  case "like": {
@@ -616,7 +688,7 @@ export class ModelUtils {
616
688
  return value instanceof RegExp ? { $not: value } : { $ne: value };
617
689
  }
618
690
  case "range": {
619
- const args = operand.split(",");
691
+ const args = ModelUtils.splitListOperand(operand);
620
692
  if (args.length !== 2) {
621
693
  const msg = StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_RANGE, {
622
694
  value: operand,
@@ -645,6 +717,16 @@ export class ModelUtils {
645
717
  return coerced;
646
718
  }
647
719
  }
720
+ else if (param instanceof QueryLiteral) {
721
+ const node = {
722
+ kind: "predicate",
723
+ field: property,
724
+ op: param.op,
725
+ value: param.value,
726
+ literal: true,
727
+ };
728
+ return ModelUtils.compilePredicateMongo(node, modelClass, user)[property];
729
+ }
648
730
  else {
649
731
  // A non-string value only reaches here when the caller already parsed the raw query into native
650
732
  // types itself (e.g. the `q` base64-encoded JSON query parameter in RouteUtils.wrapMiddleware) —
@@ -699,6 +781,69 @@ export class ModelUtils {
699
781
  const page = query?.page ? Number(query.page) : 0;
700
782
  return { take, page, skip: page * take };
701
783
  }
784
+ /** Returns `true` for the boolean grouping keys (`$or`, `$and`) accepted in a flat search query. */
785
+ static isGroupKey(key) {
786
+ return key === "$or" || key === "$and";
787
+ }
788
+ /**
789
+ * Validates the value of a `$or`/`$and` key: a non-empty array (at most `MAX_QUERY_NODES` long) of plain query
790
+ * objects. Anything else is a 400 on both backends. In particular an empty array is rejected rather than
791
+ * compiled: on SQL it used to expand to zero branches, which dropped the whole `where` (every other condition
792
+ * included) and matched every row, while MongoDB rejects `$or: []` outright.
793
+ */
794
+ static assertQueryGroup(value) {
795
+ if (!Array.isArray(value) ||
796
+ value.length === 0 ||
797
+ value.some((sub) => !sub || typeof sub !== "object" || Array.isArray(sub) || sub instanceof QueryLiteral)) {
798
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
799
+ }
800
+ if (value.length > MAX_QUERY_NODES) {
801
+ throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
802
+ }
803
+ return value;
804
+ }
805
+ /**
806
+ * ANDs two lists of OR-ed SQL `where` branches: `(L1 OR L2) AND (R1 OR R2)` becomes the cross product
807
+ * `(L1 AND R1) OR (L1 AND R2) OR ...`. Throws before allocating if the product exceeds `MAX_QUERY_NODES`.
808
+ */
809
+ static andBranches(left, right) {
810
+ if (left.length * right.length > MAX_QUERY_NODES) {
811
+ throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
812
+ }
813
+ const combined = [];
814
+ for (const l of left) {
815
+ for (const r of right) {
816
+ combined.push(ModelUtils.mergeWhereBranches(l, r));
817
+ }
818
+ }
819
+ return combined;
820
+ }
821
+ /**
822
+ * ANDs two SQL `where` branch objects. A key present on only one side is copied; a key present on both keeps
823
+ * both conditions via TypeORM's `And()` (a plain object spread would let the right side silently replace the
824
+ * left, e.g. a forced scope key being overridden by a `$or` branch). Nested plain objects (embedded entities or
825
+ * relations) are merged recursively.
826
+ */
827
+ static mergeWhereBranches(left, right) {
828
+ const result = { ...left };
829
+ for (const key of Object.keys(right)) {
830
+ result[key] = key in result ? ModelUtils.andWhereValues(result[key], right[key]) : right[key];
831
+ }
832
+ return result;
833
+ }
834
+ static andWhereValues(left, right) {
835
+ const { And, Equal, IsNull, InstanceChecker } = ModelUtils.orm;
836
+ const isPlainObject = (v) => !!v && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype;
837
+ if (isPlainObject(left) && isPlainObject(right)) {
838
+ return ModelUtils.mergeWhereBranches(left, right);
839
+ }
840
+ // Flatten nested And() so repeated merges on one key stay a single flat conjunction.
841
+ const operands = (v) => {
842
+ const op = InstanceChecker.isFindOperator(v) ? v : v === null ? IsNull() : Equal(v);
843
+ return op.type === "and" ? op.value : [op];
844
+ };
845
+ return And(...operands(left), ...operands(right));
846
+ }
702
847
  /**
703
848
  * Builds a query object for the given criteria and repository. Query params can have a value containing a
704
849
  * conditional operator to apply for the search. The operator is encoded with the format `op(value)`. The following
@@ -718,6 +863,27 @@ export class ModelUtils {
718
863
  * When no operator is provided the comparison is evaluated as `eq`, unless `exactMatch` is `false`, in which
719
864
  * case a string-valued parameter is instead matched as a case-insensitive "contains" search.
720
865
  *
866
+ * Operand and escaping rules:
867
+ * * A value is only parsed as an operator when the WHOLE value has the shape `name(...)`. The operand is
868
+ * everything between the first `(` and the last `)`, verbatim: parentheses, commas, leading/trailing spaces,
869
+ * newlines and nested `op(...)` text included. So `eq(Support(EU))` matches `Support(EU)`, `eq( a,b )`
870
+ * matches ` a,b ` and `eq(ne(x))` matches `ne(x)`. The operator name is case-insensitive.
871
+ * * A bare value shaped like `name(...)` whose `name` is not a known operator is rejected with a 400; wrap it
872
+ * in `eq(...)` to match it literally.
873
+ * * `eq()`/`ne()` operands are then coerced like any operand: `me` resolves to the requesting user's uid, `null`
874
+ * matches a null value, and the value is converted to the column's declared type (number, boolean, date).
875
+ * With no column metadata a JSON/date heuristic is used instead.
876
+ * * `in()`, `nin()` and `range()` split their operand on commas. Write `\,` for a comma inside one value and
877
+ * `\\` for a backslash, e.g. `in(a\,b,c)` matches `a,b` or `c`. A backslash before any other character is
878
+ * kept as is.
879
+ * * `$or` and `$and` (only from programmatic queries or the `q` JSON parameter; a query string can't build them)
880
+ * take a non-empty array of sub-query objects that are ANDed with every other key, the same on both backends.
881
+ * A `$`-prefixed field name is rejected with a 400.
882
+ *
883
+ * Code that passes a value it doesn't control (an email header, an iCalendar UID, a display name, ...) should
884
+ * use `ModelUtils.literal(value)` instead of building an `eq(...)` string. A literal skips all of the parsing
885
+ * and coercion above. Other non-string values (numbers, booleans, `Date`, `null`) are also compared as given.
886
+ *
721
887
  * A repeated query parameter name (e.g. `?a=1&a=2`) OR-combines its values, "zipped" positionally against
722
888
  * every other repeated parameter rather than as a cartesian product: `?a=1&a=2&b=3&b=4` compiles to
723
889
  * `(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
@@ -780,7 +946,7 @@ export class ModelUtils {
780
946
  // So first let's find out how many queries in total we are going to need.
781
947
  let numQueries = 1;
782
948
  for (const key in query) {
783
- if (key === "$or" || key.match(REGEX_RESERVED_QUERY_PARAMS) || key.match(REGEX_QUERY_LIMITS)) {
949
+ if (ModelUtils.isGroupKey(key) || key.match(REGEX_RESERVED_QUERY_PARAMS) || key.match(REGEX_QUERY_LIMITS)) {
784
950
  continue;
785
951
  }
786
952
  const value = query[key];
@@ -794,8 +960,8 @@ export class ModelUtils {
794
960
  // Now go through each query paramater. If the parameter is a single value, add it to each query object. If it's an array,
795
961
  // add only one value to each query object.
796
962
  for (let key in query) {
797
- // `$or` is composed after the main loop, cross-producted against everything else built here.
798
- if (key === "$or") {
963
+ // `$or`/`$and` are composed after the main loop, cross-producted against everything else built here.
964
+ if (ModelUtils.isGroupKey(key)) {
799
965
  continue;
800
966
  }
801
967
  // Ignore reserved query parameters
@@ -838,6 +1004,11 @@ export class ModelUtils {
838
1004
  }
839
1005
  continue;
840
1006
  }
1007
+ // Same rule as the Mongo builder: a `$`-prefixed key (or path segment) is never a field, so reject it
1008
+ // with a 400 on both backends instead of letting TypeORM fail on an unknown property.
1009
+ if (key.split(".").some((segment) => segment.startsWith("$"))) {
1010
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
1011
+ }
841
1012
  if (Array.isArray(query[key])) {
842
1013
  // Add each value in the array to each corresponding query. Multi-valued keys are "zipped"
843
1014
  // together via `numQueries` above; if this key's array is shorter than another key's, pad the
@@ -862,29 +1033,28 @@ export class ModelUtils {
862
1033
  }
863
1034
  }
864
1035
  }
865
- // A `$or` key is composed via a distinct pass, after every other key: since a TypeORM `find()`-based
1036
+ // `$or`/`$and` keys are composed via a distinct pass, after every other key: since a TypeORM `find()`-based
866
1037
  // `where` only supports OR as a top-level array (no nested-OR expressible within one branch), each
867
1038
  // sub-query's own OR-branches are cross-producted (distributed) against the branches already built above
868
1039
  // - (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 : [{}]);
1040
+ // existing "zip" array regardless of processing order. Each combined branch must keep BOTH sides'
1041
+ // conditions, including on a key present on both sides (see `mergeWhereBranches`), exactly like Mongo's
1042
+ // implicit AND of a top-level key and a `$or`.
1043
+ for (const groupKey of ["$and", "$or"]) {
1044
+ if (!(groupKey in query)) {
1045
+ continue;
875
1046
  }
876
- const orBranches = [].concat(...branchesPerChild);
1047
+ const branchesPerChild = ModelUtils.assertQueryGroup(query[groupKey]).map((sub) => {
1048
+ const compiled = ModelUtils.buildSearchQuerySQL(modelClass, sub, exactMatch, user, driverType, depth + 1);
1049
+ return compiled.where ?? [{}];
1050
+ });
877
1051
  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
- }
1052
+ if (groupKey === "$or") {
1053
+ result.where = ModelUtils.andBranches(base, [].concat(...branchesPerChild));
883
1054
  }
884
- if (combined.length > MAX_QUERY_NODES) {
885
- throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
1055
+ else {
1056
+ result.where = branchesPerChild.reduce((acc, branches) => ModelUtils.andBranches(acc, branches), base);
886
1057
  }
887
- result.where = combined;
888
1058
  }
889
1059
  if (result.where.length === 0) {
890
1060
  delete result.where;
@@ -933,7 +1103,7 @@ export class ModelUtils {
933
1103
  // pre-allocate them, before any key gets applied to a subset of branches.
934
1104
  let numQueries = 1;
935
1105
  for (const key in query) {
936
- if (key === "$or" || key.match(REGEX_RESERVED_QUERY_PARAMS) || key.match(REGEX_QUERY_LIMITS)) {
1106
+ if (ModelUtils.isGroupKey(key) || key.match(REGEX_RESERVED_QUERY_PARAMS) || key.match(REGEX_QUERY_LIMITS)) {
937
1107
  continue;
938
1108
  }
939
1109
  const value = query[key];
@@ -1007,13 +1177,13 @@ export class ModelUtils {
1007
1177
  // handled just below; its sub-queries are validated recursively when they're built). Operator
1008
1178
  // injection hidden inside a *value* (e.g. `eq({"$ne":null})`) is separately guarded by
1009
1179
  // `assertNoOperatorInjection` wherever values are parsed.
1010
- if (key !== "$or" && key.split(".").some((segment) => segment.startsWith("$"))) {
1180
+ if (!ModelUtils.isGroupKey(key) && key.split(".").some((segment) => segment.startsWith("$"))) {
1011
1181
  throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
1012
1182
  }
1013
- if (key === "$or") {
1014
- // Array of OR queries
1183
+ if (ModelUtils.isGroupKey(key)) {
1184
+ // Array of OR (or AND) sub-queries
1015
1185
  let orResults = [];
1016
- for (const q of query[key]) {
1186
+ for (const q of ModelUtils.assertQueryGroup(query[key])) {
1017
1187
  const subQueryOrResult = this.buildSearchQueryMongo(modelClass, q, exactMatch, user, depth + 1);
1018
1188
  const validSubQueryResult = ModelUtils.extractMatch(subQueryOrResult);
1019
1189
  validSubQueryResult && orResults.push(validSubQueryResult);
@@ -1025,7 +1195,7 @@ export class ModelUtils {
1025
1195
  // already placed on each branch — replacing outright would silently discard them, and merging
1026
1196
  // only into queries[0] would silently drop the $or constraint from any other zipped branch.
1027
1197
  for (let i = 0; i < numQueries; i++) {
1028
- queries[i] = { ...queries[i], $or: orResults };
1198
+ queries[i] = { ...queries[i], [key]: orResults };
1029
1199
  }
1030
1200
  continue;
1031
1201
  }
@@ -1080,23 +1250,33 @@ export class ModelUtils {
1080
1250
  const { field, op } = node;
1081
1251
  switch (op) {
1082
1252
  case "eq":
1083
- return { [field]: ModelUtils.coerceNodeValue(node.value, modelClass, field, user) };
1253
+ return { [field]: ModelUtils.coerceNodeValue(node.value, modelClass, field, user, node.literal) };
1084
1254
  case "ne":
1085
- return { [field]: { $ne: ModelUtils.coerceNodeValue(node.value, modelClass, field, user) } };
1255
+ return {
1256
+ [field]: { $ne: ModelUtils.coerceNodeValue(node.value, modelClass, field, user, node.literal) },
1257
+ };
1086
1258
  case "gt":
1087
- return { [field]: { $gt: ModelUtils.coerceNodeValue(node.value, modelClass, field, user) } };
1259
+ return {
1260
+ [field]: { $gt: ModelUtils.coerceNodeValue(node.value, modelClass, field, user, node.literal) },
1261
+ };
1088
1262
  case "gte":
1089
- return { [field]: { $gte: ModelUtils.coerceNodeValue(node.value, modelClass, field, user) } };
1263
+ return {
1264
+ [field]: { $gte: ModelUtils.coerceNodeValue(node.value, modelClass, field, user, node.literal) },
1265
+ };
1090
1266
  case "lt":
1091
- return { [field]: { $lt: ModelUtils.coerceNodeValue(node.value, modelClass, field, user) } };
1267
+ return {
1268
+ [field]: { $lt: ModelUtils.coerceNodeValue(node.value, modelClass, field, user, node.literal) },
1269
+ };
1092
1270
  case "lte":
1093
- return { [field]: { $lte: ModelUtils.coerceNodeValue(node.value, modelClass, field, user) } };
1271
+ return {
1272
+ [field]: { $lte: ModelUtils.coerceNodeValue(node.value, modelClass, field, user, node.literal) },
1273
+ };
1094
1274
  case "in": {
1095
- const values = (Array.isArray(node.value) ? node.value : [node.value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user));
1275
+ const values = (Array.isArray(node.value) ? node.value : [node.value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user, node.literal));
1096
1276
  return { [field]: { $in: values } };
1097
1277
  }
1098
1278
  case "nin": {
1099
- const values = (Array.isArray(node.value) ? node.value : [node.value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user));
1279
+ const values = (Array.isArray(node.value) ? node.value : [node.value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user, node.literal));
1100
1280
  return { [field]: { $nin: values } };
1101
1281
  }
1102
1282
  case "range": {
@@ -1109,8 +1289,8 @@ export class ModelUtils {
1109
1289
  const [lo, hi] = node.value;
1110
1290
  return {
1111
1291
  [field]: {
1112
- $gte: ModelUtils.coerceNodeValue(lo, modelClass, field, user),
1113
- $lte: ModelUtils.coerceNodeValue(hi, modelClass, field, user),
1292
+ $gte: ModelUtils.coerceNodeValue(lo, modelClass, field, user, node.literal),
1293
+ $lte: ModelUtils.coerceNodeValue(hi, modelClass, field, user, node.literal),
1114
1294
  },
1115
1295
  };
1116
1296
  }
@@ -1149,31 +1329,31 @@ export class ModelUtils {
1149
1329
  ? ModelUtils.compileGroupMongo(node, modelClass, user, depth)
1150
1330
  : ModelUtils.compilePredicateMongo(node, modelClass, user);
1151
1331
  }
1152
- static compilePredicateSQLOperator(op, value, modelClass, field, user, driverType) {
1332
+ static compilePredicateSQLOperator(op, value, modelClass, field, user, driverType, literal = false) {
1153
1333
  const { Equal, MoreThan, MoreThanOrEqual, In, ILike, LessThan, LessThanOrEqual, Not, Between, IsNull } = ModelUtils.orm;
1154
1334
  switch (op) {
1155
1335
  case "eq": {
1156
- const v = ModelUtils.coerceNodeValue(value, modelClass, field, user);
1336
+ const v = ModelUtils.coerceNodeValue(value, modelClass, field, user, literal);
1157
1337
  return v === null ? IsNull() : Equal(v);
1158
1338
  }
1159
1339
  case "ne": {
1160
- const v = ModelUtils.coerceNodeValue(value, modelClass, field, user);
1340
+ const v = ModelUtils.coerceNodeValue(value, modelClass, field, user, literal);
1161
1341
  return v === null ? Not(IsNull()) : Not(v);
1162
1342
  }
1163
1343
  case "gt":
1164
- return MoreThan(ModelUtils.coerceNodeValue(value, modelClass, field, user));
1344
+ return MoreThan(ModelUtils.coerceNodeValue(value, modelClass, field, user, literal));
1165
1345
  case "gte":
1166
- return MoreThanOrEqual(ModelUtils.coerceNodeValue(value, modelClass, field, user));
1346
+ return MoreThanOrEqual(ModelUtils.coerceNodeValue(value, modelClass, field, user, literal));
1167
1347
  case "lt":
1168
- return LessThan(ModelUtils.coerceNodeValue(value, modelClass, field, user));
1348
+ return LessThan(ModelUtils.coerceNodeValue(value, modelClass, field, user, literal));
1169
1349
  case "lte":
1170
- return LessThanOrEqual(ModelUtils.coerceNodeValue(value, modelClass, field, user));
1350
+ return LessThanOrEqual(ModelUtils.coerceNodeValue(value, modelClass, field, user, literal));
1171
1351
  case "in": {
1172
- const values = (Array.isArray(value) ? value : [value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user));
1352
+ const values = (Array.isArray(value) ? value : [value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user, literal));
1173
1353
  return In(values);
1174
1354
  }
1175
1355
  case "nin": {
1176
- const values = (Array.isArray(value) ? value : [value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user));
1356
+ const values = (Array.isArray(value) ? value : [value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user, literal));
1177
1357
  return Not(In(values));
1178
1358
  }
1179
1359
  case "range": {
@@ -1184,7 +1364,7 @@ export class ModelUtils {
1184
1364
  }));
1185
1365
  }
1186
1366
  const [lo, hi] = value;
1187
- return Between(ModelUtils.coerceNodeValue(lo, modelClass, field, user), ModelUtils.coerceNodeValue(hi, modelClass, field, user));
1367
+ return Between(ModelUtils.coerceNodeValue(lo, modelClass, field, user, literal), ModelUtils.coerceNodeValue(hi, modelClass, field, user, literal));
1188
1368
  }
1189
1369
  case "like":
1190
1370
  return ILike(ModelUtils.globToLike(String(value)));
@@ -1208,7 +1388,7 @@ export class ModelUtils {
1208
1388
  if (node.kind === "predicate") {
1209
1389
  return [
1210
1390
  {
1211
- [node.field]: ModelUtils.compilePredicateSQLOperator(node.op, node.value, modelClass, node.field, user, driverType),
1391
+ [node.field]: ModelUtils.compilePredicateSQLOperator(node.op, node.value, modelClass, node.field, user, driverType, node.literal),
1212
1392
  },
1213
1393
  ];
1214
1394
  }
@@ -1229,15 +1409,7 @@ export class ModelUtils {
1229
1409
  branches = [].concat(...childBranches);
1230
1410
  }
1231
1411
  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
- }, [{}]);
1412
+ branches = childBranches.reduce((acc, branchesForChild) => ModelUtils.andBranches(acc, branchesForChild), [{}]);
1241
1413
  }
1242
1414
  if (branches.length > MAX_QUERY_NODES) {
1243
1415
  throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
@@ -1317,6 +1489,8 @@ export class ModelUtils {
1317
1489
  ModelUtils.idPropertyCache = new Map();
1318
1490
  ModelUtils.readOnlyPropertyCache = new Map();
1319
1491
  ModelUtils.columnTypeCache = new Map();
1492
+ /** Sequence used to give every `Raw()` SQL expression's named parameter a unique name within one query. */
1493
+ ModelUtils.rawParamSeq = 0;
1320
1494
  /** Maximum accepted length of a client-supplied `like()`/`regex()` search pattern. */
1321
1495
  ModelUtils.MAX_PATTERN_LENGTH = 100;
1322
1496
  //# sourceMappingURL=ModelUtils.js.map