@mikro-orm/sql 7.2.0-dev.13 → 7.2.0-dev.15

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.
@@ -397,7 +397,10 @@ export class QueryBuilder {
397
397
  * @internal
398
398
  */
399
399
  scheduleFilterCheck(path) {
400
- this.#state.autoJoinedPaths.push(path);
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]) => key.startsWith(`${alias}.`) || this.condReferencesAlias(value, alias));
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 cached = await this.em?.tryCache(this.mainAlias.entityName, this.#state.cache, [
1054
- 'qb.execute',
1055
- query.sql,
1056
- query.params,
1057
- method,
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 res = await this.getConnection().execute(query.sql, query.params, method, this.context, loggerContext);
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
- this.#state.joins[key] = {
1418
- prop,
1419
- alias,
1420
- type,
1421
- cond,
1422
- schema,
1423
- subquery: field.toString(),
1424
- ownerAlias: this.alias,
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
- schema = join.schema === '*' ? schema : (join.schema ?? schemaOverride);
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.replaceAll(ALIAS_REPLACEMENT, this.#alias);
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.replaceAll(ALIAS_REPLACEMENT, this.#alias);
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(', ')})`);
@@ -742,7 +763,7 @@ export class QueryBuilderHelper {
742
763
  }
743
764
  updateVersionProperty(qb, data) {
744
765
  const meta = this.#metadata.find(this.#entityName);
745
- if (!meta?.versionProperty || meta.versionProperty in data) {
766
+ if (!meta?.ownsVersionProperty() || meta.versionProperty in data) {
746
767
  return;
747
768
  }
748
769
  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
  /**
@@ -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
  }
@@ -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
- return {
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;