@mikro-orm/core 7.2.0-dev.14 → 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.
@@ -7,7 +7,7 @@ import { EntityLoader, type EntityLoaderOptions } from './entity/EntityLoader.js
7
7
  import { Reference } from './entity/Reference.js';
8
8
  import { UnitOfWork } from './unit-of-work/UnitOfWork.js';
9
9
  import type { CountByOptions, CountOptions, DeleteOptions, FilterOptions, FindAllOptions, FindByCursorOptions, FindOneOptions, FindOneOrFailOptions, FindOptions, GetReferenceOptions, IDatabaseDriver, LockOptions, NativeInsertUpdateOptions, StreamOptions, UpdateOptions, UpsertManyOptions, UpsertOptions } from './drivers/IDatabaseDriver.js';
10
- import type { AnyString, ArrayElement, AutoPath, ConnectionType, Dictionary, EntityClass, EntityData, EntityDictionary, EntityDTO, EntityKey, EntityMetadata, EntityName, FilterDef, FilterQuery, FromEntityType, GetRepository, IHydrator, IsSubset, Loaded, MergeLoaded, MergeSelected, ObjectQuery, PopulateOptions, Primary, Ref, RequiredEntityData, RoutineArgs, RoutineReturn, UnboxArray, IndexFilterQuery, WithUsingOptions } from './typings.js';
10
+ import type { AnyString, ArrayElement, AutoPath, ConnectionType, Dictionary, EntityClass, EntityData, EntityDictionary, EntityDTO, EntityKey, EntityMetadata, EntityName, FilterDef, FilterQuery, FromEntityType, GetRepository, IHydrator, IsSubset, Loaded, MergeLoaded, MergeSelected, ObjectQuery, PopulateOptions, Primary, Ref, RequiredEntityData, RoutineArgs, RoutineReturn, SessionContext, UnboxArray, IndexFilterQuery, WithUsingOptions } from './typings.js';
11
11
  import type { Routine } from './metadata/Routine.js';
12
12
  import { FlushMode, LockMode, PopulatePath, type TransactionOptions } from './enums.js';
13
13
  import type { MetadataStorage } from './metadata/MetadataStorage.js';
@@ -95,7 +95,7 @@ export declare class EntityManager<Driver extends IDatabaseDriver = IDatabaseDri
95
95
  /**
96
96
  * Registers global filter to this entity manager. Global filters are enabled by default (unless disabled via last parameter).
97
97
  */
98
- addFilter<T extends EntityName | readonly EntityName[]>(options: FilterDef<T>): void;
98
+ addFilter<T extends EntityName | readonly EntityName[]>(options: Omit<FilterDef<T>, 'rls'>): void;
99
99
  /**
100
100
  * Sets filter parameter values globally inside context defined by this entity manager.
101
101
  * If you want to set shared value for all contexts, be sure to use the root entity manager.
@@ -105,6 +105,32 @@ export declare class EntityManager<Driver extends IDatabaseDriver = IDatabaseDri
105
105
  * Returns filter parameters for given filter set in this context.
106
106
  */
107
107
  getFilterParams<T extends Dictionary = Dictionary>(name: string): T;
108
+ /**
109
+ * Sets the database session context (row level security) for this entity manager. Session variables are merged
110
+ * with any previously set ones, while the role is replaced when one is provided. The variables are applied via `set_config()` and are
111
+ * typically referenced by RLS policies through `current_setting()`.
112
+ */
113
+ setSessionContext(context: SessionContext): void;
114
+ private validateSessionContextStaging;
115
+ /** Merges into this exact instance — `fork()` must bypass context resolution to target the new fork. */
116
+ private mergeSessionContext;
117
+ /**
118
+ * Returns the database session context (row level security) set for this entity manager, or `undefined` if none.
119
+ */
120
+ getSessionContext(): SessionContext | undefined;
121
+ /**
122
+ * Clears the database session context, since `setSessionContext()` only ever merges variables and updates the role.
123
+ */
124
+ clearSessionContext(): void;
125
+ /** @internal session context to apply on `begin()` under the `'transaction'` strategy (`undefined` otherwise). */
126
+ getTransactionSessionContext(): SessionContext | undefined;
127
+ /**
128
+ * Wraps a driver call in a short implicit transaction when the session context needs to apply, so `set local`
129
+ * takes effect. Resolves to a plain call when already inside a transaction or when no session context is set.
130
+ *
131
+ * @internal
132
+ */
133
+ withSessionContext<T>(ctx: Transaction | undefined, cb: (ctx?: Transaction) => Promise<T>): Promise<T>;
108
134
  /**
109
135
  * Sets logger context for this entity manager.
110
136
  */
@@ -700,6 +726,8 @@ export interface ForkOptions {
700
726
  keepTransactionContext?: boolean;
701
727
  /** default schema to use for this fork */
702
728
  schema?: string;
729
+ /** database session context (row level security) for this fork; inherited from the parent when not set */
730
+ session?: SessionContext;
703
731
  /** default logger context, can be overridden via {@apilink FindOptions} */
704
732
  loggerContext?: Dictionary;
705
733
  /**
package/EntityManager.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getOnConflictReturningFields, getOnCreateGeneratedFields, getWhereCondition, resetUntouchedCollections, } from './utils/upsert-utils.js';
2
+ import { computeRemovedRlsVariables, computeRlsFilterVariables, findRlsFilterDefs } from './utils/rls-utils.js';
2
3
  import { Utils } from './utils/Utils.js';
3
4
  import { Cursor } from './utils/Cursor.js';
4
5
  import { QueryHelper } from './utils/QueryHelper.js';
@@ -15,7 +16,7 @@ import { UnitOfWork } from './unit-of-work/UnitOfWork.js';
15
16
  import { EventType, FlushMode, LoadStrategy, LockMode, PopulateHint, PopulatePath, QueryFlag, ReferenceKind, SCALAR_TYPES, } from './enums.js';
16
17
  import { EventManager } from './events/EventManager.js';
17
18
  import { TransactionEventBroadcaster } from './events/TransactionEventBroadcaster.js';
18
- import { OptimisticLockError, ValidationError } from './errors.js';
19
+ import { MetadataError, OptimisticLockError, ValidationError } from './errors.js';
19
20
  import { applyPopulateHints, getLoadingStrategy } from './entity/utils.js';
20
21
  import { TransactionManager } from './utils/TransactionManager.js';
21
22
  /**
@@ -44,6 +45,7 @@ export class EntityManager {
44
45
  #resultCache;
45
46
  #filters = {};
46
47
  #filterParams = {};
48
+ #sessionContext;
47
49
  loggerContext;
48
50
  #transactionContext;
49
51
  #disableTransactions;
@@ -199,6 +201,11 @@ export class EntityManager {
199
201
  */
200
202
  async *stream(entityName, options = {}) {
201
203
  const em = this.getContext();
204
+ // a stream never opens the implicit session-context transaction, so under the 'transaction' strategy the staged
205
+ // context would silently never apply outside an ambient transaction and other tenants' rows would leak — fail closed
206
+ if (!options.ctx && !em.#transactionContext && em.getTransactionSessionContext()) {
207
+ throw ValidationError.sessionContextStreamRequiresTransaction();
208
+ }
202
209
  options = em.prepareOptions(options);
203
210
  options.strategy = 'joined';
204
211
  await em.tryFlush(entityName, options);
@@ -262,6 +269,14 @@ export class EntityManager {
262
269
  if (options.entity) {
263
270
  options.entity = Utils.asArray(options.entity).map(n => Utils.classOrName(n));
264
271
  }
272
+ // runtime-registered filters are never part of entity metadata, so no policy can be generated for them — whether or
273
+ // not an `entity` was scoped, `rls` is only valid on filters declared in metadata via `@Filter()`. `rls` is excluded
274
+ // from the type above so TS users fail at compile time; the runtime guard still covers JS callers using `as any`
275
+ if (options.rls) {
276
+ throw options.entity
277
+ ? MetadataError.rlsFilterCannotBeRegisteredAtRuntime(options.name)
278
+ : MetadataError.rlsFilterMustBeEntityScoped(options.name);
279
+ }
265
280
  options.default ??= true;
266
281
  this.getContext(false).#filters[options.name] = options;
267
282
  }
@@ -270,7 +285,37 @@ export class EntityManager {
270
285
  * If you want to set shared value for all contexts, be sure to use the root entity manager.
271
286
  */
272
287
  setFilterParams(name, args) {
273
- this.getContext().#filterParams[name] = args;
288
+ const em = this.getContext();
289
+ // `rls` filters mirror their params as session variables, so the matching DB policies see the same values;
290
+ // the same filter name can be declared on multiple entities, so stage the union across all `rls`-flagged defs
291
+ const filters = findRlsFilterDefs(em.metadata, name);
292
+ if (filters.length === 0) {
293
+ em.#filterParams[name] = args;
294
+ return;
295
+ }
296
+ // fail before storing the params and pruning stale variables below, so an invalid staging attempt leaves both
297
+ // the filter params and the session context untouched
298
+ em.validateSessionContextStaging();
299
+ const variables = computeRlsFilterVariables(filters, args);
300
+ const previousArgs = em.#filterParams[name];
301
+ em.#filterParams[name] = args;
302
+ // this call replaces the filter's params, so drop the exact variables a previous call for this filter staged but
303
+ // this one no longer sets (unless another filter's current params still stage them)
304
+ const staged = em.#sessionContext?.variables;
305
+ if (staged && previousArgs) {
306
+ for (const key of computeRemovedRlsVariables(em.metadata, name, filters, previousArgs, variables, em.#filterParams)) {
307
+ delete staged[key];
308
+ }
309
+ // pruning may have emptied the whole context — drop it, so it does not keep forcing the implicit
310
+ // transaction wrap (and a distinct cache key) while carrying no variables
311
+ if (Object.keys(staged).length === 0 && !em.#sessionContext.role) {
312
+ em.#sessionContext = undefined;
313
+ }
314
+ }
315
+ // an empty context would still switch on the implicit transaction wrapping
316
+ if (Object.keys(variables).length > 0) {
317
+ em.mergeSessionContext({ variables });
318
+ }
274
319
  }
275
320
  /**
276
321
  * Returns filter parameters for given filter set in this context.
@@ -278,6 +323,90 @@ export class EntityManager {
278
323
  getFilterParams(name) {
279
324
  return this.getContext().#filterParams[name];
280
325
  }
326
+ /**
327
+ * Sets the database session context (row level security) for this entity manager. Session variables are merged
328
+ * with any previously set ones, while the role is replaced when one is provided. The variables are applied via `set_config()` and are
329
+ * typically referenced by RLS policies through `current_setting()`.
330
+ */
331
+ setSessionContext(context) {
332
+ // validate the global context like `setFilterParams` — a tenant context set on the global EM
333
+ // would be silently inherited by every later fork
334
+ this.getContext().mergeSessionContext(context);
335
+ }
336
+ validateSessionContextStaging() {
337
+ if (!this.getPlatform().supportsRowLevelSecurity()) {
338
+ throw ValidationError.sessionContextNotSupported();
339
+ }
340
+ // staging inside an open transaction is inert under both strategies (the 'transaction' context is only emitted at
341
+ // top-level begin; the 'connection' context was applied when the pinned connection was reserved), while
342
+ // `getSessionContext()` would still claim it is set — fail closed regardless of strategy
343
+ if (this.#transactionContext) {
344
+ throw ValidationError.sessionContextInsideTransaction();
345
+ }
346
+ if (this.config.get('sessionContext') === 'transaction') {
347
+ // the context is applied on transaction begin, so without implicit transactions writes run untransacted and
348
+ // silently bypass the policies — fail closed instead of leaking a base-role write
349
+ if (this.config.get('implicitTransactions') === false) {
350
+ throw ValidationError.sessionContextRequiresImplicitTransactions();
351
+ }
352
+ // same rationale for disabled transactions (config or fork option): the UoW flush would run untransacted
353
+ // and skip the context while reads still get the per-statement wrap — fail closed on the asymmetry
354
+ if (this.#disableTransactions) {
355
+ throw ValidationError.sessionContextWithDisabledTransactions();
356
+ }
357
+ }
358
+ }
359
+ /** Merges into this exact instance — `fork()` must bypass context resolution to target the new fork. */
360
+ mergeSessionContext(context) {
361
+ this.validateSessionContextStaging();
362
+ const current = this.#sessionContext;
363
+ const merged = {
364
+ variables: { ...current?.variables, ...context.variables },
365
+ role: context.role ?? current?.role,
366
+ };
367
+ // normalize an empty context away — it would still force the implicit transaction wrap and a distinct cache key
368
+ this.#sessionContext = Object.keys(merged.variables).length > 0 || merged.role ? merged : undefined;
369
+ }
370
+ /**
371
+ * Returns the database session context (row level security) set for this entity manager, or `undefined` if none.
372
+ */
373
+ getSessionContext() {
374
+ return this.getContext(false).#sessionContext;
375
+ }
376
+ /**
377
+ * Clears the database session context, since `setSessionContext()` only ever merges variables and updates the role.
378
+ */
379
+ clearSessionContext() {
380
+ const em = this.getContext(false);
381
+ // clearing inside an open transaction would be as inert (and cache-poisoning) as staging there — fail closed too,
382
+ // under both strategies (the 'connection' pinned connection was already reserved with the previous context)
383
+ if (em.#sessionContext && em.#transactionContext) {
384
+ throw ValidationError.sessionContextInsideTransaction('clear');
385
+ }
386
+ em.#sessionContext = undefined;
387
+ }
388
+ /** @internal session context to apply on `begin()` under the `'transaction'` strategy (`undefined` otherwise). */
389
+ getTransactionSessionContext() {
390
+ const em = this.getContext(false);
391
+ if (!em.#sessionContext || em.config.get('sessionContext') !== 'transaction') {
392
+ return undefined;
393
+ }
394
+ return em.#sessionContext;
395
+ }
396
+ /**
397
+ * Wraps a driver call in a short implicit transaction when the session context needs to apply, so `set local`
398
+ * takes effect. Resolves to a plain call when already inside a transaction or when no session context is set.
399
+ *
400
+ * @internal
401
+ */
402
+ async withSessionContext(ctx, cb) {
403
+ const em = this.getContext(false);
404
+ const sessionContext = ctx ? undefined : em.getTransactionSessionContext();
405
+ if (!sessionContext) {
406
+ return cb(ctx);
407
+ }
408
+ return em.getConnection('write').transactional(trx => cb(trx), { sessionContext, loggerContext: em.loggerContext });
409
+ }
281
410
  /**
282
411
  * Sets logger context for this entity manager.
283
412
  */
@@ -836,12 +965,12 @@ export class EntityManager {
836
965
  data = em.#comparator.prepareEntity(entity);
837
966
  }
838
967
  }
839
- const ret = await em.driver.nativeUpdate(entityName, where, data, {
840
- ctx: em.#transactionContext,
968
+ const ret = await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => em.driver.nativeUpdate(entityName, where, data, {
841
969
  upsert: true,
842
970
  convertCustomTypes,
843
971
  ...options,
844
- });
972
+ ctx,
973
+ }));
845
974
  em.#unitOfWork.getChangeSetPersister().mapReturnedValues(entity, data, ret.row, meta, true);
846
975
  entity ??= em.#entityFactory.create(entityName, data, {
847
976
  refresh: true,
@@ -873,13 +1002,13 @@ export class EntityManager {
873
1002
  where[meta.primaryKeys[0]] = ret.insertId;
874
1003
  }
875
1004
  }
876
- const data2 = await this.driver.findOne(meta.class, where, {
1005
+ const data2 = await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => this.driver.findOne(meta.class, where, {
877
1006
  fields: returning.concat(...(options.onConflictMergeFields ?? [])),
878
- ctx: em.#transactionContext,
1007
+ ctx,
879
1008
  convertCustomTypes: true,
880
1009
  connectionType: 'write',
881
1010
  schema: options.schema,
882
- });
1011
+ }));
883
1012
  em.getHydrator().hydrate(entity, meta, data2, em.#entityFactory, 'full', false, true);
884
1013
  }
885
1014
  // recompute the data as there might be some values missing (e.g. those with db column defaults)
@@ -1023,12 +1152,12 @@ export class EntityManager {
1023
1152
  allData[idx] = em.#comparator.prepareEntity(entity);
1024
1153
  }
1025
1154
  }
1026
- const res = await em.driver.nativeUpdateMany(entityName, allWhere, allData, {
1027
- ctx: em.#transactionContext,
1155
+ const res = await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => em.driver.nativeUpdateMany(entityName, allWhere, allData, {
1028
1156
  upsert: true,
1029
1157
  convertCustomTypes,
1030
1158
  ...options,
1031
- });
1159
+ ctx,
1160
+ }));
1032
1161
  entities.clear();
1033
1162
  entitiesByData.clear();
1034
1163
  const loadPK = new Map();
@@ -1071,16 +1200,16 @@ export class EntityManager {
1071
1200
  where.$or[idx][prop] = item[prop];
1072
1201
  });
1073
1202
  });
1074
- const data2 = await this.driver.find(meta.class, where, {
1203
+ const data2 = await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => this.driver.find(meta.class, where, {
1075
1204
  fields: returning
1076
1205
  .concat(...add)
1077
1206
  .concat(...(Array.isArray(uniqueFields) ? uniqueFields : []))
1078
1207
  .concat(...(options.onConflictMergeFields ?? [])),
1079
- ctx: em.#transactionContext,
1208
+ ctx,
1080
1209
  convertCustomTypes: true,
1081
1210
  connectionType: 'write',
1082
1211
  schema: options.schema,
1083
- });
1212
+ }));
1084
1213
  for (const [entity, cond] of loadPK.entries()) {
1085
1214
  const row = data2.find(row => {
1086
1215
  const tmp = {};
@@ -1178,6 +1307,7 @@ export class EntityManager {
1178
1307
  }
1179
1308
  const em = this.getContext(false);
1180
1309
  em.#transactionContext = await em.getConnection('write').begin({
1310
+ sessionContext: em.getTransactionSessionContext(),
1181
1311
  ...options,
1182
1312
  eventBroadcaster: new TransactionEventBroadcaster(em, { topLevelTransaction: !options.ctx }),
1183
1313
  });
@@ -1245,15 +1375,12 @@ export class EntityManager {
1245
1375
  const meta = helper(data).__meta;
1246
1376
  const payload = em.#comparator.prepareEntity(data);
1247
1377
  const cs = new ChangeSet(data, ChangeSetType.CREATE, payload, meta);
1248
- await em.#unitOfWork.getChangeSetPersister().executeInserts([cs], { ctx: em.#transactionContext, ...options });
1378
+ await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => em.#unitOfWork.getChangeSetPersister().executeInserts([cs], { ...options, ctx }));
1249
1379
  return cs.getPrimaryKey();
1250
1380
  }
1251
1381
  data = QueryHelper.processObjectParams(data);
1252
1382
  validateParams(data, 'insert data');
1253
- const res = await em.driver.nativeInsert(entityName, data, {
1254
- ctx: em.#transactionContext,
1255
- ...options,
1256
- });
1383
+ const res = await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => em.driver.nativeInsert(entityName, data, { ...options, ctx }));
1257
1384
  return res.insertId;
1258
1385
  }
1259
1386
  /**
@@ -1295,10 +1422,7 @@ export class EntityManager {
1295
1422
  options ??= {};
1296
1423
  options = em.prepareOptions(options);
1297
1424
  const meta = em.metadata.get(entityName);
1298
- const res = await em.driver.nativeClone(entityName, where, overrides, {
1299
- ctx: em.#transactionContext,
1300
- ...options,
1301
- });
1425
+ const res = await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => em.driver.nativeClone(entityName, where, overrides, { ...options, ctx }));
1302
1426
  const pk = res.insertId ?? res.row?.[meta.primaryKeys[0]];
1303
1427
  return em.findOneOrFail(entityName, pk, {
1304
1428
  schema: options.schema,
@@ -1333,15 +1457,12 @@ export class EntityManager {
1333
1457
  const payload = em.#comparator.prepareEntity(row);
1334
1458
  return new ChangeSet(row, ChangeSetType.CREATE, payload, meta);
1335
1459
  });
1336
- await em.#unitOfWork.getChangeSetPersister().executeInserts(css, { ctx: em.#transactionContext, ...options });
1460
+ await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => em.#unitOfWork.getChangeSetPersister().executeInserts(css, { ...options, ctx }));
1337
1461
  return css.map(cs => cs.getPrimaryKey());
1338
1462
  }
1339
1463
  data = data.map(row => QueryHelper.processObjectParams(row));
1340
1464
  data.forEach(row => validateParams(row, 'insert data'));
1341
- const res = await em.driver.nativeInsertMany(entityName, data, {
1342
- ctx: em.#transactionContext,
1343
- ...options,
1344
- });
1465
+ const res = await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => em.driver.nativeInsertMany(entityName, data, { ...options, ctx }));
1345
1466
  if (res.insertedIds) {
1346
1467
  return res.insertedIds;
1347
1468
  }
@@ -1358,11 +1479,11 @@ export class EntityManager {
1358
1479
  where = await em.processWhere(entityName, where, { ...options, convertCustomTypes: false }, 'update');
1359
1480
  validateParams(data, 'update data');
1360
1481
  validateParams(where, 'update condition');
1361
- const res = await em.driver.nativeUpdate(entityName, where, data, {
1362
- ctx: em.#transactionContext,
1482
+ const res = await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => em.driver.nativeUpdate(entityName, where, data, {
1363
1483
  em,
1364
1484
  ...options,
1365
- });
1485
+ ctx,
1486
+ }));
1366
1487
  return res.affectedRows;
1367
1488
  }
1368
1489
  /**
@@ -1394,7 +1515,7 @@ export class EntityManager {
1394
1515
  throw new Error(`Routine '${routine.name}' is not registered in the 'routines' config option.`);
1395
1516
  }
1396
1517
  const conn = em.driver.getConnection('write');
1397
- return conn.callRoutine(routine, args, em.#transactionContext);
1518
+ return em.withSessionContext(em.#transactionContext, ctx => conn.callRoutine(routine, args, ctx));
1398
1519
  }
1399
1520
  /**
1400
1521
  * Fires native delete query. Calling this has no side effects on the context (identity map).
@@ -1405,11 +1526,11 @@ export class EntityManager {
1405
1526
  await em.processUnionWhere(entityName, options, 'delete');
1406
1527
  where = (await em.processWhere(entityName, where, options, 'delete'));
1407
1528
  validateParams(where, 'delete condition');
1408
- const res = await em.driver.nativeDelete(entityName, where, {
1409
- ctx: em.#transactionContext,
1529
+ const res = await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => em.driver.nativeDelete(entityName, where, {
1410
1530
  em,
1411
1531
  ...options,
1412
- });
1532
+ ctx,
1533
+ }));
1413
1534
  return res.affectedRows;
1414
1535
  }
1415
1536
  /**
@@ -1548,7 +1669,7 @@ export class EntityManager {
1548
1669
  if (cached?.data !== undefined) {
1549
1670
  return cached.data;
1550
1671
  }
1551
- const count = await em.driver.count(entityName, where, { ctx: em.#transactionContext, em, ...options });
1672
+ const count = await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => em.driver.count(entityName, where, { em, ...options, ctx }));
1552
1673
  await em.storeCache(options.cache, cached, () => +count);
1553
1674
  return +count;
1554
1675
  }
@@ -1713,6 +1834,18 @@ export class EntityManager {
1713
1834
  }
1714
1835
  fork.#filters = { ...em.#filters };
1715
1836
  fork.#filterParams = Utils.copy(em.#filterParams);
1837
+ if (options.session) {
1838
+ // the fork's session replaces the parent context, but the copied `rls` filter params must stay consistent with
1839
+ // it — re-stage their variables underneath the explicit `session.variables`, which win on any conflict
1840
+ const variables = {};
1841
+ for (const name of Object.keys(fork.#filterParams)) {
1842
+ Object.assign(variables, computeRlsFilterVariables(findRlsFilterDefs(fork.metadata, name), fork.#filterParams[name]));
1843
+ }
1844
+ fork.mergeSessionContext({ ...options.session, variables: { ...variables, ...options.session.variables } });
1845
+ }
1846
+ else if (em.#sessionContext) {
1847
+ fork.#sessionContext = Utils.copy(em.#sessionContext);
1848
+ }
1716
1849
  fork.loggerContext = Utils.merge({}, em.loggerContext, options.loggerContext);
1717
1850
  fork.#schema = options.schema ?? em.#schema;
1718
1851
  fork.signal = options.signal ?? em.signal;
@@ -2127,8 +2260,16 @@ export class EntityManager {
2127
2260
  // the table name (plus discriminator value for STI) is stable across builds and processes,
2128
2261
  // unlike class names, which minifiers can mangle to the same short name for two entities
2129
2262
  const meta = this.metadata.find(entityName);
2130
- const key = meta?.tableName ? [meta.schema, meta.tableName, meta.discriminatorValue] : Utils.className(entityName);
2131
- return [key, method, opts, where];
2263
+ const entityKey = meta?.tableName
2264
+ ? [meta.schema, meta.tableName, meta.discriminatorValue]
2265
+ : Utils.className(entityName);
2266
+ const key = [entityKey, method, opts, where];
2267
+ // session context (row level security) scopes cached rows per tenant/role, avoiding cross-context serves
2268
+ const sessionContext = this.getContext(false).#sessionContext;
2269
+ if (sessionContext) {
2270
+ key.push(sessionContext);
2271
+ }
2272
+ return key;
2132
2273
  }
2133
2274
  /**
2134
2275
  * @internal
@@ -2139,7 +2280,13 @@ export class EntityManager {
2139
2280
  return undefined;
2140
2281
  }
2141
2282
  const em = this.getContext();
2142
- const cacheKey = Array.isArray(config) ? config[0] : JSON.stringify(key);
2283
+ // a named cache key (`config[0]`) discards the computed `key`, which already carries the session context — so
2284
+ // scope it here too, otherwise a fork's rows would be served to another session context under the same name
2285
+ const cacheKey = Array.isArray(config)
2286
+ ? em.#sessionContext
2287
+ ? `${config[0]}|${JSON.stringify(em.#sessionContext)}`
2288
+ : config[0]
2289
+ : JSON.stringify(key);
2143
2290
  const cached = await em.#resultCache.get(cacheKey);
2144
2291
  if (!cached) {
2145
2292
  return { key: cacheKey, data: cached };
@@ -2187,7 +2334,12 @@ export class EntityManager {
2187
2334
  * ```
2188
2335
  */
2189
2336
  async clearCache(cacheKey) {
2190
- await this.getContext().#resultCache.remove(cacheKey);
2337
+ const em = this.getContext();
2338
+ await em.#resultCache.remove(cacheKey);
2339
+ // named keys are scoped by the session context (see `tryCache`), so clear this context's variant too
2340
+ if (em.#sessionContext) {
2341
+ await em.#resultCache.remove(`${cacheKey}|${JSON.stringify(em.#sessionContext)}`);
2342
+ }
2191
2343
  }
2192
2344
  /**
2193
2345
  * Returns the default schema of this EntityManager. Respects the context, so global EM will give you the contextual schema
package/MikroORM.js CHANGED
@@ -2,6 +2,7 @@ import { MetadataDiscovery } from './metadata/MetadataDiscovery.js';
2
2
  import { MetadataStorage } from './metadata/MetadataStorage.js';
3
3
  import { Configuration } from './utils/Configuration.js';
4
4
  import { loadEnvironmentVars } from './utils/env-vars.js';
5
+ import { clearRlsFilterDefsCache } from './utils/rls-utils.js';
5
6
  import { Utils } from './utils/Utils.js';
6
7
  import { colors } from './logging/colors.js';
7
8
  async function tryRegisterExtension(name, pkg, extensions) {
@@ -195,6 +196,8 @@ export class MikroORM {
195
196
  meta.root = this.#metadata.get(meta.root.class);
196
197
  }
197
198
  this.#metadata.decorate(this.em);
199
+ // the newly discovered entities may declare `rls` filters the cached lookup was built without
200
+ clearRlsFilterDefsCache(this.#metadata);
198
201
  }
199
202
  /**
200
203
  * Gets the SchemaGenerator.
@@ -1,7 +1,7 @@
1
1
  import { type Configuration, type ConnectionOptions } from '../utils/Configuration.js';
2
2
  import type { LogContext, Logger } from '../logging/Logger.js';
3
3
  import type { MetadataStorage } from '../metadata/MetadataStorage.js';
4
- import type { ConnectionType, Dictionary, MaybePromise, Primary, RoutineProperty } from '../typings.js';
4
+ import type { ConnectionType, Dictionary, MaybePromise, Primary, RoutineProperty, SessionContext } from '../typings.js';
5
5
  import type { Routine } from '../metadata/Routine.js';
6
6
  import type { Platform } from '../platforms/Platform.js';
7
7
  import type { Type } from '../types/Type.js';
@@ -66,6 +66,7 @@ export declare abstract class Connection {
66
66
  ctx?: Transaction;
67
67
  eventBroadcaster?: TransactionEventBroadcaster;
68
68
  loggerContext?: LogContext;
69
+ sessionContext?: SessionContext;
69
70
  }): Promise<T>;
70
71
  /** Begins a new database transaction and returns the transaction context. */
71
72
  begin(options?: {
@@ -74,6 +75,7 @@ export declare abstract class Connection {
74
75
  ctx?: Transaction;
75
76
  eventBroadcaster?: TransactionEventBroadcaster;
76
77
  loggerContext?: LogContext;
78
+ sessionContext?: SessionContext;
77
79
  }): Promise<Transaction>;
78
80
  /** Commits the given transaction. */
79
81
  commit(ctx: Transaction, eventBroadcaster?: TransactionEventBroadcaster, loggerContext?: LogContext): Promise<void>;
@@ -117,9 +117,11 @@ export class Collection {
117
117
  opts.orderBy = QueryHelper.mergeOrderBy(opts.orderBy, this.property.orderBy, this.property.targetMeta?.orderBy);
118
118
  options.populate = (await em.preparePopulate(this.property.targetMeta.class, options));
119
119
  const cond = (await em.applyFilters(this.property.targetMeta.class, where, options.filters ?? {}, 'read'));
120
- const map = await em
120
+ // fall back to the ambient transaction context, or `withSessionContext` would wrap the pivot load in a
121
+ // second concurrent transaction (a deadlock with a single-connection pool) instead of joining the open one
122
+ const map = await em.withSessionContext(ctx ?? em.getTransactionContext(), trx => em
121
123
  .getDriver()
122
- .loadFromPivotTable(this.property, [helper(this.owner).__primaryKeys], cond, opts.orderBy, ctx, options);
124
+ .loadFromPivotTable(this.property, [helper(this.owner).__primaryKeys], cond, opts.orderBy, trx, options));
123
125
  items = map[helper(this.owner).getSerializedPrimaryKey()].map((item) => em.merge(this.property.targetMeta.class, item, { convertCustomTypes: true }));
124
126
  await em.populate(items, options.populate, options);
125
127
  }
@@ -624,7 +624,7 @@ export class EntityLoader {
624
624
  if (!Utils.isEmpty(prop.where)) {
625
625
  where = { $and: [where, prop.where] };
626
626
  }
627
- const map = await this.#driver.loadFromPivotTable(prop, ids, where, orderBy, this.#em.getTransactionContext(), options2, pivotJoin);
627
+ const map = await this.#em.withSessionContext(this.#em.getTransactionContext(), ctx => this.#driver.loadFromPivotTable(prop, ids, where, orderBy, ctx, options2, pivotJoin));
628
628
  const children = [];
629
629
  const isUnionTargetMN = QueryHelper.isUnionTargetPolymorphic(prop);
630
630
  for (let i = 0; i < filtered.length; i++) {
@@ -647,6 +647,9 @@ export interface EntityMetadataWithProperties<TName extends string, TTableName e
647
647
  entity?: EntityName<any> | EntityName<any>[];
648
648
  args?: boolean;
649
649
  strict?: boolean;
650
+ rls?: boolean | {
651
+ setting?: string;
652
+ };
650
653
  }>;
651
654
  forceObject?: TForceObject;
652
655
  embeddable?: TEmbeddable;
package/enums.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { EntityKey, ExpandProperty } from './typings.js';
1
+ import type { EntityKey, ExpandProperty, SessionContext } from './typings.js';
2
2
  import type { InflightQueryAbortStrategy, Transaction } from './connections/Connection.js';
3
3
  import type { LogContext } from './logging/Logger.js';
4
4
  /** Controls when the `EntityManager` flushes pending changes to the database. */
@@ -307,6 +307,8 @@ export interface TransactionOptions {
307
307
  flushMode?: FlushMode | `${FlushMode}`;
308
308
  ignoreNestedTransactions?: boolean;
309
309
  loggerContext?: LogContext;
310
+ /** @internal database session context applied on `begin()` (set via `em.setSessionContext()`). */
311
+ sessionContext?: SessionContext;
310
312
  /**
311
313
  * `AbortSignal` cancelling every query within the transaction (including the implicit flush).
312
314
  * Cancelling mid-transaction triggers a rollback once the in-flight query settles.
package/errors.d.ts CHANGED
@@ -25,6 +25,13 @@ export declare class ValidationError<T extends AnyEntity = AnyEntity> extends Er
25
25
  static invalidCompositeIdentifier(meta: EntityMetadata): ValidationError;
26
26
  static cannotCommit(): ValidationError;
27
27
  static cannotUseGlobalContext(): ValidationError;
28
+ static sessionContextNotSupported(): ValidationError;
29
+ static sessionContextRequiresImplicitTransactions(): ValidationError;
30
+ static sessionContextWithDisabledTransactions(): ValidationError;
31
+ static sessionContextInsideTransaction(action?: 'set' | 'clear'): ValidationError;
32
+ static cannotStageNonScalarSessionVariable(filterName: string, argName: string): ValidationError;
33
+ static sessionContextStreamRequiresTransaction(): ValidationError;
34
+ static connectionSessionContextNotSupported(): ValidationError;
28
35
  static cannotUseOperatorsInsideEmbeddables(entityName: EntityName, propName: string, payload: unknown): ValidationError;
29
36
  static cannotUseGroupOperatorsInsideScalars(entityName: EntityName, propName: string, payload: unknown): ValidationError;
30
37
  static invalidEmbeddableQuery(entityName: EntityName, propName: string, embeddableType: string): ValidationError;
@@ -77,6 +84,30 @@ export declare class MetadataError<T extends AnyEntity = AnyEntity> extends Vali
77
84
  static tptNotSupportedByDriver(meta: EntityMetadata): MetadataError;
78
85
  /** Thrown when database triggers are defined on an entity using a driver that does not support them. */
79
86
  static triggersNotSupportedByDriver(meta: EntityMetadata): MetadataError;
87
+ /** Thrown when row level security is declared on an entity using a driver that does not support it. */
88
+ static rowLevelSecurityNotSupportedByDriver(meta: EntityMetadata): MetadataError;
89
+ /** Thrown when row level security is declared on a non-root entity of an STI hierarchy. */
90
+ static rowLevelSecurityOnNonRootStiEntity(meta: EntityMetadata): MetadataError;
91
+ /** Thrown when two policies on the same entity are given the same explicit name. */
92
+ static duplicatePolicyName(meta: EntityMetadata, name: string): MetadataError;
93
+ /** Thrown when a filter flagged with `rls` is declared on a driver that does not support row level security. */
94
+ static rlsFilterNotSupportedByDriver(meta: EntityMetadata, filterName: string): MetadataError;
95
+ /** Thrown when a filter flagged with `rls` is declared on a non-root entity of an STI hierarchy. */
96
+ static rlsFilterOnNonRootStiEntity(meta: EntityMetadata, filterName: string): MetadataError;
97
+ /** Thrown when a global (config or EM registered) filter is flagged with `rls`; RLS filters must be entity scoped. */
98
+ static rlsFilterMustBeEntityScoped(filterName: string): MetadataError;
99
+ /** Thrown when an entity-scoped `rls` filter is registered at runtime via `em.addFilter()` instead of in metadata. */
100
+ static rlsFilterCannotBeRegisteredAtRuntime(filterName: string): MetadataError;
101
+ /** Thrown when a filter's custom `setting` is used with more than one argument. */
102
+ static rlsFilterMultiArgSetting(filterName: string, args: string[]): MetadataError;
103
+ /** Thrown when an `rls` filter's condition depends on runtime state and cannot be compiled to a static policy. */
104
+ static rlsFilterDependsOnRuntimeState(filterName: string): MetadataError;
105
+ /** Thrown when an `rls` filter compares against a column whose type has no automatic session-variable cast. */
106
+ static rlsFilterUncastableType(filterName: string, columnType: string): MetadataError;
107
+ /** Thrown when an `rls` filter references an argument outside of a direct comparison, which cannot be compiled. */
108
+ static rlsFilterUnsupportedCond(filterName: string): MetadataError;
109
+ /** Thrown when an `rls` filter compares against a column the schema generator does not manage. */
110
+ static rlsFilterUnmanagedColumn(filterName: string, column: string): MetadataError;
80
111
  private static fromMessage;
81
112
  }
82
113
  /** Error thrown when an entity lookup fails to find the expected result. */