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