@rapidrest/service-core 1.8.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/ApiErrors.js +10 -0
- package/dist/lib/ApiErrors.js.map +1 -1
- package/dist/lib/BackgroundServiceManager.js +51 -9
- package/dist/lib/BackgroundServiceManager.js.map +1 -1
- package/dist/lib/EventListenerManager.js +35 -2
- package/dist/lib/EventListenerManager.js.map +1 -1
- package/dist/lib/NetUtils.js +215 -21
- package/dist/lib/NetUtils.js.map +1 -1
- package/dist/lib/ObjectFactory.js +7 -2
- package/dist/lib/ObjectFactory.js.map +1 -1
- package/dist/lib/RateLimiter.js.map +1 -1
- package/dist/lib/Server.js +121 -80
- package/dist/lib/Server.js.map +1 -1
- package/dist/lib/auth/AuthMiddleware.js +160 -100
- package/dist/lib/auth/AuthMiddleware.js.map +1 -1
- package/dist/lib/auth/JWTStrategy.js +6 -2
- package/dist/lib/auth/JWTStrategy.js.map +1 -1
- package/dist/lib/database/ConnectionManager.js +47 -1
- package/dist/lib/database/ConnectionManager.js.map +1 -1
- package/dist/lib/database/DatabaseErrors.js +88 -0
- package/dist/lib/database/DatabaseErrors.js.map +1 -0
- package/dist/lib/database/MongoRepository.js +31 -3
- package/dist/lib/database/MongoRepository.js.map +1 -1
- package/dist/lib/database/MongoSchemaSync.js +7 -1
- package/dist/lib/database/MongoSchemaSync.js.map +1 -1
- package/dist/lib/database/TypeOrmSupport.js +56 -6
- package/dist/lib/database/TypeOrmSupport.js.map +1 -1
- package/dist/lib/database/index.js +1 -0
- package/dist/lib/database/index.js.map +1 -1
- package/dist/lib/decorators/PersistenceDecorators.js +23 -0
- package/dist/lib/decorators/PersistenceDecorators.js.map +1 -1
- package/dist/lib/decorators/RouteDecorators.js +4 -2
- package/dist/lib/decorators/RouteDecorators.js.map +1 -1
- package/dist/lib/http/bun/BunRouter.js +119 -9
- package/dist/lib/http/bun/BunRouter.js.map +1 -1
- package/dist/lib/http/index.js +1 -0
- package/dist/lib/http/index.js.map +1 -1
- package/dist/lib/http/session/sessionMiddleware.js +62 -14
- package/dist/lib/http/session/sessionMiddleware.js.map +1 -1
- package/dist/lib/http/types.js +10 -1
- package/dist/lib/http/types.js.map +1 -1
- package/dist/lib/http/uWS/Adapters.js +31 -13
- package/dist/lib/http/uWS/Adapters.js.map +1 -1
- package/dist/lib/http/uWS/Router.js +80 -16
- package/dist/lib/http/uWS/Router.js.map +1 -1
- package/dist/lib/http/uWS/WebSocket.js +4 -2
- package/dist/lib/http/uWS/WebSocket.js.map +1 -1
- package/dist/lib/models/ModelUtils.js +910 -227
- package/dist/lib/models/ModelUtils.js.map +1 -1
- package/dist/lib/models/RepoUtils.js +863 -266
- package/dist/lib/models/RepoUtils.js.map +1 -1
- package/dist/lib/routes/BaseAdminRoute.js +5 -4
- package/dist/lib/routes/BaseAdminRoute.js.map +1 -1
- package/dist/lib/routes/BasePushRoute.js +104 -40
- package/dist/lib/routes/BasePushRoute.js.map +1 -1
- package/dist/lib/routes/CRUDRoute.js +31 -17
- package/dist/lib/routes/CRUDRoute.js.map +1 -1
- package/dist/lib/routes/RouteUtils.js +120 -29
- package/dist/lib/routes/RouteUtils.js.map +1 -1
- package/dist/lib/security/ACLUtils.js +170 -34
- package/dist/lib/security/ACLUtils.js.map +1 -1
- package/dist/types/ApiErrors.d.ts +10 -0
- package/dist/types/BackgroundServiceManager.d.ts +6 -0
- package/dist/types/EventListenerManager.d.ts +4 -0
- package/dist/types/NetUtils.d.ts +65 -6
- package/dist/types/RateLimiter.d.ts +4 -3
- package/dist/types/Server.d.ts +33 -2
- package/dist/types/auth/AuthMiddleware.d.ts +35 -4
- package/dist/types/database/ConnectionManager.d.ts +18 -0
- package/dist/types/database/DatabaseErrors.d.ts +26 -0
- package/dist/types/database/MongoRepository.d.ts +21 -2
- package/dist/types/database/index.d.ts +1 -0
- package/dist/types/decorators/PersistenceDecorators.d.ts +31 -0
- package/dist/types/decorators/RouteDecorators.d.ts +4 -2
- package/dist/types/http/bun/BunRouter.d.ts +23 -2
- package/dist/types/http/index.d.ts +2 -1
- package/dist/types/http/session/sessionMiddleware.d.ts +14 -4
- package/dist/types/http/types.d.ts +40 -0
- package/dist/types/http/uWS/Adapters.d.ts +10 -1
- package/dist/types/http/uWS/Router.d.ts +15 -2
- package/dist/types/models/ModelUtils.d.ts +301 -47
- package/dist/types/models/RepoUtils.d.ts +240 -4
- package/dist/types/routes/BasePushRoute.d.ts +5 -0
- package/dist/types/routes/CRUDRoute.d.ts +10 -0
- package/dist/types/routes/RouteUtils.d.ts +37 -1
- package/dist/types/security/ACLUtils.d.ts +68 -7
- package/package.json +1 -1
|
@@ -4,23 +4,114 @@ 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
|
+
// `[\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]*)\)$/;
|
|
9
12
|
// Anchored at the start so these only match the intended reserved parameter names/prefixes and not any
|
|
10
13
|
// field that merely contains one as a substring (e.g. "sortOrder", "rateLimit", "packageId", "homepage").
|
|
11
14
|
const REGEX_RESERVED_QUERY_PARAMS = new RegExp("^(jwt_|oauth_|auth_|cache).*", "i");
|
|
12
15
|
const REGEX_QUERY_LIMITS = new RegExp("^(limit|page|sort)$", "i");
|
|
13
16
|
const REGEX_QUERY_SORT_STRING = new RegExp(/^\{.*\}$/, "i");
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
/** Default number of records returned by a search query when no `limit` is specified. Shared by both backends. */
|
|
18
|
+
export const DEFAULT_PAGE_SIZE = 100;
|
|
19
|
+
/** Maximum number of records a search query may request via `limit`, regardless of provider. Shared by both backends. */
|
|
20
|
+
export const MAX_PAGE_SIZE = 1000;
|
|
21
|
+
/** The operator names recognized by the `op(value)` query syntax. Anything else is rejected with a 400. */
|
|
22
|
+
const KNOWN_OPERATORS = new Set([
|
|
23
|
+
"eq",
|
|
24
|
+
"ne",
|
|
25
|
+
"not",
|
|
26
|
+
"gt",
|
|
27
|
+
"gte",
|
|
28
|
+
"lt",
|
|
29
|
+
"lte",
|
|
30
|
+
"in",
|
|
31
|
+
"nin",
|
|
32
|
+
"like",
|
|
33
|
+
"regex",
|
|
34
|
+
"range",
|
|
35
|
+
"exists",
|
|
36
|
+
]);
|
|
37
|
+
/** Maximum nesting depth accepted for a `$or` array or a `QueryNode` tree, to bound recursion. */
|
|
38
|
+
const MAX_QUERY_DEPTH = 8;
|
|
39
|
+
/** Maximum number of OR branches / predicate nodes a single query may expand to, to bound total work. */
|
|
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
|
+
}
|
|
18
61
|
/**
|
|
19
62
|
* Utility class for working with data model classes.
|
|
20
63
|
*
|
|
21
64
|
* @author Jean-Philippe Steinmetz
|
|
22
65
|
*/
|
|
23
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
|
+
}
|
|
24
115
|
/**
|
|
25
116
|
* Provides the `typeorm` module to use when building SQL queries. This is called automatically when a SQL
|
|
26
117
|
* datasource connection is established.
|
|
@@ -95,6 +186,41 @@ export class ModelUtils {
|
|
|
95
186
|
ModelUtils.readOnlyPropertyCache.set(modelClass, results);
|
|
96
187
|
return results;
|
|
97
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Resolves the declared type of a model property, from an explicit `type` override on `@Column` or (falling
|
|
191
|
+
* back) the TypeScript design-time type reflected at decoration time. Returns `undefined` when `modelClass`
|
|
192
|
+
* is not provided or declares no column metadata for `property` - callers must fall back to a heuristic in
|
|
193
|
+
* that case, the same way `coerceOperand` does.
|
|
194
|
+
*/
|
|
195
|
+
static resolvePropertyType(modelClass, property) {
|
|
196
|
+
if (!modelClass || !property) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
let byProperty = ModelUtils.columnTypeCache.get(modelClass);
|
|
200
|
+
if (!byProperty) {
|
|
201
|
+
byProperty = new Map();
|
|
202
|
+
for (const column of getColumnMetadata(modelClass)) {
|
|
203
|
+
byProperty.set(column.propertyName, column.options.type ?? column.designType);
|
|
204
|
+
}
|
|
205
|
+
ModelUtils.columnTypeCache.set(modelClass, byProperty);
|
|
206
|
+
}
|
|
207
|
+
return byProperty.get(property);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Returns the set of property names a `sort` query parameter may reference for `modelClass`, or `undefined`
|
|
211
|
+
* if `modelClass` declares no column metadata at all - in which case sort keys are accepted unvalidated
|
|
212
|
+
* (the same permissive fallback `coerceOperand` uses when no type metadata is available).
|
|
213
|
+
*/
|
|
214
|
+
static getSortablePropertyNames(modelClass) {
|
|
215
|
+
if (!modelClass) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
const columns = getColumnMetadata(modelClass);
|
|
219
|
+
if (columns.length === 0) {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
return new Set(columns.map((c) => c.propertyName));
|
|
223
|
+
}
|
|
98
224
|
/**
|
|
99
225
|
* Builds a query object for use with `find` functions of the given repository for retrieving objects matching the
|
|
100
226
|
* specified unique identifier.
|
|
@@ -190,6 +316,212 @@ export class ModelUtils {
|
|
|
190
316
|
}
|
|
191
317
|
return { $or: query };
|
|
192
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* Resolves a raw, single-value operand (already unwrapped from any `op(...)` syntax) to a properly-typed
|
|
321
|
+
* native value: `me` is substituted for the requesting user's uid, and the result is otherwise coerced
|
|
322
|
+
* according to `property`'s declared type on `modelClass` (falling back to a JSON/Date/string heuristic when
|
|
323
|
+
* no column metadata is available for it). Used for every scalar operand on both backends - including each
|
|
324
|
+
* element of `in()`/`nin()`/`range()` - so type coercion, `me` substitution and operator-injection rejection
|
|
325
|
+
* are applied uniformly everywhere a client-supplied value enters a query, on both backends.
|
|
326
|
+
*
|
|
327
|
+
* @throws {ApiError} If `raw` is `me` with no authenticated user, if a typed column rejects an unparseable
|
|
328
|
+
* operand, or if the coerced value contains a hidden MongoDB operator/dotted key.
|
|
329
|
+
*/
|
|
330
|
+
static coerceOperand(raw, modelClass, property, user) {
|
|
331
|
+
if (raw === "me") {
|
|
332
|
+
if (!user) {
|
|
333
|
+
throw new ApiError(ApiErrors.SEARCH_INVALID_ME_REFERENCE, 403, ApiErrorMessages.SEARCH_INVALID_ME_REFERENCE);
|
|
334
|
+
}
|
|
335
|
+
return user.uid;
|
|
336
|
+
}
|
|
337
|
+
const type = ModelUtils.resolvePropertyType(modelClass, property);
|
|
338
|
+
let result;
|
|
339
|
+
if (type !== undefined) {
|
|
340
|
+
result = ModelUtils.coerceToType(raw, type, property);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
// No column metadata available for this property - fall back to the legacy heuristic: try JSON
|
|
344
|
+
// (covers numbers/booleans/null/objects/arrays), then a date, then leave it as a plain string. This
|
|
345
|
+
// is the one path that still risks the "Mar 5" ambiguity (a text value that happens to look like a
|
|
346
|
+
// date gets silently reinterpreted as one) - kept only for callers that don't supply column
|
|
347
|
+
// metadata (e.g. `modelClass` is undefined, or the property isn't declared via `@Column`). A
|
|
348
|
+
// string-typed column skips the Date attempt entirely once metadata IS available - see
|
|
349
|
+
// `coerceToType`.
|
|
350
|
+
try {
|
|
351
|
+
result = JSON.parse(raw);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
const asDate = new Date(raw);
|
|
355
|
+
result = isNaN(asDate.valueOf()) ? raw : asDate;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
ModelUtils.assertNoOperatorInjection(result);
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Coerces a raw operand string to `type` (an explicit `@Column({type})` override or a reflected TypeScript
|
|
363
|
+
* design type), rejecting operands that don't parse as that type rather than silently guessing.
|
|
364
|
+
*/
|
|
365
|
+
static coerceToType(raw, type, property) {
|
|
366
|
+
if (raw === "null") {
|
|
367
|
+
// The JSON `null` literal is valid for any declared type (e.g. `eq(null)`); the eq/ne callers
|
|
368
|
+
// special-case the resulting `null` via IsNull()/$eq:null rather than this function.
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
if (type === Date || type === "date" || type === "datetime" || type === "timestamp") {
|
|
372
|
+
const value = new Date(raw);
|
|
373
|
+
if (isNaN(value.valueOf())) {
|
|
374
|
+
throw ModelUtils.invalidOperandError(raw, property, "date");
|
|
375
|
+
}
|
|
376
|
+
return value;
|
|
377
|
+
}
|
|
378
|
+
if (type === Number ||
|
|
379
|
+
["int", "integer", "float", "double", "decimal", "numeric", "bigint", "smallint", "tinyint"].includes(type)) {
|
|
380
|
+
const value = Number(raw);
|
|
381
|
+
if (raw.trim() === "" || isNaN(value)) {
|
|
382
|
+
throw ModelUtils.invalidOperandError(raw, property, "number");
|
|
383
|
+
}
|
|
384
|
+
return value;
|
|
385
|
+
}
|
|
386
|
+
if (type === Boolean || type === "boolean" || type === "bool") {
|
|
387
|
+
const lower = raw.toLowerCase();
|
|
388
|
+
if (lower === "true")
|
|
389
|
+
return true;
|
|
390
|
+
if (lower === "false")
|
|
391
|
+
return false;
|
|
392
|
+
throw ModelUtils.invalidOperandError(raw, property, "boolean");
|
|
393
|
+
}
|
|
394
|
+
// String (and any other/unrecognized declared type): never attempt Date/number parsing - this is what
|
|
395
|
+
// fixes the "Mar 5" bug, where a text search value that happened to look like a date was silently
|
|
396
|
+
// reinterpreted as one.
|
|
397
|
+
return raw;
|
|
398
|
+
}
|
|
399
|
+
static invalidOperandError(raw, 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
|
+
}));
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Coerces an already-typed AST predicate value (see `QueryNode`): a string operand is routed through
|
|
408
|
+
* `coerceOperand` (type coercion, `me` substitution, injection guard) exactly like the flat `op(value)`
|
|
409
|
+
* form; any other value is assumed to already be correctly typed by the caller and is only checked for a
|
|
410
|
+
* hidden operator/dotted key. With `literal` set, a string operand is kept exactly as given too.
|
|
411
|
+
*/
|
|
412
|
+
static coerceNodeValue(value, modelClass, property, user, literal = false) {
|
|
413
|
+
if (typeof value === "string" && !literal) {
|
|
414
|
+
return ModelUtils.coerceOperand(value, modelClass, property, user);
|
|
415
|
+
}
|
|
416
|
+
ModelUtils.assertNoOperatorInjection(value);
|
|
417
|
+
return value;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Recursively verifies that no key in the given value (at any depth, including keys of objects nested inside
|
|
421
|
+
* arrays) is a MongoDB operator (starts with `$`) or uses dot-notation field addressing (contains `.`). Client
|
|
422
|
+
* input is only ever meant to supply plain field values/comparison operands — never raw Mongo query operators —
|
|
423
|
+
* so any such key indicates an attempt to inject arbitrary query behavior (e.g. `$where`, `$expr`, or reaching
|
|
424
|
+
* into a field the API doesn't expose via dot-notation). Applied to every coerced operand on both backends -
|
|
425
|
+
* `Equal(JSON.parse(param))`-style SQL operators are constructed by TypeORM rather than interpreted from the
|
|
426
|
+
* operand directly, but a client-supplied object operand should still be rejected consistently on both
|
|
427
|
+
* backends rather than left to whatever TypeORM happens to do with it.
|
|
428
|
+
*
|
|
429
|
+
* @param value The value to check, typically a parsed query parameter.
|
|
430
|
+
* @throws {ApiError} If an operator-like or dotted key is found anywhere in `value`.
|
|
431
|
+
*/
|
|
432
|
+
static assertNoOperatorInjection(value) {
|
|
433
|
+
if (Array.isArray(value)) {
|
|
434
|
+
for (const item of value) {
|
|
435
|
+
ModelUtils.assertNoOperatorInjection(item);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
else if (value && typeof value === "object") {
|
|
439
|
+
for (const key of Object.keys(value)) {
|
|
440
|
+
if (key.startsWith("$") || key.includes(".")) {
|
|
441
|
+
throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
|
|
442
|
+
}
|
|
443
|
+
ModelUtils.assertNoOperatorInjection(value[key]);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Best-effort check for regex patterns vulnerable to catastrophic backtracking (ReDoS): patterns that are
|
|
449
|
+
* unreasonably long, that contain a quantified group whose contents are themselves quantified (e.g. `(a+)+`,
|
|
450
|
+
* `(a*)*`), or a quantified group containing alternation (e.g. `(a|a)*`, `(a|ab)*`) - both classic shapes
|
|
451
|
+
* that cause exponential backtracking in JS's (and SQLite's, since the `regex()` SQL operator is backed by a
|
|
452
|
+
* JS `RegExp` - see `registerRegexpFunction` in `TypeOrmSupport.ts`) regex engine. This is not an exhaustive
|
|
453
|
+
* defense; it catches the common cases a client would realistically send. `like()` no longer accepts raw
|
|
454
|
+
* regex (it compiles glob syntax instead - see `globToRegExpSource`), so this now guards only the explicit
|
|
455
|
+
* `regex()` operator.
|
|
456
|
+
*
|
|
457
|
+
* Public so the SQLite `REGEXP` custom function (registered per-connection in `TypeOrmSupport.ts`) can apply
|
|
458
|
+
* the same guard at query-execution time, since a pattern reaching that function didn't necessarily pass
|
|
459
|
+
* through this class's own query builders (e.g. a raw `Raw()`/QueryBuilder use elsewhere).
|
|
460
|
+
*/
|
|
461
|
+
static isUnsafeRegexPattern(pattern) {
|
|
462
|
+
if (pattern.length > ModelUtils.MAX_PATTERN_LENGTH) {
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
return /\([^()]*[+*]\)[+*{]/.test(pattern) || /\([^()]*\|[^()]*\)[+*{]/.test(pattern);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Translates a client-supplied glob pattern (`*` = any sequence, `?` = any single character) to a SQL
|
|
469
|
+
* `LIKE` pattern. Any `%`/`_` already present in the glob source is passed through unescaped (matching this
|
|
470
|
+
* operator's pre-existing behavior before glob support was added) - a client wanting to match a literal `%`
|
|
471
|
+
* or `_` cannot fully escape it, a narrow, documented limitation rather than a regression.
|
|
472
|
+
*/
|
|
473
|
+
static globToLike(glob) {
|
|
474
|
+
let result = "";
|
|
475
|
+
for (const ch of glob) {
|
|
476
|
+
if (ch === "*")
|
|
477
|
+
result += "%";
|
|
478
|
+
else if (ch === "?")
|
|
479
|
+
result += "_";
|
|
480
|
+
else
|
|
481
|
+
result += ch;
|
|
482
|
+
}
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Translates a client-supplied glob pattern into a fully-escaped, anchored regular expression source string
|
|
487
|
+
* for use with MongoDB's `$regex`, so glob syntax behaves identically on both backends.
|
|
488
|
+
*/
|
|
489
|
+
static globToRegExpSource(glob) {
|
|
490
|
+
let result = "";
|
|
491
|
+
for (const ch of glob) {
|
|
492
|
+
if (ch === "*")
|
|
493
|
+
result += ".*";
|
|
494
|
+
else if (ch === "?")
|
|
495
|
+
result += ".";
|
|
496
|
+
else
|
|
497
|
+
result += StringUtils.escapeRegExp(ch);
|
|
498
|
+
}
|
|
499
|
+
return `^${result}$`;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Compiles a validated `regex()` pattern to a driver-appropriate case-insensitive match expression. Only
|
|
503
|
+
* PostgreSQL (`~*`), MySQL/MariaDB (`REGEXP`) and the `better-sqlite3` driver (via a `REGEXP` function
|
|
504
|
+
* registered per-connection - see `registerRegexpFunction` in `TypeOrmSupport.ts`) are supported; any other
|
|
505
|
+
* driver rejects the operator outright rather than silently falling back to something incorrect.
|
|
506
|
+
*/
|
|
507
|
+
static compileSqlRegex(pattern, driverType) {
|
|
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}`;
|
|
512
|
+
switch (driverType) {
|
|
513
|
+
case "postgres":
|
|
514
|
+
case "cockroachdb":
|
|
515
|
+
return Raw((alias) => `${alias} ~* :${name}`, { [name]: pattern });
|
|
516
|
+
case "mysql":
|
|
517
|
+
case "mariadb":
|
|
518
|
+
case "better-sqlite3":
|
|
519
|
+
case "sqlite":
|
|
520
|
+
return Raw((alias) => `${alias} REGEXP :${name}`, { [name]: pattern });
|
|
521
|
+
default:
|
|
522
|
+
throw new ApiError(ApiErrors.SEARCH_OPERATOR_NOT_SUPPORTED, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_OPERATOR_NOT_SUPPORTED, { operator: "regex" }));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
193
525
|
/**
|
|
194
526
|
* Given a string containing a parameter value and/or a comparison operation return a TypeORM compatible find value.
|
|
195
527
|
* e.g.
|
|
@@ -198,7 +530,7 @@ export class ModelUtils {
|
|
|
198
530
|
*
|
|
199
531
|
* @param param
|
|
200
532
|
*/
|
|
201
|
-
static getQueryParamValue(param) {
|
|
533
|
+
static getQueryParamValue(param, modelClass, property, user, exactMatch, driverType) {
|
|
202
534
|
if (typeof param === "string") {
|
|
203
535
|
const { Equal, MoreThan, MoreThanOrEqual, In, ILike, LessThan, LessThanOrEqual, Not, Between, IsNull } = ModelUtils.orm;
|
|
204
536
|
// The value of each param can optionally have the operation included. If no operator is included Eq is
|
|
@@ -207,140 +539,103 @@ export class ModelUtils {
|
|
|
207
539
|
const matches = param.match(REGEX_QUERY_PARAM_VALUE);
|
|
208
540
|
if (matches) {
|
|
209
541
|
const opName = matches[1].toLowerCase();
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
value
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
}
|
|
542
|
+
const operand = matches[2];
|
|
543
|
+
if (!KNOWN_OPERATORS.has(opName)) {
|
|
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.
|
|
547
|
+
throw new ApiError(ApiErrors.SEARCH_UNKNOWN_OPERATOR, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_UNKNOWN_OPERATOR, { operator: matches[1] }));
|
|
221
548
|
}
|
|
222
549
|
switch (opName) {
|
|
223
|
-
case "eq":
|
|
550
|
+
case "eq": {
|
|
551
|
+
const value = ModelUtils.coerceOperand(operand, modelClass, property, user);
|
|
224
552
|
// `Equal(null)` compiles to `column = NULL`, which standard SQL NULL semantics always
|
|
225
553
|
// evaluate to unknown/false (never true), regardless of the column's actual value -
|
|
226
554
|
// TypeORM requires the dedicated `IsNull()` operator to produce `column IS NULL`.
|
|
227
555
|
return value === null ? IsNull() : Equal(value);
|
|
556
|
+
}
|
|
228
557
|
case "gt":
|
|
229
|
-
return MoreThan(
|
|
558
|
+
return MoreThan(ModelUtils.coerceOperand(operand, modelClass, property, user));
|
|
230
559
|
case "gte":
|
|
231
|
-
return MoreThanOrEqual(
|
|
560
|
+
return MoreThanOrEqual(ModelUtils.coerceOperand(operand, modelClass, property, user));
|
|
232
561
|
case "in": {
|
|
233
|
-
|
|
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(",");
|
|
562
|
+
const args = ModelUtils.splitListOperand(operand).map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
|
|
236
563
|
return In(args);
|
|
237
564
|
}
|
|
238
565
|
case "like":
|
|
239
|
-
return ILike(
|
|
566
|
+
return ILike(ModelUtils.globToLike(operand));
|
|
567
|
+
case "regex": {
|
|
568
|
+
if (ModelUtils.isUnsafeRegexPattern(operand)) {
|
|
569
|
+
throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
|
|
570
|
+
}
|
|
571
|
+
return ModelUtils.compileSqlRegex(operand, driverType);
|
|
572
|
+
}
|
|
240
573
|
case "lt":
|
|
241
|
-
return LessThan(
|
|
574
|
+
return LessThan(ModelUtils.coerceOperand(operand, modelClass, property, user));
|
|
242
575
|
case "lte":
|
|
243
|
-
return LessThanOrEqual(
|
|
576
|
+
return LessThanOrEqual(ModelUtils.coerceOperand(operand, modelClass, property, user));
|
|
244
577
|
case "ne":
|
|
245
|
-
case "not":
|
|
578
|
+
case "not": {
|
|
246
579
|
// Same NULL-semantics gap as "eq" above, mirrored: `Not(null)` compiles to
|
|
247
580
|
// `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`.
|
|
249
|
-
|
|
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).
|
|
581
|
+
// "has a value" query is `Not(IsNull())`, producing `column IS NOT NULL`.
|
|
582
|
+
const value = ModelUtils.coerceOperand(operand, modelClass, property, user);
|
|
252
583
|
return value === null ? Not(IsNull()) : Not(value);
|
|
584
|
+
}
|
|
253
585
|
case "nin": {
|
|
254
|
-
|
|
255
|
-
const args = matches[2].split(",");
|
|
586
|
+
const args = ModelUtils.splitListOperand(operand).map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
|
|
256
587
|
return Not(In(args));
|
|
257
588
|
}
|
|
258
589
|
case "range": {
|
|
259
|
-
|
|
260
|
-
const args = matches[2].split(",");
|
|
590
|
+
const args = ModelUtils.splitListOperand(operand);
|
|
261
591
|
if (args.length !== 2) {
|
|
262
592
|
const msg = StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_RANGE, {
|
|
263
|
-
value,
|
|
593
|
+
value: operand,
|
|
264
594
|
length: args.length,
|
|
265
595
|
});
|
|
266
596
|
throw new ApiError(ApiErrors.SEARCH_INVALID_RANGE, 400, msg);
|
|
267
597
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
598
|
+
const lower = ModelUtils.coerceOperand(args[0], modelClass, property, user);
|
|
599
|
+
const upper = ModelUtils.coerceOperand(args[1], modelClass, property, user);
|
|
600
|
+
return Between(lower, upper);
|
|
601
|
+
}
|
|
602
|
+
case "exists": {
|
|
603
|
+
const wantsExists = operand.trim().toLowerCase() === "true";
|
|
604
|
+
return wantsExists ? Not(IsNull()) : IsNull();
|
|
275
605
|
}
|
|
276
606
|
default:
|
|
277
|
-
|
|
607
|
+
// Unreachable: opName was already validated against KNOWN_OPERATORS above.
|
|
608
|
+
throw new Error(`Unhandled search operator: ${opName}`);
|
|
278
609
|
}
|
|
279
610
|
}
|
|
280
611
|
else {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
return
|
|
612
|
+
const coerced = ModelUtils.coerceOperand(param, modelClass, property, user);
|
|
613
|
+
if (!exactMatch && typeof coerced === "string") {
|
|
614
|
+
return ILike(`%${coerced}%`);
|
|
284
615
|
}
|
|
285
|
-
|
|
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);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
else {
|
|
293
|
-
return param;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
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);
|
|
616
|
+
return coerced === null ? IsNull() : Equal(coerced);
|
|
310
617
|
}
|
|
311
618
|
}
|
|
312
|
-
else if (
|
|
313
|
-
|
|
314
|
-
if (key.startsWith("$") || key.includes(".")) {
|
|
315
|
-
throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
|
|
316
|
-
}
|
|
317
|
-
ModelUtils.assertNoOperatorInjection(value[key]);
|
|
318
|
-
}
|
|
619
|
+
else if (param instanceof QueryLiteral) {
|
|
620
|
+
return ModelUtils.compilePredicateSQLOperator(param.op, param.value, modelClass, property, user, driverType, true);
|
|
319
621
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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;
|
|
622
|
+
else {
|
|
623
|
+
// A non-string value only reaches here when the caller already parsed the raw query into native
|
|
624
|
+
// types itself (mirrors the equivalent Mongo case below) - still validated for a hidden operator.
|
|
625
|
+
ModelUtils.assertNoOperatorInjection(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;
|
|
332
628
|
}
|
|
333
|
-
return /\([^()]*[+*]\)[+*{]/.test(pattern);
|
|
334
629
|
}
|
|
335
630
|
/**
|
|
336
631
|
* Given a string containing a parameter value and/or a comparison operation return a MongoDB compatible find value.
|
|
337
632
|
* e.g.
|
|
338
633
|
* Given the string "myvalue" will return an `"myvalue"` object.
|
|
339
|
-
* Given the string "not(myvalue)" will return an `{ $
|
|
634
|
+
* Given the string "not(myvalue)" will return an `{ $ne: "myvalue" }` object.
|
|
340
635
|
*
|
|
341
636
|
* @param param
|
|
342
637
|
*/
|
|
343
|
-
static getQueryParamValueMongo(param) {
|
|
638
|
+
static getQueryParamValueMongo(param, modelClass, property, user, exactMatch) {
|
|
344
639
|
if (typeof param === "string") {
|
|
345
640
|
// The value of each param can optionally have the operation included. If no operator is included Eq is
|
|
346
641
|
// always assumed.
|
|
@@ -348,98 +643,90 @@ export class ModelUtils {
|
|
|
348
643
|
const matches = param.match(REGEX_QUERY_PARAM_VALUE);
|
|
349
644
|
if (matches) {
|
|
350
645
|
const opName = matches[1].toLowerCase();
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
value = JSON.parse(matches[2]);
|
|
646
|
+
const operand = matches[2];
|
|
647
|
+
if (!KNOWN_OPERATORS.has(opName)) {
|
|
648
|
+
throw new ApiError(ApiErrors.SEARCH_UNKNOWN_OPERATOR, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_UNKNOWN_OPERATOR, { operator: matches[1] }));
|
|
355
649
|
}
|
|
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
|
-
}
|
|
362
|
-
}
|
|
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
650
|
switch (opName) {
|
|
369
651
|
case "eq":
|
|
370
|
-
return
|
|
652
|
+
return ModelUtils.coerceOperand(operand, modelClass, property, user);
|
|
371
653
|
case "gt":
|
|
372
|
-
return { $gt:
|
|
654
|
+
return { $gt: ModelUtils.coerceOperand(operand, modelClass, property, user) };
|
|
373
655
|
case "gte":
|
|
374
|
-
return { $gte:
|
|
656
|
+
return { $gte: ModelUtils.coerceOperand(operand, modelClass, property, user) };
|
|
375
657
|
case "in": {
|
|
376
|
-
|
|
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(",");
|
|
658
|
+
const args = ModelUtils.splitListOperand(operand).map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
|
|
379
659
|
return { $in: args };
|
|
380
660
|
}
|
|
381
661
|
case "nin": {
|
|
382
|
-
|
|
383
|
-
const args = matches[2].split(",");
|
|
662
|
+
const args = ModelUtils.splitListOperand(operand).map((raw) => ModelUtils.coerceOperand(raw, modelClass, property, user));
|
|
384
663
|
return { $nin: args };
|
|
385
664
|
}
|
|
386
665
|
case "like": {
|
|
387
|
-
const pattern =
|
|
388
|
-
|
|
666
|
+
const pattern = ModelUtils.globToRegExpSource(operand);
|
|
667
|
+
return { $regex: pattern, $options: "i" };
|
|
668
|
+
}
|
|
669
|
+
case "regex": {
|
|
670
|
+
if (ModelUtils.isUnsafeRegexPattern(operand)) {
|
|
389
671
|
throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
|
|
390
672
|
}
|
|
391
|
-
return { $regex:
|
|
673
|
+
return { $regex: operand, $options: "i" };
|
|
392
674
|
}
|
|
393
675
|
case "lt":
|
|
394
|
-
return { $lt:
|
|
676
|
+
return { $lt: ModelUtils.coerceOperand(operand, modelClass, property, user) };
|
|
395
677
|
case "lte":
|
|
396
|
-
return { $lte:
|
|
678
|
+
return { $lte: ModelUtils.coerceOperand(operand, modelClass, property, user) };
|
|
397
679
|
case "ne":
|
|
398
|
-
return { $ne:
|
|
399
|
-
case "not":
|
|
400
|
-
|
|
680
|
+
return { $ne: ModelUtils.coerceOperand(operand, modelClass, property, user) };
|
|
681
|
+
case "not": {
|
|
682
|
+
// MongoDB's `$not` accepts only an operator expression or a regex - a bare scalar (e.g.
|
|
683
|
+
// `{ $not: "somestring" }`) is rejected by the server rather than matching zero rows.
|
|
684
|
+
// Compile scalar negation to `$ne` instead, reserving `$not` for an actual `RegExp`
|
|
685
|
+
// operand (kept for defensiveness; `like()`/`regex()` above compile to `$regex` objects,
|
|
686
|
+
// not live `RegExp` instances, so this branch is not normally reached in practice).
|
|
687
|
+
const value = ModelUtils.coerceOperand(operand, modelClass, property, user);
|
|
688
|
+
return value instanceof RegExp ? { $not: value } : { $ne: value };
|
|
689
|
+
}
|
|
401
690
|
case "range": {
|
|
402
|
-
|
|
403
|
-
const args = matches[2].split(",");
|
|
691
|
+
const args = ModelUtils.splitListOperand(operand);
|
|
404
692
|
if (args.length !== 2) {
|
|
405
693
|
const msg = StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_RANGE, {
|
|
406
|
-
value,
|
|
694
|
+
value: operand,
|
|
407
695
|
length: args.length,
|
|
408
696
|
});
|
|
409
697
|
throw new ApiError(ApiErrors.SEARCH_INVALID_RANGE, 400, msg);
|
|
410
698
|
}
|
|
411
|
-
|
|
412
|
-
|
|
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);
|
|
699
|
+
const gte = ModelUtils.coerceOperand(args[0], modelClass, property, user);
|
|
700
|
+
const lte = ModelUtils.coerceOperand(args[1], modelClass, property, user);
|
|
423
701
|
return { $gte: gte, $lte: lte };
|
|
424
702
|
}
|
|
703
|
+
case "exists": {
|
|
704
|
+
const wantsExists = operand.trim().toLowerCase() === "true";
|
|
705
|
+
return { $exists: wantsExists };
|
|
706
|
+
}
|
|
425
707
|
default:
|
|
426
|
-
|
|
708
|
+
// Unreachable: opName was already validated against KNOWN_OPERATORS above.
|
|
709
|
+
throw new Error(`Unhandled search operator: ${opName}`);
|
|
427
710
|
}
|
|
428
711
|
}
|
|
429
712
|
else {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
parsed = JSON.parse(param);
|
|
713
|
+
const coerced = ModelUtils.coerceOperand(param, modelClass, property, user);
|
|
714
|
+
if (!exactMatch && typeof coerced === "string") {
|
|
715
|
+
return { $regex: StringUtils.escapeRegExp(coerced), $options: "i" };
|
|
434
716
|
}
|
|
435
|
-
|
|
436
|
-
const date = new Date(param);
|
|
437
|
-
return !isNaN(date.valueOf()) ? date : param;
|
|
438
|
-
}
|
|
439
|
-
ModelUtils.assertNoOperatorInjection(parsed);
|
|
440
|
-
return parsed;
|
|
717
|
+
return coerced;
|
|
441
718
|
}
|
|
442
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
|
+
}
|
|
443
730
|
else {
|
|
444
731
|
// A non-string value only reaches here when the caller already parsed the raw query into native
|
|
445
732
|
// types itself (e.g. the `q` base64-encoded JSON query parameter in RouteUtils.wrapMiddleware) —
|
|
@@ -450,6 +737,113 @@ export class ModelUtils {
|
|
|
450
737
|
return param;
|
|
451
738
|
}
|
|
452
739
|
}
|
|
740
|
+
/**
|
|
741
|
+
* Extracts the `$match` stage from either shape `buildSearchQueryMongo` can return (a pipeline array or a
|
|
742
|
+
* flattened `{$match, $sort}` object).
|
|
743
|
+
*/
|
|
744
|
+
static extractMatch(pipelineOrObject) {
|
|
745
|
+
if (Array.isArray(pipelineOrObject)) {
|
|
746
|
+
return pipelineOrObject.length > 0 ? pipelineOrObject[0]["$match"] : undefined;
|
|
747
|
+
}
|
|
748
|
+
return pipelineOrObject ? pipelineOrObject["$match"] : undefined;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Normalizes the return value of `buildSearchQueryMongo` to a single shape: a full aggregation pipeline.
|
|
752
|
+
* `buildSearchQueryMongo` itself still returns either a pipeline array or a flattened `{$match, $sort}`
|
|
753
|
+
* object depending on how many stages it produced (existing callers, e.g. `RepoUtils`, already branch on
|
|
754
|
+
* `Array.isArray()` to handle both) - use this instead at any new call site that wants one consistent shape.
|
|
755
|
+
*/
|
|
756
|
+
static toFindQuery(pipelineOrObject) {
|
|
757
|
+
if (Array.isArray(pipelineOrObject)) {
|
|
758
|
+
return pipelineOrObject;
|
|
759
|
+
}
|
|
760
|
+
const stages = [];
|
|
761
|
+
if (pipelineOrObject?.$match !== undefined) {
|
|
762
|
+
stages.push({ $match: pipelineOrObject.$match });
|
|
763
|
+
}
|
|
764
|
+
if (pipelineOrObject?.$sort !== undefined) {
|
|
765
|
+
stages.push({ $sort: pipelineOrObject.$sort });
|
|
766
|
+
}
|
|
767
|
+
return stages;
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Resolves the `limit`/`page` reserved query parameters to a bounded `take`/`skip` pair, applying the same
|
|
771
|
+
* default (`DEFAULT_PAGE_SIZE`) and ceiling (`MAX_PAGE_SIZE`) that `buildSearchQuerySQL` already bakes into
|
|
772
|
+
* its own return value (as `take`/`page`). `buildSearchQueryMongo` does NOT bake pagination into its own
|
|
773
|
+
* pipeline - doing so would execute as `$skip`/`$limit` aggregation stages, which would double up with (and
|
|
774
|
+
* corrupt) any cursor-level `.skip()/.limit()` a caller applies on top, as `RepoUtils` already does for its
|
|
775
|
+
* own route-level pagination. A caller building a Mongo query directly - rather than going through
|
|
776
|
+
* `RepoUtils` - should call this explicitly to get the same bounded pagination the SQL path enforces
|
|
777
|
+
* automatically, rather than an unbounded result set.
|
|
778
|
+
*/
|
|
779
|
+
static resolvePagination(query = {}) {
|
|
780
|
+
const take = query?.limit ? Math.min(Number(query.limit), MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE;
|
|
781
|
+
const page = query?.page ? Number(query.page) : 0;
|
|
782
|
+
return { take, page, skip: page * take };
|
|
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
|
+
}
|
|
453
847
|
/**
|
|
454
848
|
* Builds a query object for the given criteria and repository. Query params can have a value containing a
|
|
455
849
|
* conditional operator to apply for the search. The operator is encoded with the format `op(value)`. The following
|
|
@@ -458,13 +852,43 @@ export class ModelUtils {
|
|
|
458
852
|
* * `gt` - Returns matches whose parameter is greater than the given value. e.g. `param > value`
|
|
459
853
|
* * `gte` - Returns matches whose parameter is greater than or equal to the given value. e.g. `param >= value`
|
|
460
854
|
* * `in` - Returns matches whose parameter includes one of the given values. e.g. `param in ('value1', 'value2', 'value3', ...)`
|
|
461
|
-
* * `like` - Returns matches whose parameter
|
|
855
|
+
* * `like` - Returns matches whose parameter matches the given glob pattern (`*` = any sequence, `?` = any single character), case-insensitively. e.g. `like(*.txt)`
|
|
856
|
+
* * `regex` - Returns matches whose parameter matches the given regular expression, case-insensitively.
|
|
462
857
|
* * `lt` - Returns matches whose parameter is less than the given value. e.g. `param < value`
|
|
463
858
|
* * `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
|
|
859
|
+
* * `not` / `ne` - Returns matches whose parameter is not equal to the given value. e.g. `param != value`
|
|
465
860
|
* * `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)`
|
|
861
|
+
* * `exists` - Returns matches whose parameter is (`exists(true)`) or is not (`exists(false)`) set.
|
|
862
|
+
*
|
|
863
|
+
* When no operator is provided the comparison is evaluated as `eq`, unless `exactMatch` is `false`, in which
|
|
864
|
+
* case a string-valued parameter is instead matched as a case-insensitive "contains" search.
|
|
466
865
|
*
|
|
467
|
-
*
|
|
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
|
+
*
|
|
887
|
+
* A repeated query parameter name (e.g. `?a=1&a=2`) OR-combines its values, "zipped" positionally against
|
|
888
|
+
* every other repeated parameter rather than as a cartesian product: `?a=1&a=2&b=3&b=4` compiles to
|
|
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
|
|
890
|
+
* by repeating its own last value against the longer one(s), rather than leaving the key unset for the extra
|
|
891
|
+
* branches (which would match ANY value there, silently dropping that filter).
|
|
468
892
|
*
|
|
469
893
|
* NOTE: The result of this function is only compatible with the `aggregate()` function when MongoDB is used.
|
|
470
894
|
*
|
|
@@ -487,50 +911,44 @@ export class ModelUtils {
|
|
|
487
911
|
return ModelUtils.buildSearchQueryMongo(modelClass, query, exactMatch, user);
|
|
488
912
|
}
|
|
489
913
|
else {
|
|
490
|
-
|
|
914
|
+
const driverType = repo?.manager?.connection?.options?.type;
|
|
915
|
+
return ModelUtils.buildSearchQuerySQL(modelClass, query, exactMatch, user, driverType);
|
|
491
916
|
}
|
|
492
917
|
}
|
|
493
918
|
/**
|
|
494
|
-
* Builds a TypeORM compatible query object for the given criteria.
|
|
495
|
-
*
|
|
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)`
|
|
919
|
+
* Builds a TypeORM compatible query object for the given criteria. See `buildSearchQuery` for the supported
|
|
920
|
+
* `op(value)` operators and multi-value "zip" semantics.
|
|
506
921
|
*
|
|
507
|
-
*
|
|
922
|
+
* Unlike `buildSearchQuery` (which always injects a `deleted: false` filter for a `RecoverableBaseEntity`
|
|
923
|
+
* before delegating here), this function applies no soft-delete filtering of its own - a caller invoking it
|
|
924
|
+
* directly, bypassing `buildSearchQuery`, will not get that default exclusion.
|
|
508
925
|
*
|
|
509
926
|
* @param modelClass The class definition of the data model to build a search query for.
|
|
510
927
|
* @param {any} query The search query parameters to include.
|
|
511
928
|
* @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
929
|
* @param {any} user The user that is performing the request.
|
|
930
|
+
* @param {string} driverType The TypeORM driver type (`connection.options.type`) of the target datasource, used
|
|
931
|
+
* to select a compatible SQL translation for the `regex()` operator. Only needed when `regex()` may appear in
|
|
932
|
+
* `query`.
|
|
933
|
+
* @param {number} depth Internal recursion-depth counter for nested `$or` groups - do not pass explicitly.
|
|
513
934
|
* @returns {object} The TypeORM compatible query object.
|
|
514
935
|
*/
|
|
515
|
-
static buildSearchQuerySQL(modelClass, query = {}, exactMatch = false, user) {
|
|
936
|
+
static buildSearchQuerySQL(modelClass, query = {}, exactMatch = false, user, driverType, depth = 0) {
|
|
937
|
+
if (depth > MAX_QUERY_DEPTH) {
|
|
938
|
+
throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
|
|
939
|
+
}
|
|
516
940
|
const result = {};
|
|
517
941
|
result.where = [];
|
|
518
|
-
|
|
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
|
-
}
|
|
942
|
+
const sortableFields = ModelUtils.getSortablePropertyNames(modelClass);
|
|
528
943
|
// Query parameters can be a single value or multiple. In the case of multiple we want to perform an OR
|
|
529
944
|
// operation for each value. But to do that we need to build a separate object for each value containing all
|
|
530
945
|
// the parameters as well.
|
|
531
946
|
// So first let's find out how many queries in total we are going to need.
|
|
532
947
|
let numQueries = 1;
|
|
533
948
|
for (const key in query) {
|
|
949
|
+
if (ModelUtils.isGroupKey(key) || key.match(REGEX_RESERVED_QUERY_PARAMS) || key.match(REGEX_QUERY_LIMITS)) {
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
534
952
|
const value = query[key];
|
|
535
953
|
if (Array.isArray(value)) {
|
|
536
954
|
if (value.length > numQueries) {
|
|
@@ -542,6 +960,10 @@ export class ModelUtils {
|
|
|
542
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,
|
|
543
961
|
// add only one value to each query object.
|
|
544
962
|
for (let key in query) {
|
|
963
|
+
// `$or`/`$and` are composed after the main loop, cross-producted against everything else built here.
|
|
964
|
+
if (ModelUtils.isGroupKey(key)) {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
545
967
|
// Ignore reserved query parameters
|
|
546
968
|
if (key.match(REGEX_RESERVED_QUERY_PARAMS)) {
|
|
547
969
|
continue;
|
|
@@ -563,16 +985,30 @@ export class ModelUtils {
|
|
|
563
985
|
value = JSON.parse(value);
|
|
564
986
|
}
|
|
565
987
|
else {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
value =
|
|
988
|
+
// Supports the conventional `sort=-fieldName` shorthand for descending order.
|
|
989
|
+
const descending = value.startsWith("-");
|
|
990
|
+
const field = descending ? value.slice(1) : value;
|
|
991
|
+
value = { [field]: descending ? "DESC" : "ASC" };
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
if (sortableFields) {
|
|
995
|
+
for (const sortKey of Object.keys(value)) {
|
|
996
|
+
if (!sortableFields.has(sortKey)) {
|
|
997
|
+
throw new ApiError(ApiErrors.SEARCH_INVALID_SORT_FIELD, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_SORT_FIELD, {
|
|
998
|
+
field: sortKey,
|
|
999
|
+
}));
|
|
1000
|
+
}
|
|
570
1001
|
}
|
|
571
1002
|
}
|
|
572
1003
|
result[key] = value;
|
|
573
1004
|
}
|
|
574
1005
|
continue;
|
|
575
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
|
+
}
|
|
576
1012
|
if (Array.isArray(query[key])) {
|
|
577
1013
|
// Add each value in the array to each corresponding query. Multi-valued keys are "zipped"
|
|
578
1014
|
// together via `numQueries` above; if this key's array is shorter than another key's, pad the
|
|
@@ -584,7 +1020,7 @@ export class ModelUtils {
|
|
|
584
1020
|
result.where[i] = {};
|
|
585
1021
|
}
|
|
586
1022
|
const value = i < values.length ? values[i] : values[values.length - 1];
|
|
587
|
-
result.where[i][key] = ModelUtils.getQueryParamValue(value);
|
|
1023
|
+
result.where[i][key] = ModelUtils.getQueryParamValue(value, modelClass, key, user, exactMatch, driverType);
|
|
588
1024
|
}
|
|
589
1025
|
}
|
|
590
1026
|
else {
|
|
@@ -593,37 +1029,56 @@ export class ModelUtils {
|
|
|
593
1029
|
if (!result.where[i]) {
|
|
594
1030
|
result.where[i] = {};
|
|
595
1031
|
}
|
|
596
|
-
result.where[i][key] = ModelUtils.getQueryParamValue(query[key]);
|
|
1032
|
+
result.where[i][key] = ModelUtils.getQueryParamValue(query[key], modelClass, key, user, exactMatch, driverType);
|
|
597
1033
|
}
|
|
598
1034
|
}
|
|
599
1035
|
}
|
|
1036
|
+
// `$or`/`$and` keys are composed via a distinct pass, after every other key: since a TypeORM `find()`-based
|
|
1037
|
+
// `where` only supports OR as a top-level array (no nested-OR expressible within one branch), each
|
|
1038
|
+
// sub-query's own OR-branches are cross-producted (distributed) against the branches already built above
|
|
1039
|
+
// - (A) AND ($or: [X,Y]) is equivalent to (A AND X) OR (A AND Y), which composes correctly with the
|
|
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;
|
|
1046
|
+
}
|
|
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
|
+
});
|
|
1051
|
+
const base = result.where.length > 0 ? result.where : [{}];
|
|
1052
|
+
if (groupKey === "$or") {
|
|
1053
|
+
result.where = ModelUtils.andBranches(base, [].concat(...branchesPerChild));
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
result.where = branchesPerChild.reduce((acc, branches) => ModelUtils.andBranches(acc, branches), base);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
600
1059
|
if (result.where.length === 0) {
|
|
601
1060
|
delete result.where;
|
|
602
1061
|
}
|
|
603
1062
|
if (result.take) {
|
|
604
|
-
result.take = Math.min(result.take,
|
|
1063
|
+
result.take = Math.min(result.take, MAX_PAGE_SIZE);
|
|
605
1064
|
}
|
|
606
1065
|
else {
|
|
607
|
-
result.take =
|
|
1066
|
+
result.take = DEFAULT_PAGE_SIZE;
|
|
608
1067
|
}
|
|
609
1068
|
result.page = result.page ? result.page : 0;
|
|
610
1069
|
return result;
|
|
611
1070
|
}
|
|
612
1071
|
/**
|
|
613
|
-
* Builds a MongoDB compatible query object for the given criteria.
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
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)`
|
|
1072
|
+
* Builds a MongoDB compatible query object for the given criteria. See `buildSearchQuery` for the supported
|
|
1073
|
+
* `op(value)` operators and multi-value "zip" semantics.
|
|
1074
|
+
*
|
|
1075
|
+
* Unlike `buildSearchQuery` (which always injects a `deleted: false` filter for a `RecoverableBaseEntity`
|
|
1076
|
+
* before delegating here), this function applies no soft-delete filtering of its own - a caller invoking it
|
|
1077
|
+
* directly, bypassing `buildSearchQuery`, will not get that default exclusion.
|
|
625
1078
|
*
|
|
626
|
-
*
|
|
1079
|
+
* Does NOT bound `limit`/`page` into the returned pipeline (see `resolvePagination`) and returns either an
|
|
1080
|
+
* aggregation pipeline array or a flattened `{$match, $sort}` object depending on how many stages it
|
|
1081
|
+
* produced (see `toFindQuery` to normalize to one shape).
|
|
627
1082
|
*
|
|
628
1083
|
* NOTE: The result of this function is only compatible with the `aggregate()` function.
|
|
629
1084
|
*
|
|
@@ -631,11 +1086,16 @@ export class ModelUtils {
|
|
|
631
1086
|
* @param {any} query The search query parameters to include.
|
|
632
1087
|
* @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
1088
|
* @param {any} user The user that is performing the request.
|
|
1089
|
+
* @param {number} depth Internal recursion-depth counter for nested `$or` groups - do not pass explicitly.
|
|
634
1090
|
* @returns {object} The MongoDB compatible query object.
|
|
635
1091
|
*/
|
|
636
|
-
static buildSearchQueryMongo(modelClass, query = {}, exactMatch = false, user) {
|
|
1092
|
+
static buildSearchQueryMongo(modelClass, query = {}, exactMatch = false, user, depth = 0) {
|
|
1093
|
+
if (depth > MAX_QUERY_DEPTH) {
|
|
1094
|
+
throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
|
|
1095
|
+
}
|
|
637
1096
|
const queries = [{}];
|
|
638
1097
|
let sort = undefined;
|
|
1098
|
+
const sortableFields = ModelUtils.getSortablePropertyNames(modelClass);
|
|
639
1099
|
// logger?.debug(`Query params: ${JSON.stringify(queryParams)}`);
|
|
640
1100
|
// Query parameters can be a single value or multiple. In the case of multiple we want to perform an OR
|
|
641
1101
|
// operation for each value, "zipped" together with any other multi-valued parameters (see the equivalent
|
|
@@ -643,7 +1103,7 @@ export class ModelUtils {
|
|
|
643
1103
|
// pre-allocate them, before any key gets applied to a subset of branches.
|
|
644
1104
|
let numQueries = 1;
|
|
645
1105
|
for (const key in query) {
|
|
646
|
-
if (key
|
|
1106
|
+
if (ModelUtils.isGroupKey(key) || key.match(REGEX_RESERVED_QUERY_PARAMS) || key.match(REGEX_QUERY_LIMITS)) {
|
|
647
1107
|
continue;
|
|
648
1108
|
}
|
|
649
1109
|
const value = query[key];
|
|
@@ -659,13 +1119,6 @@ export class ModelUtils {
|
|
|
659
1119
|
if (key.match(REGEX_RESERVED_QUERY_PARAMS)) {
|
|
660
1120
|
continue;
|
|
661
1121
|
}
|
|
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
1122
|
// Limit, page and sort are reserved for specifying query limits
|
|
670
1123
|
if (key.match(REGEX_QUERY_LIMITS)) {
|
|
671
1124
|
let value = query[key];
|
|
@@ -675,10 +1128,19 @@ export class ModelUtils {
|
|
|
675
1128
|
value = JSON.parse(value);
|
|
676
1129
|
}
|
|
677
1130
|
else {
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
value =
|
|
1131
|
+
// Supports the conventional `sort=-fieldName` shorthand for descending order.
|
|
1132
|
+
const descending = value.startsWith("-");
|
|
1133
|
+
const field = descending ? value.slice(1) : value;
|
|
1134
|
+
value = { [field]: descending ? -1 : 1 };
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
if (sortableFields) {
|
|
1138
|
+
for (const sortKey of Object.keys(value)) {
|
|
1139
|
+
if (!sortableFields.has(sortKey)) {
|
|
1140
|
+
throw new ApiError(ApiErrors.SEARCH_INVALID_SORT_FIELD, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_SORT_FIELD, {
|
|
1141
|
+
field: sortKey,
|
|
1142
|
+
}));
|
|
1143
|
+
}
|
|
682
1144
|
}
|
|
683
1145
|
}
|
|
684
1146
|
let resolvedSort = {
|
|
@@ -715,24 +1177,25 @@ export class ModelUtils {
|
|
|
715
1177
|
// handled just below; its sub-queries are validated recursively when they're built). Operator
|
|
716
1178
|
// injection hidden inside a *value* (e.g. `eq({"$ne":null})`) is separately guarded by
|
|
717
1179
|
// `assertNoOperatorInjection` wherever values are parsed.
|
|
718
|
-
if (key
|
|
1180
|
+
if (!ModelUtils.isGroupKey(key) && key.split(".").some((segment) => segment.startsWith("$"))) {
|
|
719
1181
|
throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
|
|
720
1182
|
}
|
|
721
|
-
if (key
|
|
722
|
-
// Array of OR queries
|
|
1183
|
+
if (ModelUtils.isGroupKey(key)) {
|
|
1184
|
+
// Array of OR (or AND) sub-queries
|
|
723
1185
|
let orResults = [];
|
|
724
|
-
for (const q of query[key]) {
|
|
725
|
-
const subQueryOrResult = this.buildSearchQueryMongo(modelClass, q, exactMatch, user);
|
|
726
|
-
const validSubQueryResult =
|
|
727
|
-
? subQueryOrResult[0]["$match"]
|
|
728
|
-
: subQueryOrResult["$match"];
|
|
1186
|
+
for (const q of ModelUtils.assertQueryGroup(query[key])) {
|
|
1187
|
+
const subQueryOrResult = this.buildSearchQueryMongo(modelClass, q, exactMatch, user, depth + 1);
|
|
1188
|
+
const validSubQueryResult = ModelUtils.extractMatch(subQueryOrResult);
|
|
729
1189
|
validSubQueryResult && orResults.push(validSubQueryResult);
|
|
730
1190
|
}
|
|
1191
|
+
if (orResults.length > MAX_QUERY_NODES) {
|
|
1192
|
+
throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
|
|
1193
|
+
}
|
|
731
1194
|
// Merge into whatever conditions earlier keys (including the injected soft-delete filter)
|
|
732
1195
|
// already placed on each branch — replacing outright would silently discard them, and merging
|
|
733
1196
|
// only into queries[0] would silently drop the $or constraint from any other zipped branch.
|
|
734
1197
|
for (let i = 0; i < numQueries; i++) {
|
|
735
|
-
queries[i] = { ...queries[i],
|
|
1198
|
+
queries[i] = { ...queries[i], [key]: orResults };
|
|
736
1199
|
}
|
|
737
1200
|
continue;
|
|
738
1201
|
}
|
|
@@ -745,11 +1208,11 @@ export class ModelUtils {
|
|
|
745
1208
|
const values = query[key];
|
|
746
1209
|
for (let i = 0; i < numQueries; i++) {
|
|
747
1210
|
const raw = i < values.length ? values[i] : values[values.length - 1];
|
|
748
|
-
queries[i][key] = ModelUtils.getQueryParamValueMongo(raw);
|
|
1211
|
+
queries[i][key] = ModelUtils.getQueryParamValueMongo(raw, modelClass, key, user, exactMatch);
|
|
749
1212
|
}
|
|
750
1213
|
}
|
|
751
1214
|
else {
|
|
752
|
-
const value = ModelUtils.getQueryParamValueMongo(query[key]);
|
|
1215
|
+
const value = ModelUtils.getQueryParamValueMongo(query[key], modelClass, key, user, exactMatch);
|
|
753
1216
|
for (let i = 0; i < numQueries; i++) {
|
|
754
1217
|
queries[i][key] = value;
|
|
755
1218
|
}
|
|
@@ -780,6 +1243,223 @@ export class ModelUtils {
|
|
|
780
1243
|
}
|
|
781
1244
|
return result;
|
|
782
1245
|
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Compiles a single `PredicateNode` leaf to a MongoDB filter fragment (`{field: ...}`).
|
|
1248
|
+
*/
|
|
1249
|
+
static compilePredicateMongo(node, modelClass, user) {
|
|
1250
|
+
const { field, op } = node;
|
|
1251
|
+
switch (op) {
|
|
1252
|
+
case "eq":
|
|
1253
|
+
return { [field]: ModelUtils.coerceNodeValue(node.value, modelClass, field, user, node.literal) };
|
|
1254
|
+
case "ne":
|
|
1255
|
+
return {
|
|
1256
|
+
[field]: { $ne: ModelUtils.coerceNodeValue(node.value, modelClass, field, user, node.literal) },
|
|
1257
|
+
};
|
|
1258
|
+
case "gt":
|
|
1259
|
+
return {
|
|
1260
|
+
[field]: { $gt: ModelUtils.coerceNodeValue(node.value, modelClass, field, user, node.literal) },
|
|
1261
|
+
};
|
|
1262
|
+
case "gte":
|
|
1263
|
+
return {
|
|
1264
|
+
[field]: { $gte: ModelUtils.coerceNodeValue(node.value, modelClass, field, user, node.literal) },
|
|
1265
|
+
};
|
|
1266
|
+
case "lt":
|
|
1267
|
+
return {
|
|
1268
|
+
[field]: { $lt: ModelUtils.coerceNodeValue(node.value, modelClass, field, user, node.literal) },
|
|
1269
|
+
};
|
|
1270
|
+
case "lte":
|
|
1271
|
+
return {
|
|
1272
|
+
[field]: { $lte: ModelUtils.coerceNodeValue(node.value, modelClass, field, user, node.literal) },
|
|
1273
|
+
};
|
|
1274
|
+
case "in": {
|
|
1275
|
+
const values = (Array.isArray(node.value) ? node.value : [node.value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user, node.literal));
|
|
1276
|
+
return { [field]: { $in: values } };
|
|
1277
|
+
}
|
|
1278
|
+
case "nin": {
|
|
1279
|
+
const values = (Array.isArray(node.value) ? node.value : [node.value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user, node.literal));
|
|
1280
|
+
return { [field]: { $nin: values } };
|
|
1281
|
+
}
|
|
1282
|
+
case "range": {
|
|
1283
|
+
if (!Array.isArray(node.value) || node.value.length !== 2) {
|
|
1284
|
+
throw new ApiError(ApiErrors.SEARCH_INVALID_RANGE, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_RANGE, {
|
|
1285
|
+
value: JSON.stringify(node.value),
|
|
1286
|
+
length: Array.isArray(node.value) ? node.value.length : 1,
|
|
1287
|
+
}));
|
|
1288
|
+
}
|
|
1289
|
+
const [lo, hi] = node.value;
|
|
1290
|
+
return {
|
|
1291
|
+
[field]: {
|
|
1292
|
+
$gte: ModelUtils.coerceNodeValue(lo, modelClass, field, user, node.literal),
|
|
1293
|
+
$lte: ModelUtils.coerceNodeValue(hi, modelClass, field, user, node.literal),
|
|
1294
|
+
},
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
case "like": {
|
|
1298
|
+
const pattern = ModelUtils.globToRegExpSource(String(node.value));
|
|
1299
|
+
return { [field]: { $regex: pattern, $options: "i" } };
|
|
1300
|
+
}
|
|
1301
|
+
case "regex": {
|
|
1302
|
+
const pattern = String(node.value);
|
|
1303
|
+
if (ModelUtils.isUnsafeRegexPattern(pattern)) {
|
|
1304
|
+
throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
|
|
1305
|
+
}
|
|
1306
|
+
return { [field]: { $regex: pattern, $options: "i" } };
|
|
1307
|
+
}
|
|
1308
|
+
case "exists":
|
|
1309
|
+
return { [field]: { $exists: !!node.value } };
|
|
1310
|
+
default:
|
|
1311
|
+
throw new ApiError(ApiErrors.SEARCH_UNKNOWN_OPERATOR, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_UNKNOWN_OPERATOR, {
|
|
1312
|
+
operator: String(node.op),
|
|
1313
|
+
}));
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
static compileGroupMongo(node, modelClass, user, depth) {
|
|
1317
|
+
if (depth > MAX_QUERY_DEPTH) {
|
|
1318
|
+
throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
|
|
1319
|
+
}
|
|
1320
|
+
if (node.children.length > MAX_QUERY_NODES) {
|
|
1321
|
+
throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
|
|
1322
|
+
}
|
|
1323
|
+
const compiledChildren = node.children.map((child) => ModelUtils.compileNodeMongo(child, modelClass, user, depth + 1));
|
|
1324
|
+
const clause = node.op === "and" ? { $and: compiledChildren } : { $or: compiledChildren };
|
|
1325
|
+
return node.negated ? { $nor: [clause] } : clause;
|
|
1326
|
+
}
|
|
1327
|
+
static compileNodeMongo(node, modelClass, user, depth = 0) {
|
|
1328
|
+
return node.kind === "group"
|
|
1329
|
+
? ModelUtils.compileGroupMongo(node, modelClass, user, depth)
|
|
1330
|
+
: ModelUtils.compilePredicateMongo(node, modelClass, user);
|
|
1331
|
+
}
|
|
1332
|
+
static compilePredicateSQLOperator(op, value, modelClass, field, user, driverType, literal = false) {
|
|
1333
|
+
const { Equal, MoreThan, MoreThanOrEqual, In, ILike, LessThan, LessThanOrEqual, Not, Between, IsNull } = ModelUtils.orm;
|
|
1334
|
+
switch (op) {
|
|
1335
|
+
case "eq": {
|
|
1336
|
+
const v = ModelUtils.coerceNodeValue(value, modelClass, field, user, literal);
|
|
1337
|
+
return v === null ? IsNull() : Equal(v);
|
|
1338
|
+
}
|
|
1339
|
+
case "ne": {
|
|
1340
|
+
const v = ModelUtils.coerceNodeValue(value, modelClass, field, user, literal);
|
|
1341
|
+
return v === null ? Not(IsNull()) : Not(v);
|
|
1342
|
+
}
|
|
1343
|
+
case "gt":
|
|
1344
|
+
return MoreThan(ModelUtils.coerceNodeValue(value, modelClass, field, user, literal));
|
|
1345
|
+
case "gte":
|
|
1346
|
+
return MoreThanOrEqual(ModelUtils.coerceNodeValue(value, modelClass, field, user, literal));
|
|
1347
|
+
case "lt":
|
|
1348
|
+
return LessThan(ModelUtils.coerceNodeValue(value, modelClass, field, user, literal));
|
|
1349
|
+
case "lte":
|
|
1350
|
+
return LessThanOrEqual(ModelUtils.coerceNodeValue(value, modelClass, field, user, literal));
|
|
1351
|
+
case "in": {
|
|
1352
|
+
const values = (Array.isArray(value) ? value : [value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user, literal));
|
|
1353
|
+
return In(values);
|
|
1354
|
+
}
|
|
1355
|
+
case "nin": {
|
|
1356
|
+
const values = (Array.isArray(value) ? value : [value]).map((v) => ModelUtils.coerceNodeValue(v, modelClass, field, user, literal));
|
|
1357
|
+
return Not(In(values));
|
|
1358
|
+
}
|
|
1359
|
+
case "range": {
|
|
1360
|
+
if (!Array.isArray(value) || value.length !== 2) {
|
|
1361
|
+
throw new ApiError(ApiErrors.SEARCH_INVALID_RANGE, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_INVALID_RANGE, {
|
|
1362
|
+
value: JSON.stringify(value),
|
|
1363
|
+
length: Array.isArray(value) ? value.length : 1,
|
|
1364
|
+
}));
|
|
1365
|
+
}
|
|
1366
|
+
const [lo, hi] = value;
|
|
1367
|
+
return Between(ModelUtils.coerceNodeValue(lo, modelClass, field, user, literal), ModelUtils.coerceNodeValue(hi, modelClass, field, user, literal));
|
|
1368
|
+
}
|
|
1369
|
+
case "like":
|
|
1370
|
+
return ILike(ModelUtils.globToLike(String(value)));
|
|
1371
|
+
case "regex": {
|
|
1372
|
+
const pattern = String(value);
|
|
1373
|
+
if (ModelUtils.isUnsafeRegexPattern(pattern)) {
|
|
1374
|
+
throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
|
|
1375
|
+
}
|
|
1376
|
+
return ModelUtils.compileSqlRegex(pattern, driverType);
|
|
1377
|
+
}
|
|
1378
|
+
case "exists":
|
|
1379
|
+
return value ? Not(IsNull()) : IsNull();
|
|
1380
|
+
default:
|
|
1381
|
+
throw new ApiError(ApiErrors.SEARCH_UNKNOWN_OPERATOR, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_UNKNOWN_OPERATOR, { operator: String(op) }));
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
static compileNodeSQL(node, modelClass, user, driverType, depth) {
|
|
1385
|
+
if (depth > MAX_QUERY_DEPTH) {
|
|
1386
|
+
throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
|
|
1387
|
+
}
|
|
1388
|
+
if (node.kind === "predicate") {
|
|
1389
|
+
return [
|
|
1390
|
+
{
|
|
1391
|
+
[node.field]: ModelUtils.compilePredicateSQLOperator(node.op, node.value, modelClass, node.field, user, driverType, node.literal),
|
|
1392
|
+
},
|
|
1393
|
+
];
|
|
1394
|
+
}
|
|
1395
|
+
if (node.negated) {
|
|
1396
|
+
// De Morgan expansion of an arbitrarily-nested negated group isn't attempted against TypeORM's
|
|
1397
|
+
// `find()`-based `where` (no general boolean-algebra rewrite is implemented here) - only the Mongo
|
|
1398
|
+
// compiler, which can express negation natively via `$nor`, supports it.
|
|
1399
|
+
throw new ApiError(ApiErrors.SEARCH_OPERATOR_NOT_SUPPORTED, 400, StringUtils.findAndReplace(ApiErrorMessages.SEARCH_OPERATOR_NOT_SUPPORTED, {
|
|
1400
|
+
operator: "negated group (SQL)",
|
|
1401
|
+
}));
|
|
1402
|
+
}
|
|
1403
|
+
if (node.children.length > MAX_QUERY_NODES) {
|
|
1404
|
+
throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
|
|
1405
|
+
}
|
|
1406
|
+
const childBranches = node.children.map((child) => ModelUtils.compileNodeSQL(child, modelClass, user, driverType, depth + 1));
|
|
1407
|
+
let branches;
|
|
1408
|
+
if (node.op === "or") {
|
|
1409
|
+
branches = [].concat(...childBranches);
|
|
1410
|
+
}
|
|
1411
|
+
else {
|
|
1412
|
+
branches = childBranches.reduce((acc, branchesForChild) => ModelUtils.andBranches(acc, branchesForChild), [{}]);
|
|
1413
|
+
}
|
|
1414
|
+
if (branches.length > MAX_QUERY_NODES) {
|
|
1415
|
+
throw new ApiError(ApiErrors.SEARCH_QUERY_TOO_COMPLEX, 400, ApiErrorMessages.SEARCH_QUERY_TOO_COMPLEX);
|
|
1416
|
+
}
|
|
1417
|
+
return branches;
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Compiles a `QueryNode` boolean tree into a query object for the given repository - the nested-condition
|
|
1421
|
+
* counterpart to `buildSearchQuery()`'s flat `op(value)` query-parameter form, for boolean shapes the flat
|
|
1422
|
+
* form can't express (e.g. `(a AND b) OR (c AND d)`, with no key forced into every branch). Reuses the same
|
|
1423
|
+
* operand coercion, `me` substitution and operator-injection guard as the flat form. Bounded by the same
|
|
1424
|
+
* `MAX_QUERY_DEPTH`/`MAX_QUERY_NODES` limits as `$or`. Negated groups are supported on MongoDB (via `$nor`)
|
|
1425
|
+
* but rejected against the SQL `find()`-based `where` (see `compileNodeSQL`).
|
|
1426
|
+
*
|
|
1427
|
+
* @param modelClass The class definition of the data model to build a search query for.
|
|
1428
|
+
* @param repo The repository to build a search query for.
|
|
1429
|
+
* @param node The root of the query tree.
|
|
1430
|
+
* @param user The user that is performing the request, resolved for any `field: "me"` predicate value.
|
|
1431
|
+
*/
|
|
1432
|
+
static buildQueryFromNode(modelClass, repo, node, user) {
|
|
1433
|
+
if (repo instanceof MongoRepository) {
|
|
1434
|
+
return { $match: ModelUtils.compileNodeMongo(node, modelClass, user, 0) };
|
|
1435
|
+
}
|
|
1436
|
+
const driverType = repo?.manager?.connection?.options?.type;
|
|
1437
|
+
return { where: ModelUtils.compileNodeSQL(node, modelClass, user, driverType, 0) };
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* Converts a `QueryNode` boolean tree into a PostgreSQL `tsquery` expression string (`AND` -> `&`, `OR` -> `|`,
|
|
1441
|
+
* negation -> `!`), so client input can drive full-text search without passing untrusted text straight to
|
|
1442
|
+
* `to_tsquery` (which throws on malformed input) while still supporting the boolean grouping
|
|
1443
|
+
* `websearch_to_tsquery` cannot express. Every predicate leaf's `value` is treated as a search term
|
|
1444
|
+
* (lexeme/phrase) regardless of its `field`/`op` - this framework has no notion of a full-text-indexed column,
|
|
1445
|
+
* so the caller is expected to route the resulting expression to whichever `tsvector` column it's searching,
|
|
1446
|
+
* e.g. `to_tsquery(ModelUtils.toTsQuery(node))`.
|
|
1447
|
+
*/
|
|
1448
|
+
static toTsQuery(node) {
|
|
1449
|
+
if (node.kind === "predicate") {
|
|
1450
|
+
const term = String(node.value).replace(/'/g, "''");
|
|
1451
|
+
return `'${term}'`;
|
|
1452
|
+
}
|
|
1453
|
+
const joiner = node.op === "and" ? " & " : " | ";
|
|
1454
|
+
const inner = node.children
|
|
1455
|
+
.map((child) => {
|
|
1456
|
+
const compiled = ModelUtils.toTsQuery(child);
|
|
1457
|
+
return child.kind === "group" ? `(${compiled})` : compiled;
|
|
1458
|
+
})
|
|
1459
|
+
.join(joiner);
|
|
1460
|
+
const grouped = node.children.length > 1 ? `(${inner})` : inner;
|
|
1461
|
+
return node.negated ? `!${grouped}` : grouped;
|
|
1462
|
+
}
|
|
783
1463
|
/**
|
|
784
1464
|
* Loads all model schema files from the specified path and returns a map containing all the definitions.
|
|
785
1465
|
*
|
|
@@ -808,6 +1488,9 @@ export class ModelUtils {
|
|
|
808
1488
|
}
|
|
809
1489
|
ModelUtils.idPropertyCache = new Map();
|
|
810
1490
|
ModelUtils.readOnlyPropertyCache = new Map();
|
|
811
|
-
|
|
812
|
-
|
|
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;
|
|
1494
|
+
/** Maximum accepted length of a client-supplied `like()`/`regex()` search pattern. */
|
|
1495
|
+
ModelUtils.MAX_PATTERN_LENGTH = 100;
|
|
813
1496
|
//# sourceMappingURL=ModelUtils.js.map
|