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