@mikro-orm/sql 7.2.0-dev.9 → 7.2.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/AbstractSqlConnection.d.ts +14 -3
- package/AbstractSqlConnection.js +42 -4
- package/AbstractSqlDriver.d.ts +1 -8
- package/AbstractSqlDriver.js +63 -35
- package/AbstractSqlPlatform.d.ts +4 -2
- package/AbstractSqlPlatform.js +35 -1
- package/README.md +1 -0
- package/SqlEntityManager.d.ts +2 -2
- package/SqlEntityManager.js +4 -2
- package/dialects/mysql/BaseMySqlPlatform.d.ts +2 -0
- package/dialects/mysql/BaseMySqlPlatform.js +4 -0
- package/dialects/postgresql/BasePostgreSqlPlatform.d.ts +3 -1
- package/dialects/postgresql/BasePostgreSqlPlatform.js +35 -2
- package/dialects/postgresql/PostgreSqlExceptionConverter.js +8 -1
- package/dialects/postgresql/PostgreSqlSchemaHelper.d.ts +18 -1
- package/dialects/postgresql/PostgreSqlSchemaHelper.js +153 -1
- package/dialects/sqlite/SqlitePlatform.d.ts +2 -0
- package/dialects/sqlite/SqlitePlatform.js +4 -0
- package/dialects/sqlite/SqliteSchemaHelper.js +1 -1
- package/package.json +3 -3
- package/plugin/transformer.d.ts +7 -1
- package/plugin/transformer.js +60 -1
- package/query/CriteriaNodeFactory.js +4 -0
- package/query/ObjectCriteriaNode.d.ts +1 -0
- package/query/ObjectCriteriaNode.js +30 -5
- package/query/QueryBuilder.d.ts +26 -3
- package/query/QueryBuilder.js +139 -23
- package/query/QueryBuilderHelper.d.ts +5 -0
- package/query/QueryBuilderHelper.js +33 -8
- package/schema/DatabaseSchema.d.ts +4 -0
- package/schema/DatabaseSchema.js +107 -1
- package/schema/DatabaseTable.d.ts +13 -1
- package/schema/DatabaseTable.js +50 -1
- package/schema/SchemaComparator.d.ts +2 -0
- package/schema/SchemaComparator.js +94 -4
- package/schema/SchemaHelper.d.ts +6 -0
- package/schema/SchemaHelper.js +15 -0
- package/schema/SqlSchemaGenerator.js +8 -0
- package/typings.d.ts +18 -0
package/query/QueryBuilder.js
CHANGED
|
@@ -397,7 +397,10 @@ export class QueryBuilder {
|
|
|
397
397
|
* @internal
|
|
398
398
|
*/
|
|
399
399
|
scheduleFilterCheck(path) {
|
|
400
|
-
|
|
400
|
+
// deduplicate so filters forming a relation cycle cannot reschedule an already visited path forever
|
|
401
|
+
if (!this.#state.autoJoinedPaths.includes(path)) {
|
|
402
|
+
this.#state.autoJoinedPaths.push(path);
|
|
403
|
+
}
|
|
401
404
|
}
|
|
402
405
|
/**
|
|
403
406
|
* @internal
|
|
@@ -492,7 +495,10 @@ export class QueryBuilder {
|
|
|
492
495
|
return cond.some(c => this.condReferencesAlias(c, alias));
|
|
493
496
|
}
|
|
494
497
|
if (Utils.isPlainObject(cond)) {
|
|
495
|
-
return Object.entries(cond).some(([key, value]) =>
|
|
498
|
+
return Object.entries(cond).some(([key, value]) => {
|
|
499
|
+
const [keyAlias, field] = this.helper.splitField(key);
|
|
500
|
+
return this.helper.getTPTAliasForProperty(field, keyAlias) === alias || this.condReferencesAlias(value, alias);
|
|
501
|
+
});
|
|
496
502
|
}
|
|
497
503
|
return false;
|
|
498
504
|
}
|
|
@@ -901,6 +907,8 @@ export class QueryBuilder {
|
|
|
901
907
|
if (this.type === QueryType.INSERT) {
|
|
902
908
|
const returningProps = meta.hydrateProps
|
|
903
909
|
.filter(prop => prop.returning || (prop.persist !== false && ((prop.primary && prop.autoincrement) || prop.defaultRaw)))
|
|
910
|
+
// a TPT table can only return its own columns
|
|
911
|
+
.filter(prop => meta.inheritanceType !== 'tpt' || prop.primary || meta.ownProps.some(p => p.name === prop.name))
|
|
904
912
|
.filter(prop => !data || !(prop.name in data));
|
|
905
913
|
if (returningProps.length > 0) {
|
|
906
914
|
qb.returning(Utils.flatten(returningProps.map(prop => prop.fieldNames)));
|
|
@@ -990,12 +998,13 @@ export class QueryBuilder {
|
|
|
990
998
|
}
|
|
991
999
|
if (!join && options?.ignoreBranching) {
|
|
992
1000
|
join = joins.find(j => {
|
|
993
|
-
return j.path?.replace(/\[\d+]/g, '') === path.replace(/\[\d+]/g, '');
|
|
1001
|
+
return j.path?.replace(/\[\d+]/g, '') === path.replace(/\[\d+]/g, '') && !this.branchesConflict(j.path, path);
|
|
994
1002
|
});
|
|
995
1003
|
}
|
|
996
1004
|
if (!join && options?.matchPopulateJoins && options?.ignoreBranching) {
|
|
997
1005
|
join = joins.find(j => {
|
|
998
|
-
return j.path?.replace(/\[\d+]|\[populate]/g, '') === path.replace(/\[\d+]|\[populate]/g, '')
|
|
1006
|
+
return (j.path?.replace(/\[\d+]|\[populate]/g, '') === path.replace(/\[\d+]|\[populate]/g, '') &&
|
|
1007
|
+
!this.branchesConflict(j.path, path));
|
|
999
1008
|
});
|
|
1000
1009
|
}
|
|
1001
1010
|
if (!join && options?.matchPopulateJoins) {
|
|
@@ -1005,6 +1014,12 @@ export class QueryBuilder {
|
|
|
1005
1014
|
}
|
|
1006
1015
|
return join;
|
|
1007
1016
|
}
|
|
1017
|
+
/** Branch-insensitive path matching still must not cross branches — segments with different explicit branch markers (e.g. `Mobile[0]` vs `Mobile[1]`) belong to sibling joins. */
|
|
1018
|
+
branchesConflict(path1, path2) {
|
|
1019
|
+
const markers = (path) => path.split('.').map(segment => /\[(\d+)]/.exec(segment)?.[1]);
|
|
1020
|
+
const markers2 = markers(path2);
|
|
1021
|
+
return markers(path1).some((m, idx) => m != null && markers2[idx] != null && m !== markers2[idx]);
|
|
1022
|
+
}
|
|
1008
1023
|
/**
|
|
1009
1024
|
* @internal
|
|
1010
1025
|
*/
|
|
@@ -1050,17 +1065,26 @@ export class QueryBuilder {
|
|
|
1050
1065
|
this.limit(1);
|
|
1051
1066
|
}
|
|
1052
1067
|
const query = this.toQuery();
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1068
|
+
const cacheKey = ['qb.execute', query.sql, query.params, method];
|
|
1069
|
+
// session context (row level security) scopes cached rows per tenant/role, avoiding cross-context serves
|
|
1070
|
+
const qbSessionContext = this.em?.getSessionContext();
|
|
1071
|
+
if (qbSessionContext) {
|
|
1072
|
+
cacheKey.push(qbSessionContext);
|
|
1073
|
+
}
|
|
1074
|
+
const cached = await this.em?.tryCache(this.mainAlias.entityName, this.#state.cache, cacheKey);
|
|
1059
1075
|
if (cached?.data !== undefined) {
|
|
1060
1076
|
return cached.data;
|
|
1061
1077
|
}
|
|
1062
1078
|
const loggerContext = { id: this.em?.id, ...this.loggerContext, ...this.#abortOptions };
|
|
1063
|
-
const
|
|
1079
|
+
const conn = this.getConnection();
|
|
1080
|
+
// outside a transaction, wrap in a short implicit one so RLS `set local` session context applies (no-op when unset)
|
|
1081
|
+
const sessionContext = this.context ? undefined : this.em?.getTransactionSessionContext();
|
|
1082
|
+
const res = await (sessionContext
|
|
1083
|
+
? conn.transactional(trx => conn.execute(query.sql, query.params, method, trx, loggerContext), {
|
|
1084
|
+
sessionContext,
|
|
1085
|
+
loggerContext,
|
|
1086
|
+
})
|
|
1087
|
+
: conn.execute(query.sql, query.params, method, this.context, loggerContext));
|
|
1064
1088
|
const meta = this.mainAlias.meta;
|
|
1065
1089
|
if (!options.mapResults || !meta) {
|
|
1066
1090
|
await this.em?.storeCache(this.#state.cache, cached, res);
|
|
@@ -1110,6 +1134,11 @@ export class QueryBuilder {
|
|
|
1110
1134
|
* ```
|
|
1111
1135
|
*/
|
|
1112
1136
|
async *stream(options) {
|
|
1137
|
+
// mirror EntityManager.stream — a stream can't open the implicit session-context transaction, so under the
|
|
1138
|
+
// 'transaction' strategy fail closed instead of silently running the cursor without the staged context
|
|
1139
|
+
if (!this.context && this.em?.getTransactionSessionContext()) {
|
|
1140
|
+
throw ValidationError.sessionContextStreamRequiresTransaction();
|
|
1141
|
+
}
|
|
1113
1142
|
options ??= {};
|
|
1114
1143
|
options.mergeResults ??= true;
|
|
1115
1144
|
options.mapResults ??= true;
|
|
@@ -1410,19 +1439,19 @@ export class QueryBuilder {
|
|
|
1410
1439
|
prop.targetMeta = field.mainAlias.meta;
|
|
1411
1440
|
field = field.getNativeQuery();
|
|
1412
1441
|
}
|
|
1413
|
-
if (isRaw(field)) {
|
|
1414
|
-
field = this.platform.formatQuery(field.sql, field.params);
|
|
1415
|
-
}
|
|
1416
1442
|
const key = `${this.alias}.${prop.name}#${alias}`;
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1443
|
+
const join = { prop, alias, type, cond, schema, ownerAlias: this.alias };
|
|
1444
|
+
// `sql.ref('...')` is a bare table/CTE reference, join it by name instead of wrapping it as a sub-query
|
|
1445
|
+
if (isRaw(field) && field.sql === '??' && field.params.length === 1) {
|
|
1446
|
+
join.table = String(field.params[0]);
|
|
1447
|
+
}
|
|
1448
|
+
else {
|
|
1449
|
+
if (isRaw(field)) {
|
|
1450
|
+
field = this.platform.formatQuery(field.sql, field.params);
|
|
1451
|
+
}
|
|
1452
|
+
join.subquery = field.toString();
|
|
1453
|
+
}
|
|
1454
|
+
this.#state.joins[key] = join;
|
|
1426
1455
|
return { prop, key };
|
|
1427
1456
|
}
|
|
1428
1457
|
if (!subquery && type.includes('lateral')) {
|
|
@@ -1483,9 +1512,31 @@ export class QueryBuilder {
|
|
|
1483
1512
|
this.#state.joins[aliasedName] = this.helper.joinManyToOneReference(prop, ownerAlias, alias, type, cond, schema);
|
|
1484
1513
|
this.#state.joins[aliasedName].path ??= path;
|
|
1485
1514
|
}
|
|
1515
|
+
if (prop.targetMeta.inheritanceType === 'tpt' && prop.targetMeta.tptParent) {
|
|
1516
|
+
this.addTPTParentJoins(prop.targetMeta, alias, path);
|
|
1517
|
+
}
|
|
1486
1518
|
this.nestReferencedJoins(this.#state.joins[aliasedName]);
|
|
1487
1519
|
return { prop, key: aliasedName };
|
|
1488
1520
|
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Walks the TPT inheritance chain of `leafMeta` and INNER JOINs each parent table.
|
|
1523
|
+
* Registers the parent aliases in `state.tptAlias` so column resolution finds them
|
|
1524
|
+
* when conditions reference parent-table columns.
|
|
1525
|
+
* @internal
|
|
1526
|
+
*/
|
|
1527
|
+
addTPTParentJoins(leafMeta, leafAlias, basePath) {
|
|
1528
|
+
let childAlias = leafAlias;
|
|
1529
|
+
let childMeta = leafMeta;
|
|
1530
|
+
while (childMeta.tptParent) {
|
|
1531
|
+
const parentMeta = childMeta.tptParent;
|
|
1532
|
+
const parentAlias = this.getNextAlias(parentMeta.className);
|
|
1533
|
+
this.createAlias(parentMeta.class, parentAlias);
|
|
1534
|
+
this.#state.tptAlias[`${leafAlias}:${parentMeta.className}`] = parentAlias;
|
|
1535
|
+
this.addPropertyJoin(childMeta.tptParentProp, childAlias, parentAlias, JoinType.innerJoin, `${basePath}.[tpt]${childMeta.className}`);
|
|
1536
|
+
childAlias = parentAlias;
|
|
1537
|
+
childMeta = parentMeta;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1489
1540
|
prepareFields(fields, type = 'where', schema) {
|
|
1490
1541
|
const ret = [];
|
|
1491
1542
|
const getFieldName = (name, customAlias) => {
|
|
@@ -2038,6 +2089,13 @@ export class QueryBuilder {
|
|
|
2038
2089
|
for (const k of Object.keys(cond)) {
|
|
2039
2090
|
if (Utils.isOperator(k)) {
|
|
2040
2091
|
if (Array.isArray(cond[k])) {
|
|
2092
|
+
if (k === '$or' && !this.canDistributeOrBranches(cond[k], joins)) {
|
|
2093
|
+
// entity filters have no other sink, so their `$or` is kept intact on the outermost targeted join instead
|
|
2094
|
+
if (filter) {
|
|
2095
|
+
this.mergeFilterOrCondition(cond[k], joins);
|
|
2096
|
+
}
|
|
2097
|
+
continue;
|
|
2098
|
+
}
|
|
2041
2099
|
cond[k].forEach((c) => this.mergeOnConditions(joins, c, filter, k));
|
|
2042
2100
|
}
|
|
2043
2101
|
/* v8 ignore next */
|
|
@@ -2068,8 +2126,66 @@ export class QueryBuilder {
|
|
|
2068
2126
|
else {
|
|
2069
2127
|
join.cond = { ...join.cond, [k]: cond[k] };
|
|
2070
2128
|
}
|
|
2129
|
+
this.nestReferencedJoins(join);
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* `$or` branches can be moved to a join's `on` clause only when they all target that same join
|
|
2135
|
+
* and stay flat — a partial `$or` in the `on` clause would drop rows matching a sibling branch,
|
|
2136
|
+
* and nested operators cannot be preserved inside a distributed disjunction, as `mergeOnConditions`
|
|
2137
|
+
* would flatten them into `and` conjuncts.
|
|
2138
|
+
*/
|
|
2139
|
+
canDistributeOrBranches(branches, joins) {
|
|
2140
|
+
const aliases = this.getOrBranchAliases(branches);
|
|
2141
|
+
const targeted = joins.filter(j => aliases.has(j.alias));
|
|
2142
|
+
const flat = branches.every(branch => Object.keys(branch).every(k => !Utils.isOperator(k)));
|
|
2143
|
+
return aliases.size === 1 && targeted.length === 1 && flat;
|
|
2144
|
+
}
|
|
2145
|
+
getOrBranchAliases(branches) {
|
|
2146
|
+
const aliases = new Set();
|
|
2147
|
+
const collectAliases = (cond) => {
|
|
2148
|
+
for (const k of Object.keys(cond)) {
|
|
2149
|
+
if (Utils.isOperator(k)) {
|
|
2150
|
+
Utils.asArray(cond[k]).forEach((c) => collectAliases(c));
|
|
2151
|
+
}
|
|
2152
|
+
else {
|
|
2153
|
+
aliases.add(this.helper.splitField(k)[0]);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
};
|
|
2157
|
+
branches.forEach(collectAliases);
|
|
2158
|
+
return aliases;
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* An entity filter's `$or` that cannot be distributed is applied intact to the `on` clause of the
|
|
2162
|
+
* outermost join common to all targeted joins, nesting the targeted joins under it so the clause
|
|
2163
|
+
* can reference their aliases.
|
|
2164
|
+
*/
|
|
2165
|
+
mergeFilterOrCondition(branches, joins) {
|
|
2166
|
+
const aliases = this.getOrBranchAliases(branches);
|
|
2167
|
+
const chainOf = (join) => {
|
|
2168
|
+
const parent = joins.find(j => j.alias === join.ownerAlias);
|
|
2169
|
+
return parent ? [join, ...chainOf(parent)] : [join];
|
|
2170
|
+
};
|
|
2171
|
+
// chains go from each targeted join up to its root, so the first join present in all of them is the outermost common one
|
|
2172
|
+
const chains = joins.filter(j => aliases.has(j.alias)).map(chainOf);
|
|
2173
|
+
const anchor = chains[0]?.find(a => chains.every(chain => chain.includes(a)));
|
|
2174
|
+
/* v8 ignore next 3 */
|
|
2175
|
+
if (!anchor) {
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
for (const chain of chains) {
|
|
2179
|
+
// nest the chain below the anchor, so the anchor's `on` clause can reference the nested aliases
|
|
2180
|
+
for (let i = 0; chain[i] !== anchor; i++) {
|
|
2181
|
+
const nested = (chain[i + 1].nested ??= new Set());
|
|
2182
|
+
if (!nested.has(chain[i])) {
|
|
2183
|
+
chain[i].type = chain[i].type === JoinType.innerJoin ? JoinType.nestedInnerJoin : JoinType.nestedLeftJoin;
|
|
2184
|
+
nested.add(chain[i]);
|
|
2185
|
+
}
|
|
2071
2186
|
}
|
|
2072
2187
|
}
|
|
2188
|
+
anchor.cond = anchor.cond.$or ? { $and: [anchor.cond, { $or: branches }] } : { ...anchor.cond, $or: branches };
|
|
2073
2189
|
}
|
|
2074
2190
|
/**
|
|
2075
2191
|
* When adding an inner join on a left joined relation, we need to nest them,
|
|
@@ -14,6 +14,11 @@ export declare class QueryBuilderHelper {
|
|
|
14
14
|
* Returns the main alias if not a TPT property or if the property belongs to the main entity.
|
|
15
15
|
*/
|
|
16
16
|
getTPTAliasForProperty(propName: string, defaultAlias: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Replaces `ALIAS_REPLACEMENT` placeholders, resolving column-qualified ones via
|
|
19
|
+
* `getTPTAliasForProperty()` so TPT inherited columns map to their owning table.
|
|
20
|
+
*/
|
|
21
|
+
replaceAliases(sql: string, alias?: string): string;
|
|
17
22
|
mapper(field: string | Raw | RawQueryFragmentSymbol, type?: QueryType): string;
|
|
18
23
|
mapper(field: string | Raw | RawQueryFragmentSymbol, type?: QueryType, value?: any, alias?: string | null, schema?: string): string;
|
|
19
24
|
processData(data: Dictionary, convertCustomTypes: boolean, multi?: boolean): any;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ALIAS_REPLACEMENT, ALIAS_REPLACEMENT_RE, ArrayType, JsonType, inspect, isRaw, LockMode, OptimisticLockError, QueryOperator, QueryOrderNumeric, raw, Raw, QueryHelper, ReferenceKind, Utils, ValidationError, } from '@mikro-orm/core';
|
|
2
2
|
import { EMBEDDABLE_ARRAY_OPS, JoinType, QueryType } from './enums.js';
|
|
3
|
+
/** Captures the column name that follows the alias placeholder. */
|
|
4
|
+
const QUALIFYING_ALIAS_RE = new RegExp(ALIAS_REPLACEMENT_RE + '(?=["\'`\\]]*\\.["\'`\\[]?([\\w$]+))', 'g');
|
|
3
5
|
/**
|
|
4
6
|
* @internal
|
|
5
7
|
*/
|
|
@@ -49,6 +51,15 @@ export class QueryBuilderHelper {
|
|
|
49
51
|
// Property not found in hierarchy, return default alias
|
|
50
52
|
return defaultAlias;
|
|
51
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Replaces `ALIAS_REPLACEMENT` placeholders, resolving column-qualified ones via
|
|
56
|
+
* `getTPTAliasForProperty()` so TPT inherited columns map to their owning table.
|
|
57
|
+
*/
|
|
58
|
+
replaceAliases(sql, alias = this.#alias) {
|
|
59
|
+
return sql
|
|
60
|
+
.replace(QUALIFYING_ALIAS_RE, (_, column) => this.getTPTAliasForProperty(column, alias))
|
|
61
|
+
.replaceAll(ALIAS_REPLACEMENT, alias);
|
|
62
|
+
}
|
|
52
63
|
mapper(field, type = QueryType.SELECT, value, alias, schema) {
|
|
53
64
|
if (isRaw(field)) {
|
|
54
65
|
return raw(field.sql, field.params);
|
|
@@ -97,7 +108,6 @@ export class QueryBuilderHelper {
|
|
|
97
108
|
// Only apply TPT resolution when `a` is an actual table alias (in aliasMap),
|
|
98
109
|
// not when it's an embedded property name like 'profile1.identity.links'
|
|
99
110
|
const isTableAlias = !!this.#aliasMap[a];
|
|
100
|
-
const baseAlias = isTableAlias ? a : this.#alias;
|
|
101
111
|
const resolvedAlias = isTableAlias ? this.getTPTAliasForProperty(prop?.name ?? f, a) : this.#alias;
|
|
102
112
|
const aliasPrefix = isTableNameAliasRequired ? resolvedAlias + '.' : '';
|
|
103
113
|
const fkIdx2 = prop?.fieldNames.findIndex(name => name === f) ?? -1;
|
|
@@ -249,7 +259,13 @@ export class QueryBuilderHelper {
|
|
|
249
259
|
}[join.type] ?? join.type;
|
|
250
260
|
const conditions = [];
|
|
251
261
|
const params = [];
|
|
252
|
-
|
|
262
|
+
if (join.prop.name === '__subquery__') {
|
|
263
|
+
// bare table/CTE references (`sql.ref()` joins) keep their literal name, only an explicit schema applies
|
|
264
|
+
schema = join.schema === '*' ? undefined : join.schema;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
schema = join.schema === '*' ? schema : (join.schema ?? schemaOverride);
|
|
268
|
+
}
|
|
253
269
|
if (schema && schema !== this.#platform.getDefaultSchemaName()) {
|
|
254
270
|
table = `${schema}.${table}`;
|
|
255
271
|
}
|
|
@@ -465,7 +481,7 @@ export class QueryBuilderHelper {
|
|
|
465
481
|
const op = cond[key] === null ? 'is' : '=';
|
|
466
482
|
if (Raw.isKnownFragmentSymbol(key)) {
|
|
467
483
|
const raw = Raw.getKnownFragment(key);
|
|
468
|
-
const sql = raw.sql
|
|
484
|
+
const sql = this.replaceAliases(raw.sql);
|
|
469
485
|
const value = Utils.asArray(cond[key]);
|
|
470
486
|
params.push(...raw.params);
|
|
471
487
|
if (value.length > 0) {
|
|
@@ -517,6 +533,11 @@ export class QueryBuilderHelper {
|
|
|
517
533
|
const replacement = this.getOperatorReplacement(op, value);
|
|
518
534
|
const rawField = Raw.isKnownFragmentSymbol(key);
|
|
519
535
|
const fields = rawField ? [key] : Utils.splitPrimaryKeys(key);
|
|
536
|
+
// a raw key's arity is opaque, so the row-value form is detected from the payload instead
|
|
537
|
+
const rowValues = rawField &&
|
|
538
|
+
['$in', '$nin'].includes(op) &&
|
|
539
|
+
Array.isArray(value[op]) &&
|
|
540
|
+
value[op].every((v) => Array.isArray(v));
|
|
520
541
|
if (fields.length > 1 && Array.isArray(value[op])) {
|
|
521
542
|
const singleTuple = !value[op].every((v) => Array.isArray(v));
|
|
522
543
|
if (!this.#platform.allowsComparingTuples()) {
|
|
@@ -576,7 +597,7 @@ export class QueryBuilderHelper {
|
|
|
576
597
|
if (opValueIsRaw || typeof opValue?.toRaw === 'function') {
|
|
577
598
|
const query = opValueIsRaw ? opValue : opValue.toRaw();
|
|
578
599
|
const mappedKey = this.mapper(key, type, query, null);
|
|
579
|
-
let sql = query.sql
|
|
600
|
+
let sql = this.replaceAliases(query.sql);
|
|
580
601
|
if (['$in', '$nin'].includes(op)) {
|
|
581
602
|
sql = `(${sql})`;
|
|
582
603
|
}
|
|
@@ -585,15 +606,15 @@ export class QueryBuilderHelper {
|
|
|
585
606
|
}
|
|
586
607
|
else {
|
|
587
608
|
const mappedKey = this.mapper(key, type, opValue, null);
|
|
588
|
-
const val = this.getValueReplacement(fields, opValue, params, op, prop);
|
|
609
|
+
const val = this.getValueReplacement(fields, opValue, params, op, prop, rowValues);
|
|
589
610
|
parts.push(`${this.#platform.quoteIdentifier(mappedKey)} ${replacement} ${val}`);
|
|
590
611
|
}
|
|
591
612
|
}
|
|
592
613
|
return { sql: parts.join(' and '), params };
|
|
593
614
|
}
|
|
594
|
-
getValueReplacement(fields, value, params, key, prop) {
|
|
615
|
+
getValueReplacement(fields, value, params, key, prop, rowValues = false) {
|
|
595
616
|
if (Array.isArray(value)) {
|
|
596
|
-
if (fields.length > 1) {
|
|
617
|
+
if (fields.length > 1 || rowValues) {
|
|
597
618
|
const tmp = [];
|
|
598
619
|
for (const field of value) {
|
|
599
620
|
tmp.push(`(${field.map(() => '?').join(', ')})`);
|
|
@@ -622,6 +643,10 @@ export class QueryBuilderHelper {
|
|
|
622
643
|
return '?';
|
|
623
644
|
}
|
|
624
645
|
getOperatorReplacement(op, value) {
|
|
646
|
+
// collection properties expand `$all` into `$some` sub-queries in `ObjectCriteriaNode`, nothing else has a SQL equivalent
|
|
647
|
+
if (op === '$all') {
|
|
648
|
+
throw new Error('The `$all` operator is supported only on collection properties in SQL drivers, use `$contains` for array columns instead.');
|
|
649
|
+
}
|
|
625
650
|
let replacement = QueryOperator[op];
|
|
626
651
|
if (op === '$exists') {
|
|
627
652
|
replacement = value[op] ? 'is not' : 'is';
|
|
@@ -742,7 +767,7 @@ export class QueryBuilderHelper {
|
|
|
742
767
|
}
|
|
743
768
|
updateVersionProperty(qb, data) {
|
|
744
769
|
const meta = this.#metadata.find(this.#entityName);
|
|
745
|
-
if (!meta?.
|
|
770
|
+
if (!meta?.ownsVersionProperty() || meta.versionProperty in data) {
|
|
746
771
|
return;
|
|
747
772
|
}
|
|
748
773
|
const versionProperty = meta.properties[meta.versionProperty];
|
|
@@ -48,6 +48,10 @@ export declare class DatabaseSchema {
|
|
|
48
48
|
/** Separate from `create()` so the comparator only pays for routine introspection when the user actually defined routines. SQLite/libSQL helpers return []. */
|
|
49
49
|
loadRoutines(connection: AbstractSqlConnection, platform: AbstractSqlPlatform, schemas?: string[]): Promise<void>;
|
|
50
50
|
static fromMetadata(metadata: EntityMetadata[], platform: AbstractSqlPlatform, config: Configuration, schemaName?: string, em?: any): DatabaseSchema;
|
|
51
|
+
/** Compiles an `rls`-flagged filter's condition into a resolved policy backed by `current_setting()` lookups. */
|
|
52
|
+
private static compileRlsFilterPolicy;
|
|
53
|
+
/** Truncates the base first so the collision suffix survives the identifier limit. */
|
|
54
|
+
private static uniquePolicyName;
|
|
51
55
|
/** Separate from {@link fromMetadata} so the comparator only walks routines when the user defined any. */
|
|
52
56
|
addRoutinesFromMetadata(routines: readonly Routine[], platform: AbstractSqlPlatform, em?: any): void;
|
|
53
57
|
/**
|
package/schema/DatabaseSchema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ReferenceKind, isRaw, } from '@mikro-orm/core';
|
|
1
|
+
import { ReferenceKind, MetadataError, QueryHelper, Utils, isRaw, } from '@mikro-orm/core';
|
|
2
2
|
import { DatabaseTable } from './DatabaseTable.js';
|
|
3
3
|
import { normalizeViewDefinition } from './SchemaHelper.js';
|
|
4
4
|
import { getTablePartitioning } from './partitioning.js';
|
|
@@ -262,9 +262,115 @@ export class DatabaseSchema {
|
|
|
262
262
|
expression: trigger.expression,
|
|
263
263
|
});
|
|
264
264
|
}
|
|
265
|
+
// non-empty policies imply RLS; `rowLevelSecurity: 'force'` enforces it for the table owner too, but an
|
|
266
|
+
// explicit `rowLevelSecurity: false` keeps RLS disabled even when policies are staged (they stay dormant)
|
|
267
|
+
table.rlsEnabled = meta.rowLevelSecurity !== false && (meta.policies.length > 0 || !!meta.rowLevelSecurity);
|
|
268
|
+
table.rlsForced = meta.rowLevelSecurity === 'force';
|
|
269
|
+
const usedPolicyNames = new Set();
|
|
270
|
+
const resolve = (raw) => {
|
|
271
|
+
if (raw == null) {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
return isRaw(raw) ? platform.formatQuery(raw.sql, raw.params) : raw;
|
|
275
|
+
};
|
|
276
|
+
for (const policy of meta.policies) {
|
|
277
|
+
const command = policy.command ?? 'all';
|
|
278
|
+
// deterministic default name derived from table + command + collision index, truncated the
|
|
279
|
+
// same way check names are, so the introspected (engine-truncated) name matches metadata
|
|
280
|
+
const max = platform.getMaxIdentifierLength();
|
|
281
|
+
let name = policy.name;
|
|
282
|
+
if (!name) {
|
|
283
|
+
name = this.uniquePolicyName(`${meta.collection}_${command}_policy`, platform, usedPolicyNames);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
name = name.substring(0, max);
|
|
287
|
+
// explicit names skip the collision suffixing, so a duplicate would otherwise die with a raw pg error
|
|
288
|
+
// on create or be silently swallowed by the name-keyed diff dictionaries — reject it up front
|
|
289
|
+
if (usedPolicyNames.has(name)) {
|
|
290
|
+
throw MetadataError.duplicatePolicyName(meta, name);
|
|
291
|
+
}
|
|
292
|
+
usedPolicyNames.add(name);
|
|
293
|
+
}
|
|
294
|
+
table.addPolicy({
|
|
295
|
+
name,
|
|
296
|
+
command,
|
|
297
|
+
type: policy.type ?? 'permissive',
|
|
298
|
+
roles: policy.roles ?? [],
|
|
299
|
+
using: resolve(policy.using),
|
|
300
|
+
check: resolve(policy.check),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
// rls-flagged filters materialize as additional permissive policies (app-level WHERE + DB-level policy);
|
|
304
|
+
// filters inherited from a TPT parent are skipped — the policy already lives on the parent table, and the
|
|
305
|
+
// child table may not even have the referenced columns
|
|
306
|
+
const rlsFilters = Object.values(meta.filters).filter(filter => filter.rls && !(meta.tptParent && Object.values(meta.tptParent.filters).includes(filter)));
|
|
307
|
+
if (rlsFilters.length > 0) {
|
|
308
|
+
// an explicit `rowLevelSecurity: false` still stages the filter's policy but keeps RLS off (dormant)
|
|
309
|
+
table.rlsEnabled = meta.rowLevelSecurity !== false;
|
|
310
|
+
for (const filter of rlsFilters) {
|
|
311
|
+
table.addPolicy(this.compileRlsFilterPolicy(meta, filter, table, platform, usedPolicyNames));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
265
314
|
}
|
|
266
315
|
return schema;
|
|
267
316
|
}
|
|
317
|
+
/** Compiles an `rls`-flagged filter's condition into a resolved policy backed by `current_setting()` lookups. */
|
|
318
|
+
static compileRlsFilterPolicy(meta, filter, table, platform, usedPolicyNames) {
|
|
319
|
+
const accessed = new Set();
|
|
320
|
+
const cond = QueryHelper.resolveRlsFilterCond(filter, accessed, meta.className);
|
|
321
|
+
const setting = typeof filter.rls === 'object' ? filter.rls.setting : undefined;
|
|
322
|
+
if (setting && accessed.size > 1) {
|
|
323
|
+
throw MetadataError.rlsFilterMultiArgSetting(filter.name, [...accessed]);
|
|
324
|
+
}
|
|
325
|
+
// the config-bound driver is always an `AbstractSqlDriver` here, like in `DatabaseTable.processIndexWhere`
|
|
326
|
+
const driver = platform.getConfig().getDriver();
|
|
327
|
+
let sql = driver.renderPartialIndexWhere(meta.class, cond);
|
|
328
|
+
const prefix = QueryHelper.RLS_SENTINEL_PREFIX;
|
|
329
|
+
const suffix = QueryHelper.RLS_SENTINEL_SUFFIX;
|
|
330
|
+
// match `"<column>" <op> '<sentinel>'` — the LHS is always a quoted column emitted from this entity's own
|
|
331
|
+
// where; group 1 keeps the column + operator so only the sentinel literal is swapped for the session lookup
|
|
332
|
+
const re = new RegExp(`("([^"]+)"\\s*(?:!=|>=|<=|=|>|<)\\s*)'${prefix}(\\w+)${suffix}'`, 'g');
|
|
333
|
+
sql = sql.replace(re, (_whole, lhs, column, arg) => {
|
|
334
|
+
const col = table.getColumn(column);
|
|
335
|
+
// the condition can reference a property that renders a field name without a managed column
|
|
336
|
+
// (`persist: false`, `skipColumns`) — fail with a descriptive error instead of a crash
|
|
337
|
+
if (!col) {
|
|
338
|
+
throw MetadataError.rlsFilterUnmanagedColumn(filter.name, column);
|
|
339
|
+
}
|
|
340
|
+
// native enum columns compare against the enum type itself, `current_setting()` text won't coerce implicitly
|
|
341
|
+
const cast = col.nativeEnumName
|
|
342
|
+
? `::${platform.quoteIdentifier(col.nativeEnumName)}`
|
|
343
|
+
: platform.getCurrentSettingCast(col.mappedType);
|
|
344
|
+
if (cast === null) {
|
|
345
|
+
throw MetadataError.rlsFilterUncastableType(filter.name, col.type);
|
|
346
|
+
}
|
|
347
|
+
// a sentinel implies the arg was accessed, and multi-arg custom settings were already rejected above
|
|
348
|
+
const name = setting || Utils.getRlsSettingName(filter.name, arg);
|
|
349
|
+
return `${lhs}current_setting(${platform.quoteValue(name)})${cast}`;
|
|
350
|
+
});
|
|
351
|
+
// a leftover sentinel means an argument appeared somewhere other than a direct comparison, which we can't compile
|
|
352
|
+
if (sql.includes(prefix)) {
|
|
353
|
+
throw MetadataError.rlsFilterUnsupportedCond(filter.name);
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
name: this.uniquePolicyName(`${meta.collection}_${filter.name}_policy`, platform, usedPolicyNames),
|
|
357
|
+
command: 'all',
|
|
358
|
+
type: 'permissive',
|
|
359
|
+
roles: [],
|
|
360
|
+
using: sql,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/** Truncates the base first so the collision suffix survives the identifier limit. */
|
|
364
|
+
static uniquePolicyName(base, platform, used) {
|
|
365
|
+
const max = platform.getMaxIdentifierLength();
|
|
366
|
+
let name = base.substring(0, max);
|
|
367
|
+
for (let i = 2; used.has(name); i++) {
|
|
368
|
+
const suffix = `_${i}`;
|
|
369
|
+
name = base.substring(0, max - suffix.length) + suffix;
|
|
370
|
+
}
|
|
371
|
+
used.add(name);
|
|
372
|
+
return name;
|
|
373
|
+
}
|
|
268
374
|
/** Separate from {@link fromMetadata} so the comparator only walks routines when the user defined any. */
|
|
269
375
|
addRoutinesFromMetadata(routines, platform, em) {
|
|
270
376
|
const resolveBody = (raw) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Configuration, type DeferMode, type Dictionary, type EntityMetadata, type EntityProperty, type IndexCallback, type NamingStrategy } from '@mikro-orm/core';
|
|
2
2
|
import type { SchemaHelper } from './SchemaHelper.js';
|
|
3
|
-
import type { CheckDef, Column, ForeignKey, IndexDef, TablePartitioning, SqlTriggerDef } from '../typings.js';
|
|
3
|
+
import type { CheckDef, Column, ForeignKey, IndexDef, TablePartitioning, SqlPolicyDef, SqlTriggerDef } from '../typings.js';
|
|
4
4
|
import type { AbstractSqlPlatform } from '../AbstractSqlPlatform.js';
|
|
5
5
|
/**
|
|
6
6
|
* @internal
|
|
@@ -15,6 +15,10 @@ export declare class DatabaseTable {
|
|
|
15
15
|
items: string[];
|
|
16
16
|
}>;
|
|
17
17
|
comment?: string;
|
|
18
|
+
/** Whether row level security is enabled on the table (postgres only). */
|
|
19
|
+
rlsEnabled: boolean;
|
|
20
|
+
/** Whether row level security is also enforced for the table owner (postgres `force`). */
|
|
21
|
+
rlsForced: boolean;
|
|
18
22
|
partitioning?: TablePartitioning;
|
|
19
23
|
/**
|
|
20
24
|
* Effective collation the column defaults to when no explicit `COLLATE` is set on a column.
|
|
@@ -34,6 +38,11 @@ export declare class DatabaseTable {
|
|
|
34
38
|
/** @internal */
|
|
35
39
|
setPartitioning(partitioning?: TablePartitioning): void;
|
|
36
40
|
getTriggers(): SqlTriggerDef[];
|
|
41
|
+
getPolicies(): SqlPolicyDef[];
|
|
42
|
+
/** `[]` and `['public']` both mean PUBLIC — a single predicate so metadata, introspection and codegen agree. */
|
|
43
|
+
static isDefaultPolicyRoles(roles: string[]): boolean;
|
|
44
|
+
/** @internal */
|
|
45
|
+
setPolicies(policies: SqlPolicyDef[]): void;
|
|
37
46
|
/** @internal */
|
|
38
47
|
setIndexes(indexes: IndexDef[]): void;
|
|
39
48
|
/** @internal */
|
|
@@ -65,6 +74,8 @@ export declare class DatabaseTable {
|
|
|
65
74
|
hasCheck(checkName: string): boolean;
|
|
66
75
|
getTrigger(triggerName: string): SqlTriggerDef | undefined;
|
|
67
76
|
hasTrigger(triggerName: string): boolean;
|
|
77
|
+
getPolicy(policyName: string): SqlPolicyDef | undefined;
|
|
78
|
+
hasPolicy(policyName: string): boolean;
|
|
68
79
|
getPrimaryKey(): IndexDef | undefined;
|
|
69
80
|
hasPrimaryKey(): boolean;
|
|
70
81
|
private getForeignKeyDeclaration;
|
|
@@ -99,5 +110,6 @@ export declare class DatabaseTable {
|
|
|
99
110
|
private processIndexWhere;
|
|
100
111
|
addCheck(check: CheckDef): void;
|
|
101
112
|
addTrigger(trigger: SqlTriggerDef): void;
|
|
113
|
+
addPolicy(policy: SqlPolicyDef): void;
|
|
102
114
|
toJSON(): Dictionary;
|
|
103
115
|
}
|
package/schema/DatabaseTable.js
CHANGED
|
@@ -10,10 +10,15 @@ export class DatabaseTable {
|
|
|
10
10
|
#indexes = [];
|
|
11
11
|
#checks = [];
|
|
12
12
|
#triggers = [];
|
|
13
|
+
#policies = [];
|
|
13
14
|
#foreignKeys = {};
|
|
14
15
|
#platform;
|
|
15
16
|
nativeEnums = {}; // for postgres
|
|
16
17
|
comment;
|
|
18
|
+
/** Whether row level security is enabled on the table (postgres only). */
|
|
19
|
+
rlsEnabled = false;
|
|
20
|
+
/** Whether row level security is also enforced for the table owner (postgres `force`). */
|
|
21
|
+
rlsForced = false;
|
|
17
22
|
partitioning;
|
|
18
23
|
/**
|
|
19
24
|
* Effective collation the column defaults to when no explicit `COLLATE` is set on a column.
|
|
@@ -55,6 +60,17 @@ export class DatabaseTable {
|
|
|
55
60
|
getTriggers() {
|
|
56
61
|
return this.#triggers;
|
|
57
62
|
}
|
|
63
|
+
getPolicies() {
|
|
64
|
+
return this.#policies;
|
|
65
|
+
}
|
|
66
|
+
/** `[]` and `['public']` both mean PUBLIC — a single predicate so metadata, introspection and codegen agree. */
|
|
67
|
+
static isDefaultPolicyRoles(roles) {
|
|
68
|
+
return roles.length === 0 || (roles.length === 1 && roles[0] === 'public');
|
|
69
|
+
}
|
|
70
|
+
/** @internal */
|
|
71
|
+
setPolicies(policies) {
|
|
72
|
+
this.#policies = policies;
|
|
73
|
+
}
|
|
58
74
|
/** @internal */
|
|
59
75
|
setIndexes(indexes) {
|
|
60
76
|
this.#indexes = indexes;
|
|
@@ -670,6 +686,12 @@ export class DatabaseTable {
|
|
|
670
686
|
hasTrigger(triggerName) {
|
|
671
687
|
return !!this.getTrigger(triggerName);
|
|
672
688
|
}
|
|
689
|
+
getPolicy(policyName) {
|
|
690
|
+
return this.#policies.find(p => p.name === policyName);
|
|
691
|
+
}
|
|
692
|
+
hasPolicy(policyName) {
|
|
693
|
+
return !!this.getPolicy(policyName);
|
|
694
|
+
}
|
|
673
695
|
getPrimaryKey() {
|
|
674
696
|
return this.#indexes.find(i => i.primary);
|
|
675
697
|
}
|
|
@@ -990,6 +1012,9 @@ export class DatabaseTable {
|
|
|
990
1012
|
addTrigger(trigger) {
|
|
991
1013
|
this.#triggers.push(trigger);
|
|
992
1014
|
}
|
|
1015
|
+
addPolicy(policy) {
|
|
1016
|
+
this.#policies.push(policy);
|
|
1017
|
+
}
|
|
993
1018
|
toJSON() {
|
|
994
1019
|
const columns = this.#columns;
|
|
995
1020
|
// locale-independent comparison so the snapshot is stable across machines
|
|
@@ -1123,13 +1148,26 @@ export class DatabaseTable {
|
|
|
1123
1148
|
}
|
|
1124
1149
|
return out;
|
|
1125
1150
|
};
|
|
1151
|
+
const normalizePolicy = (policy) => {
|
|
1152
|
+
const out = { name: policy.name, command: policy.command, type: policy.type };
|
|
1153
|
+
if (!DatabaseTable.isDefaultPolicyRoles(policy.roles)) {
|
|
1154
|
+
out.roles = [...policy.roles].sort(byString);
|
|
1155
|
+
}
|
|
1156
|
+
for (const field of ['using', 'check']) {
|
|
1157
|
+
if (policy[field]) {
|
|
1158
|
+
out[field] = policy[field];
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
return out;
|
|
1162
|
+
};
|
|
1126
1163
|
const sortedIndexes = [...this.#indexes].sort((a, b) => byString(a.keyName, b.keyName)).map(normalizeIndex);
|
|
1127
1164
|
const sortedChecks = [...this.#checks].sort((a, b) => byString(a.name, b.name)).map(normalizeCheck);
|
|
1128
1165
|
const sortedTriggers = [...this.#triggers].sort((a, b) => byString(a.name, b.name));
|
|
1166
|
+
const sortedPolicies = [...this.#policies].sort((a, b) => byString(a.name, b.name)).map(normalizePolicy);
|
|
1129
1167
|
const sortedForeignKeys = Object.fromEntries(Object.entries(this.#foreignKeys)
|
|
1130
1168
|
.sort(([a], [b]) => byString(a, b))
|
|
1131
1169
|
.map(([k, v]) => [k, normalizeFk(v)]));
|
|
1132
|
-
|
|
1170
|
+
const ret = {
|
|
1133
1171
|
name: this.name,
|
|
1134
1172
|
schema: this.schema,
|
|
1135
1173
|
columns: columnsMapped,
|
|
@@ -1142,5 +1180,16 @@ export class DatabaseTable {
|
|
|
1142
1180
|
// platforms that can't read comments back (sqlite), where keeping it would flip the snapshot
|
|
1143
1181
|
comment: supportsComments ? this.comment || null : null,
|
|
1144
1182
|
};
|
|
1183
|
+
// emit RLS state only when set, so snapshots of non-RLS tables stay byte-for-byte unchanged
|
|
1184
|
+
if (sortedPolicies.length > 0) {
|
|
1185
|
+
ret.policies = sortedPolicies;
|
|
1186
|
+
}
|
|
1187
|
+
if (this.rlsEnabled) {
|
|
1188
|
+
ret.rlsEnabled = true;
|
|
1189
|
+
}
|
|
1190
|
+
if (this.rlsForced) {
|
|
1191
|
+
ret.rlsForced = true;
|
|
1192
|
+
}
|
|
1193
|
+
return ret;
|
|
1145
1194
|
}
|
|
1146
1195
|
}
|
|
@@ -88,6 +88,8 @@ export declare class SchemaComparator {
|
|
|
88
88
|
*/
|
|
89
89
|
private diffViewExpression;
|
|
90
90
|
private diffTrigger;
|
|
91
|
+
private diffPolicies;
|
|
92
|
+
private diffPolicy;
|
|
91
93
|
parseJsonDefault(defaultValue?: string | null): Dictionary | string | null;
|
|
92
94
|
private parseDecimalDefault;
|
|
93
95
|
hasSameDefaultValue(from: Column, to: Column): boolean;
|