@mikro-orm/sql 7.1.16-dev.10 → 7.1.16-dev.12

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.
@@ -1,5 +1,5 @@
1
1
  import { type ControlledTransaction, type Dialect, Kysely } from 'kysely';
2
- import { type AnyEntity, Connection, type Dictionary, type EntityData, type IsolationLevel, type LogContext, type LoggingOptions, type MaybePromise, type QueryResult, type RawQueryFragment, type Transaction, type TransactionEventBroadcaster } from '@mikro-orm/core';
2
+ import { type AnyEntity, Connection, type Dictionary, type EntityData, type IsolationLevel, type LogContext, type LoggingOptions, type MaybePromise, type QueryResult, type RawQueryFragment, type SessionContext, type Transaction, type TransactionEventBroadcaster } from '@mikro-orm/core';
3
3
  import type { AbstractSqlPlatform } from './AbstractSqlPlatform.js';
4
4
  import { NativeQueryBuilder } from './query/NativeQueryBuilder.js';
5
5
  /** Base class for SQL database connections, built on top of Kysely. */
@@ -36,6 +36,13 @@ export declare abstract class AbstractSqlConnection extends Connection {
36
36
  getClient<T = any>(): Kysely<T>;
37
37
  /** Ensures the Kysely client is initialized, creating it asynchronously if needed. */
38
38
  initClient(): Promise<void>;
39
+ /**
40
+ * Guards `getNativeClient()` overrides — drivers only capture their client while building their
41
+ * own dialect, which `driverOptions` carrying a ready-made Kysely instance or dialect skips.
42
+ *
43
+ * @internal
44
+ */
45
+ protected requireNativeClient<T>(client: T | undefined | null): T;
39
46
  /** Executes a callback within a transaction, committing on success and rolling back on error. */
40
47
  transactional<T>(cb: (trx: Transaction<ControlledTransaction<any, any>>) => Promise<T>, options?: {
41
48
  isolationLevel?: IsolationLevel;
@@ -43,6 +50,7 @@ export declare abstract class AbstractSqlConnection extends Connection {
43
50
  ctx?: ControlledTransaction<any>;
44
51
  eventBroadcaster?: TransactionEventBroadcaster;
45
52
  loggerContext?: LogContext;
53
+ sessionContext?: SessionContext;
46
54
  }): Promise<T>;
47
55
  /** Begins a new transaction or creates a savepoint if a transaction context already exists. */
48
56
  begin(options?: {
@@ -51,7 +59,17 @@ export declare abstract class AbstractSqlConnection extends Connection {
51
59
  ctx?: ControlledTransaction<any, any>;
52
60
  eventBroadcaster?: TransactionEventBroadcaster;
53
61
  loggerContext?: LogContext;
62
+ sessionContext?: SessionContext;
54
63
  }): Promise<ControlledTransaction<any, any>>;
64
+ /** Applies session variables (`set_config`) and role (`set local role`) for the current transaction. */
65
+ private applySessionContext;
66
+ /**
67
+ * Quotes a role name as a single PostgreSQL identifier. Unlike `platform.quoteIdentifier`, which treats a dot as a
68
+ * schema qualifier, a role like `my.role` must be quoted whole (`"my.role"`) with embedded quotes doubled.
69
+ */
70
+ protected static quoteRole(role: string): string;
71
+ /** Serializes a session variable for `set_config()`; `Date` values use ISO 8601 so casts like `::timestamptz` parse. */
72
+ protected stringifySessionVariable(value: unknown): string;
55
73
  /** Commits the transaction or releases the savepoint. */
56
74
  commit(ctx: ControlledTransaction<any, any>, eventBroadcaster?: TransactionEventBroadcaster, loggerContext?: LogContext): Promise<void>;
57
75
  /** Rolls back the transaction or rolls back to the savepoint. */
@@ -67,9 +85,9 @@ export declare abstract class AbstractSqlConnection extends Connection {
67
85
  private waitForIdleTransaction;
68
86
  private prepareQuery;
69
87
  /** Executes a SQL query and returns the result based on the method: `'all'` for rows, `'get'` for single row, `'run'` for affected count. */
70
- execute<T extends QueryResult | EntityData<AnyEntity> | EntityData<AnyEntity>[] = EntityData<AnyEntity>[]>(query: string | NativeQueryBuilder | RawQueryFragment, params?: readonly unknown[], method?: 'all' | 'get' | 'run', ctx?: Transaction, loggerContext?: LoggingOptions): Promise<T>;
88
+ execute<T extends QueryResult | EntityData<AnyEntity> | EntityData<AnyEntity>[] = EntityData<AnyEntity>[]>(query: string | NativeQueryBuilder | RawQueryFragment, params?: readonly unknown[] | Dictionary<unknown>, method?: 'all' | 'get' | 'run', ctx?: Transaction, loggerContext?: LoggingOptions): Promise<T>;
71
89
  /** Executes a SQL query and returns an async iterable that yields results row by row. */
72
- stream<T extends EntityData<AnyEntity>>(query: string | NativeQueryBuilder | RawQueryFragment, params?: readonly unknown[], ctx?: Transaction<Kysely<any>>, loggerContext?: LoggingOptions, chunkSize?: number): AsyncIterableIterator<T>;
90
+ stream<T extends EntityData<AnyEntity>>(query: string | NativeQueryBuilder | RawQueryFragment, params?: readonly unknown[] | Dictionary<unknown>, ctx?: Transaction<Kysely<any>>, loggerContext?: LoggingOptions, chunkSize?: number): AsyncIterableIterator<T>;
73
91
  /** @inheritDoc */
74
92
  executeDump(dump: string): Promise<void>;
75
93
  protected getSql(query: string, formatted: string, context?: LogContext): string;
@@ -1,5 +1,5 @@
1
1
  import { CompiledQuery, Kysely } from 'kysely';
2
- import { Connection, EventType, isRaw, Utils, } from '@mikro-orm/core';
2
+ import { Connection, EventType, isRaw, raw, Utils, } from '@mikro-orm/core';
3
3
  import { NativeQueryBuilder } from './query/NativeQueryBuilder.js';
4
4
  /**
5
5
  * Pulls cancellation controls out of a `loggerContext` payload, returning the abort options
@@ -101,6 +101,18 @@ export class AbstractSqlConnection extends Connection {
101
101
  await this.createKysely();
102
102
  }
103
103
  }
104
+ /**
105
+ * Guards `getNativeClient()` overrides — drivers only capture their client while building their
106
+ * own dialect, which `driverOptions` carrying a ready-made Kysely instance or dialect skips.
107
+ *
108
+ * @internal
109
+ */
110
+ requireNativeClient(client) {
111
+ if (client == null) {
112
+ throw new Error('The native client is not available, as it is owned by the Kysely instance or dialect passed via `driverOptions`. Access it through the object you provided there instead.');
113
+ }
114
+ return client;
115
+ }
104
116
  /** Executes a callback within a transaction, committing on success and rolling back on error. */
105
117
  async transactional(cb, options = {}) {
106
118
  const trx = await this.begin(options);
@@ -149,9 +161,43 @@ export class AbstractSqlConnection extends Connection {
149
161
  for (const query of this.platform.getBeginTransactionSQL(options)) {
150
162
  this.logQuery(query, options.loggerContext);
151
163
  }
164
+ if (options.sessionContext) {
165
+ try {
166
+ await this.applySessionContext(trx, options.sessionContext, options.loggerContext);
167
+ }
168
+ catch (e) {
169
+ // roll back the freshly opened transaction so the pooled connection is released, not leaked
170
+ await trx.rollback().execute();
171
+ this.logQuery(this.platform.getRollbackTransactionSQL(), options.loggerContext);
172
+ throw e;
173
+ }
174
+ }
152
175
  await options.eventBroadcaster?.dispatchEvent(EventType.afterTransactionStart, trx);
153
176
  return trx;
154
177
  }
178
+ /** Applies session variables (`set_config`) and role (`set local role`) for the current transaction. */
179
+ async applySessionContext(trx, sessionContext, loggerContext) {
180
+ const variables = Object.entries(sessionContext.variables ?? {});
181
+ if (variables.length > 0) {
182
+ const parts = variables.map(() => 'set_config(?, ?, true)').join(', ');
183
+ const params = variables.flatMap(([key, value]) => [key, this.stringifySessionVariable(value)]);
184
+ await this.execute(`select ${parts}`, params, 'run', trx, loggerContext);
185
+ }
186
+ if (sessionContext.role) {
187
+ await this.execute(`set local role ${AbstractSqlConnection.quoteRole(sessionContext.role)}`, [], 'run', trx, loggerContext);
188
+ }
189
+ }
190
+ /**
191
+ * Quotes a role name as a single PostgreSQL identifier. Unlike `platform.quoteIdentifier`, which treats a dot as a
192
+ * schema qualifier, a role like `my.role` must be quoted whole (`"my.role"`) with embedded quotes doubled.
193
+ */
194
+ static quoteRole(role) {
195
+ return `"${role.replaceAll('"', '""')}"`;
196
+ }
197
+ /** Serializes a session variable for `set_config()`; `Date` values use ISO 8601 so casts like `::timestamptz` parse. */
198
+ stringifySessionVariable(value) {
199
+ return value instanceof Date ? value.toISOString() : String(value);
200
+ }
155
201
  /** Commits the transaction or releases the savepoint. */
156
202
  async commit(ctx, eventBroadcaster, loggerContext) {
157
203
  if (ctx.isRolledBack) {
@@ -197,13 +243,17 @@ export class AbstractSqlConnection extends Connection {
197
243
  if (query instanceof NativeQueryBuilder) {
198
244
  query = query.toRaw();
199
245
  }
246
+ if (typeof query === 'string' && !Array.isArray(params)) {
247
+ // plain object params hold named parameters, translate them via the `raw()` helper
248
+ query = raw(query, params);
249
+ }
200
250
  if (isRaw(query)) {
201
251
  params = query.params;
202
252
  query = query.sql;
203
253
  }
204
254
  query = this.config.get('onQuery')(query, params);
205
255
  const formatted = this.platform.formatQuery(query, params);
206
- return { query, params, formatted };
256
+ return { query, params: params, formatted };
207
257
  }
208
258
  /** Executes a SQL query and returns the result based on the method: `'all'` for rows, `'get'` for single row, `'run'` for affected count. */
209
259
  async execute(query, params = [], method = 'all', ctx, loggerContext) {
@@ -237,7 +287,7 @@ export class AbstractSqlConnection extends Connection {
237
287
  .stream(compiled, chunkSize ?? 100, abort ? { signal: abort.signal } : undefined);
238
288
  this.logQuery(sql, {
239
289
  sql,
240
- params,
290
+ params: q.params,
241
291
  ...cleanCtx,
242
292
  affected: Utils.isPlainObject(res) ? res.affectedRows : undefined,
243
293
  });
@@ -248,7 +298,7 @@ export class AbstractSqlConnection extends Connection {
248
298
  }
249
299
  }
250
300
  catch (e) {
251
- this.logQuery(sql, { sql, params, ...cleanCtx, level: 'error' });
301
+ this.logQuery(sql, { sql, params: q.params, ...cleanCtx, level: 'error' });
252
302
  throw e;
253
303
  }
254
304
  }
@@ -100,7 +100,7 @@ export declare abstract class AbstractSqlDriver<Connection extends AbstractSqlCo
100
100
  */
101
101
  private convertOwnerPksForPivotQuery;
102
102
  private getPivotOrderBy;
103
- execute<T extends QueryResult | EntityData<AnyEntity> | EntityData<AnyEntity>[] = EntityData<AnyEntity>[]>(query: string | NativeQueryBuilder | RawQueryFragment, params?: any[], method?: 'all' | 'get' | 'run', ctx?: Transaction, loggerContext?: LoggingOptions): Promise<T>;
103
+ execute<T extends QueryResult | EntityData<AnyEntity> | EntityData<AnyEntity>[] = EntityData<AnyEntity>[]>(query: string | NativeQueryBuilder | RawQueryFragment, params?: any[] | Dictionary, method?: 'all' | 'get' | 'run', ctx?: Transaction, loggerContext?: LoggingOptions): Promise<T>;
104
104
  stream<T extends object>(entityName: EntityName<T>, where: FilterQuery<T>, options: StreamOptions<T, any, any, any>): AsyncIterableIterator<T>;
105
105
  /**
106
106
  * 1:1 owner side needs to be marked for population so QB auto-joins the owner id
@@ -183,7 +183,9 @@ export class AbstractSqlDriver extends DatabaseDriver {
183
183
  if (typeof meta.expression === 'string') {
184
184
  return this.wrapVirtualExpressionInSubquery(meta, meta.expression, where, options, type);
185
185
  }
186
- const em = this.createEntityManager();
186
+ // fork the caller EM so the callback inherits its filters, filter params and session context — a fork never
187
+ // resolves back to the ambient RequestContext EM, which would ignore the transaction/session context set below
188
+ const em = (options.em?.fork() ?? this.createEntityManager(false));
187
189
  em.setTransactionContext(options.ctx);
188
190
  const res = meta.expression(em, where, options);
189
191
  if (typeof res === 'string') {
@@ -209,7 +211,8 @@ export class AbstractSqlDriver extends DatabaseDriver {
209
211
  yield* this.wrapVirtualExpressionInSubqueryStream(meta, meta.expression, where, options, QueryType.SELECT);
210
212
  return;
211
213
  }
212
- const em = this.createEntityManager();
214
+ // fork the caller EM for the same reason as in `findFromVirtual`
215
+ const em = (options.em?.fork() ?? this.createEntityManager(false));
213
216
  em.setTransactionContext(options.ctx);
214
217
  const res = meta.expression(em, where, options, true);
215
218
  if (typeof res === 'string') {
@@ -239,7 +242,17 @@ export class AbstractSqlDriver extends DatabaseDriver {
239
242
  const asKeyword = this.platform.usesAsKeyword() ? ' as ' : ' ';
240
243
  native.from(raw(`(${expression})${asKeyword}${this.platform.quoteIdentifier(qb.alias)}`));
241
244
  const query = native.compile();
242
- const res = await this.execute(query.sql, query.params, 'all', options.ctx, withAbortContext(options.loggerContext, options));
245
+ const loggerContext = withAbortContext(options.loggerContext, options);
246
+ // virtual entities execute directly (not via QueryBuilder), so wrap in a short implicit transaction outside an
247
+ // existing one when a session context (row level security) needs to apply — mirrors the QueryBuilder wrap
248
+ const sessionContext = options.ctx ? undefined : options.em?.getTransactionSessionContext();
249
+ const conn = this.getConnection(this.resolveConnectionType(options));
250
+ const res = await (sessionContext
251
+ ? conn.transactional(trx => this.execute(query.sql, query.params, 'all', trx, loggerContext), {
252
+ sessionContext,
253
+ loggerContext,
254
+ })
255
+ : this.execute(query.sql, query.params, 'all', options.ctx, loggerContext));
243
256
  if (type === QueryType.COUNT) {
244
257
  return res[0].count;
245
258
  }
@@ -1,4 +1,4 @@
1
- import { type RawQueryFragment, type Constructor, type EntityManager, type EntityRepository, type IDatabaseDriver, type IsolationLevel, type MikroORM, Platform } from '@mikro-orm/core';
1
+ import { type RawQueryFragment, type Constructor, type EntityManager, type EntityProperty, type EntityRepository, type FormulaColumns, type IDatabaseDriver, type IsolationLevel, type MikroORM, Platform } from '@mikro-orm/core';
2
2
  import { SqlSchemaGenerator } from './schema/SqlSchemaGenerator.js';
3
3
  import { type SchemaHelper } from './schema/SchemaHelper.js';
4
4
  import type { IndexDef } from './typings.js';
@@ -17,6 +17,8 @@ export declare abstract class AbstractSqlPlatform extends Platform {
17
17
  getSchemaGenerator(driver: IDatabaseDriver, em?: EntityManager): SqlSchemaGenerator;
18
18
  /** @internal */
19
19
  createNativeQueryBuilder(): NativeQueryBuilder;
20
+ /** @inheritDoc */
21
+ getThroughRelationFormula(prop: EntityProperty, columns: FormulaColumns<any>): string;
20
22
  getBeginTransactionSQL(options?: {
21
23
  isolationLevel?: IsolationLevel;
22
24
  readOnly?: boolean;
@@ -1,4 +1,4 @@
1
- import { isRaw, JsonProperty, Platform, raw, Utils, } from '@mikro-orm/core';
1
+ import { isRaw, JsonProperty, Platform, raw, Utils, ValidationError, } from '@mikro-orm/core';
2
2
  import { SqlEntityRepository } from './SqlEntityRepository.js';
3
3
  import { SqlSchemaGenerator } from './schema/SqlSchemaGenerator.js';
4
4
  import { NativeQueryBuilder } from './query/NativeQueryBuilder.js';
@@ -39,6 +39,37 @@ export class AbstractSqlPlatform extends Platform {
39
39
  createNativeQueryBuilder() {
40
40
  return new NativeQueryBuilder(this);
41
41
  }
42
+ /** @inheritDoc */
43
+ getThroughRelationFormula(prop, columns) {
44
+ const through = prop.through;
45
+ const driver = this.config.getDriver();
46
+ const alias = `${prop.name}_through`;
47
+ const qb = driver.createQueryBuilder(through.entity, undefined, 'read', true, undefined, alias);
48
+ const throughMeta = driver.getMetadata().get(through.entity);
49
+ const ownerProp = throughMeta.properties[through.ownerProperty];
50
+ const ownerMeta = driver.getMetadata().get(ownerProp.target);
51
+ ownerProp.fieldNames.forEach((fieldName, idx) => {
52
+ const referencedColumn = ownerProp.referencedColumnNames[idx];
53
+ const referencedProp = ownerProp.referencedPKs
54
+ .map(name => ownerMeta.properties[name])
55
+ .find(p => p.fieldNames.includes(referencedColumn));
56
+ // the column mapping resolves the right alias even for TPT owners, only the column name may differ for composite FK properties
57
+ const ownerAlias = columns[referencedProp.name].slice(0, columns[referencedProp.name].lastIndexOf('.'));
58
+ qb.andWhere(raw('?? = ??', [`${alias}.${fieldName}`, `${ownerAlias}.${referencedColumn}`]));
59
+ });
60
+ if (through.where) {
61
+ qb.andWhere(through.where);
62
+ }
63
+ if (through.orderBy) {
64
+ qb.orderBy(through.orderBy);
65
+ }
66
+ qb.select(through.targetProperty ?? throughMeta.primaryKeys[0]).limit(1);
67
+ const sql = qb.getFormattedQuery();
68
+ if (Object.keys(qb.state.joins).length > 0) {
69
+ throw new ValidationError(`The 'where' and 'orderBy' options of the through relation ${prop.name} can only reference own columns of ${throughMeta.className}`);
70
+ }
71
+ return `(${sql})`;
72
+ }
42
73
  getBeginTransactionSQL(options) {
43
74
  if (options?.isolationLevel) {
44
75
  return [`set transaction isolation level ${options.isolationLevel}`, 'begin'];
package/README.md CHANGED
@@ -24,6 +24,7 @@ npm install @mikro-orm/mysql # MySQL
24
24
  npm install @mikro-orm/mariadb # MariaDB
25
25
  npm install @mikro-orm/sqlite # SQLite
26
26
  npm install @mikro-orm/libsql # libSQL / Turso
27
+ npm install @mikro-orm/sql-js # sql.js (in-memory SQLite in WASM)
27
28
  npm install @mikro-orm/mongodb # MongoDB
28
29
  npm install @mikro-orm/mssql # MS SQL Server
29
30
  npm install @mikro-orm/oracledb # Oracle
@@ -60,14 +60,14 @@ export declare class SqlEntityManager<Driver extends AbstractSqlDriver = Abstrac
60
60
  * `signal` / `inflightQueryAbortStrategy` (set via `em.fork({ signal })`) is applied automatically.
61
61
  * For per-call cancellation use the options-bag overload below.
62
62
  */
63
- execute<T extends QueryResult | EntityData<AnyEntity> | EntityData<AnyEntity>[] = EntityData<AnyEntity>[]>(query: string | NativeQueryBuilder | RawQueryFragment, params?: any[], method?: 'all' | 'get' | 'run', loggerContext?: LoggingOptions): Promise<T>;
63
+ execute<T extends QueryResult | EntityData<AnyEntity> | EntityData<AnyEntity>[] = EntityData<AnyEntity>[]>(query: string | NativeQueryBuilder | RawQueryFragment, params?: any[] | Dictionary, method?: 'all' | 'get' | 'run', loggerContext?: LoggingOptions): Promise<T>;
64
64
  /**
65
65
  * Executes a raw SQL query with an options bag carrying `method`, `loggerContext`, `signal`
66
66
  * and `inflightQueryAbortStrategy`. Per-call `signal` / `inflightQueryAbortStrategy` override
67
67
  * the fork-level defaults set via `em.fork({ signal })`. The current transaction context is
68
68
  * applied automatically.
69
69
  */
70
- execute<T extends QueryResult | EntityData<AnyEntity> | EntityData<AnyEntity>[] = EntityData<AnyEntity>[]>(query: string | NativeQueryBuilder | RawQueryFragment, params: any[], options: EmExecuteOptions): Promise<T>;
70
+ execute<T extends QueryResult | EntityData<AnyEntity> | EntityData<AnyEntity>[] = EntityData<AnyEntity>[]>(query: string | NativeQueryBuilder | RawQueryFragment, params: any[] | Dictionary, options: EmExecuteOptions): Promise<T>;
71
71
  /**
72
72
  * @inheritDoc
73
73
  */
@@ -68,7 +68,7 @@ export class SqlEntityManager extends EntityManager {
68
68
  merged.signal = opts.signal ?? fork?.signal;
69
69
  merged.inflightQueryAbortStrategy = opts.inflightQueryAbortStrategy ?? fork?.inflightQueryAbortStrategy;
70
70
  }
71
- return this.getDriver().execute(query, params, opts.method ?? 'all', context.getTransactionContext(), merged);
71
+ return context.withSessionContext(context.getTransactionContext(), ctx => this.getDriver().execute(query, params, opts.method ?? 'all', ctx, merged));
72
72
  }
73
73
  /**
74
74
  * @inheritDoc
@@ -14,6 +14,8 @@ export declare class BaseMySqlPlatform extends AbstractSqlPlatform {
14
14
  readonly "desc nulls first": 'is not null';
15
15
  readonly "desc nulls last": 'is null';
16
16
  };
17
+ /** mysql and mariadb treat null as the lowest value when no placement is requested. */
18
+ sortsNullsLowest(): boolean;
17
19
  supportsMultiColumnCountDistinct(): boolean;
18
20
  /** @internal */
19
21
  createNativeQueryBuilder(): MySqlNativeQueryBuilder;
@@ -18,6 +18,10 @@ export class BaseMySqlPlatform extends AbstractSqlPlatform {
18
18
  [QueryOrder.desc_nulls_first]: 'is not null',
19
19
  [QueryOrder.desc_nulls_last]: 'is null',
20
20
  };
21
+ /** mysql and mariadb treat null as the lowest value when no placement is requested. */
22
+ sortsNullsLowest() {
23
+ return true;
24
+ }
21
25
  supportsMultiColumnCountDistinct() {
22
26
  return true;
23
27
  }
@@ -16,6 +16,8 @@ export declare class BasePostgreSqlPlatform extends AbstractSqlPlatform {
16
16
  getEnumArrayCheckConstraintExpression(column: string, items: string[]): string;
17
17
  supportsMaterializedViews(): boolean;
18
18
  supportsPartitionedTables(): boolean;
19
+ supportsRowLevelSecurity(): boolean;
20
+ getCurrentSettingCast(mappedType: Type<unknown>): string | null;
19
21
  supportsCustomPrimaryKeyNames(): boolean;
20
22
  getCurrentTimestampSQL(length: number): string;
21
23
  getDateTimeTypeDeclarationSQL(column: {
@@ -1,4 +1,4 @@
1
- import { ALIAS_REPLACEMENT, ARRAY_OPERATORS, raw, RawQueryFragment, Type, Utils, } from '@mikro-orm/core';
1
+ import { ALIAS_REPLACEMENT, ARRAY_OPERATORS, BigIntType, BooleanType, DateTimeType, DateType, EnumType, IntegerType, raw, RawQueryFragment, SmallIntType, StringType, TextType, TimeType, TinyIntType, Type, Utils, UuidType, } from '@mikro-orm/core';
2
2
  import { AbstractSqlPlatform } from '../../AbstractSqlPlatform.js';
3
3
  import { PostgreSqlNativeQueryBuilder } from './PostgreSqlNativeQueryBuilder.js';
4
4
  import { PostgreSqlSchemaHelper } from './PostgreSqlSchemaHelper.js';
@@ -33,6 +33,38 @@ export class BasePostgreSqlPlatform extends AbstractSqlPlatform {
33
33
  supportsPartitionedTables() {
34
34
  return true;
35
35
  }
36
+ supportsRowLevelSecurity() {
37
+ return true;
38
+ }
39
+ getCurrentSettingCast(mappedType) {
40
+ if (mappedType instanceof UuidType) {
41
+ return '::uuid';
42
+ }
43
+ if (mappedType instanceof BigIntType) {
44
+ return '::bigint';
45
+ }
46
+ // MediumIntType extends IntegerType, so it is covered here too
47
+ if (mappedType instanceof IntegerType || mappedType instanceof SmallIntType || mappedType instanceof TinyIntType) {
48
+ return '::int';
49
+ }
50
+ if (mappedType instanceof BooleanType) {
51
+ return '::boolean';
52
+ }
53
+ if (mappedType instanceof DateTimeType) {
54
+ return '::timestamptz';
55
+ }
56
+ if (mappedType instanceof DateType) {
57
+ return '::date';
58
+ }
59
+ if (mappedType instanceof TimeType) {
60
+ return '::time';
61
+ }
62
+ // current_setting() returns text already, so string-compatible types need no cast (CharacterType extends StringType)
63
+ if (mappedType instanceof StringType || mappedType instanceof TextType || mappedType instanceof EnumType) {
64
+ return '';
65
+ }
66
+ return null;
67
+ }
36
68
  supportsCustomPrimaryKeyNames() {
37
69
  return true;
38
70
  }
@@ -1,4 +1,4 @@
1
- import { DeadlockException, ExceptionConverter, ForeignKeyConstraintViolationException, InvalidFieldNameException, NonUniqueFieldNameException, NotNullConstraintViolationException, SyntaxErrorException, TableExistsException, TableNotFoundException, UniqueConstraintViolationException, CheckConstraintViolationException, } from '@mikro-orm/core';
1
+ import { DeadlockException, ExceptionConverter, ForeignKeyConstraintViolationException, InvalidFieldNameException, NonUniqueFieldNameException, NotNullConstraintViolationException, RowLevelSecurityViolationException, SyntaxErrorException, TableExistsException, TableNotFoundException, UniqueConstraintViolationException, CheckConstraintViolationException, } from '@mikro-orm/core';
2
2
  export class PostgreSqlExceptionConverter extends ExceptionConverter {
3
3
  /**
4
4
  * @see http://www.postgresql.org/docs/9.4/static/errcodes-appendix.html
@@ -31,6 +31,13 @@ export class PostgreSqlExceptionConverter extends ExceptionConverter {
31
31
  return new UniqueConstraintViolationException(exception);
32
32
  case '23514':
33
33
  return new CheckConstraintViolationException(exception);
34
+ case '42501':
35
+ // 42501 is generic insufficient_privilege; only RLS write violations carry this message — the `routine`
36
+ // field covers servers with a non-english `lc_messages`, where the message check cannot match
37
+ if (exception.message.includes('row-level security policy') || exception.routine === 'ExecWithCheckOptions') {
38
+ return new RowLevelSecurityViolationException(exception);
39
+ }
40
+ break;
34
41
  case '42601':
35
42
  return new SyntaxErrorException(exception);
36
43
  case '42702':
@@ -1,7 +1,7 @@
1
1
  import { type Dictionary, type Transaction } from '@mikro-orm/core';
2
2
  import { SchemaHelper } from '../../schema/SchemaHelper.js';
3
3
  import type { AbstractSqlConnection } from '../../AbstractSqlConnection.js';
4
- import type { CheckDef, Column, ForeignKey, IndexDef, Table, TableDifference, TablePartitioning, SqlTriggerDef, SqlRoutineDef } from '../../typings.js';
4
+ import type { CheckDef, Column, ForeignKey, IndexDef, Table, TableDifference, TablePartitioning, SqlPolicyDef, SqlTriggerDef, SqlRoutineDef } from '../../typings.js';
5
5
  import type { DatabaseSchema } from '../../schema/DatabaseSchema.js';
6
6
  import type { DatabaseTable } from '../../schema/DatabaseTable.js';
7
7
  export declare class PostgreSqlSchemaHelper extends SchemaHelper {
@@ -71,6 +71,17 @@ export declare class PostgreSqlSchemaHelper extends SchemaHelper {
71
71
  dropTrigger(table: DatabaseTable, trigger: SqlTriggerDef): string;
72
72
  /** Flattens `;\n` inside the dollar-quoted blocks of a raw DDL expression, which are not statement boundaries. */
73
73
  private flattenDollarQuotedBodies;
74
+ getRlsCreateSQL(table: DatabaseTable): string[];
75
+ getRlsDropSQL(diff: TableDifference, safe?: boolean): string[];
76
+ getRlsAlterSQL(diff: TableDifference, safe?: boolean): string[];
77
+ /**
78
+ * Quotes a policy or role name as a single identifier. Unlike `quote()`/`platform.quoteIdentifier`, which treat
79
+ * a dot as a schema qualifier, these names are never schema-qualified, so `my.role` must render as `"my.role"`.
80
+ */
81
+ private quoteUnqualified;
82
+ private createPolicy;
83
+ private dropPolicy;
84
+ private formatPolicyRoles;
74
85
  createRoutine(routine: SqlRoutineDef): string;
75
86
  dropRoutine(routine: SqlRoutineDef): string;
76
87
  getAllRoutines(connection: AbstractSqlConnection, schemas?: string[]): Promise<SqlRoutineDef[]>;
@@ -87,6 +98,12 @@ export declare class PostgreSqlSchemaHelper extends SchemaHelper {
87
98
  getDatabaseCollation(connection: AbstractSqlConnection, ctx?: Transaction): Promise<string | undefined>;
88
99
  getAllTriggers(connection: AbstractSqlConnection, tablesBySchemas: Map<string | undefined, Table[]>): Promise<Dictionary<SqlTriggerDef[]>>;
89
100
  private getTriggersSQL;
101
+ getAllPolicies(connection: AbstractSqlConnection, tablesBySchemas: Map<string | undefined, Table[]>, ctx?: Transaction): Promise<Dictionary<{
102
+ policies: SqlPolicyDef[];
103
+ enabled: boolean;
104
+ forced: boolean;
105
+ }>>;
106
+ private parsePgRoles;
90
107
  getAllForeignKeys(connection: AbstractSqlConnection, tablesBySchemas: Map<string | undefined, Table[]>, ctx?: Transaction): Promise<Dictionary<Dictionary<ForeignKey>>>;
91
108
  getNativeEnumDefinitions(connection: AbstractSqlConnection, schemas: string[], ctx?: Transaction): Promise<Dictionary<{
92
109
  name: string;
@@ -162,6 +162,7 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
162
162
  const fks = await this.getAllForeignKeys(connection, tablesBySchema, ctx);
163
163
  const partitionings = await this.getPartitions(connection, tablesBySchema, ctx);
164
164
  const triggers = await this.getAllTriggers(connection, tablesBySchema);
165
+ const policies = await this.getAllPolicies(connection, tablesBySchema, ctx);
165
166
  const dbCollation = await this.getDatabaseCollation(connection, ctx);
166
167
  for (const t of tables) {
167
168
  const key = this.getTableKey(t);
@@ -175,6 +176,12 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
175
176
  if (triggers[key]) {
176
177
  table.setTriggers(triggers[key]);
177
178
  }
179
+ const rls = policies[key];
180
+ if (rls) {
181
+ table.setPolicies(rls.policies);
182
+ table.rlsEnabled = rls.enabled;
183
+ table.rlsForced = rls.forced;
184
+ }
178
185
  table.setPartitioning(partitionings[key]);
179
186
  }
180
187
  }
@@ -540,6 +547,83 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
540
547
  })
541
548
  .join('');
542
549
  }
550
+ getRlsCreateSQL(table) {
551
+ const ret = [];
552
+ if (table.rlsEnabled) {
553
+ ret.push(`alter table ${table.getQuotedName()} enable row level security`);
554
+ }
555
+ if (table.rlsForced) {
556
+ ret.push(`alter table ${table.getQuotedName()} force row level security`);
557
+ }
558
+ for (const policy of table.getPolicies()) {
559
+ ret.push(this.createPolicy(table, policy));
560
+ }
561
+ return ret;
562
+ }
563
+ getRlsDropSQL(diff, safe) {
564
+ // a policy expression holds a dependency on the columns it references, so removed policies (including the
565
+ // old version of changed ones) must be dropped before the column drops emitted later in the diff;
566
+ // in safe mode only recreated policies (present in both removed + added) are dropped, so a policy that is
567
+ // merely removed is left untouched like removed triggers/columns
568
+ return Object.values(diff.removedPolicies)
569
+ .filter(policy => !safe || policy.name in diff.addedPolicies)
570
+ .map(policy => this.dropPolicy(diff.toTable, policy));
571
+ }
572
+ getRlsAlterSQL(diff, safe) {
573
+ const ret = [];
574
+ const table = diff.toTable;
575
+ if (diff.changedRlsEnabled === true) {
576
+ ret.push(`alter table ${table.getQuotedName()} enable row level security`);
577
+ }
578
+ if (diff.changedRlsForced === true) {
579
+ ret.push(`alter table ${table.getQuotedName()} force row level security`);
580
+ }
581
+ else if (!safe && diff.changedRlsForced === false) {
582
+ ret.push(`alter table ${table.getQuotedName()} no force row level security`);
583
+ }
584
+ for (const policy of Object.values(diff.addedPolicies)) {
585
+ ret.push(this.createPolicy(table, policy));
586
+ }
587
+ // disable only after its policies are gone (removed ones were dropped via `getRlsDropSQL`); skipped in
588
+ // safe mode so a safe run never lifts row level security off an existing table
589
+ if (!safe && diff.changedRlsEnabled === false) {
590
+ ret.push(`alter table ${table.getQuotedName()} disable row level security`);
591
+ }
592
+ return ret;
593
+ }
594
+ /**
595
+ * Quotes a policy or role name as a single identifier. Unlike `quote()`/`platform.quoteIdentifier`, which treat
596
+ * a dot as a schema qualifier, these names are never schema-qualified, so `my.role` must render as `"my.role"`.
597
+ */
598
+ quoteUnqualified(name) {
599
+ return `"${name.replaceAll('"', '""')}"`;
600
+ }
601
+ createPolicy(table, policy) {
602
+ const parts = [`create policy ${this.quoteUnqualified(policy.name)} on ${table.getQuotedName()}`];
603
+ if (policy.type === 'restrictive') {
604
+ parts.push('as restrictive');
605
+ }
606
+ if (policy.command !== 'all') {
607
+ parts.push(`for ${policy.command}`);
608
+ }
609
+ if (policy.roles.length > 0) {
610
+ parts.push(`to ${this.formatPolicyRoles(policy.roles)}`);
611
+ }
612
+ if (policy.using) {
613
+ parts.push(`using (${policy.using})`);
614
+ }
615
+ if (policy.check) {
616
+ parts.push(`with check (${policy.check})`);
617
+ }
618
+ return parts.join(' ');
619
+ }
620
+ dropPolicy(table, policy) {
621
+ return `drop policy ${this.quoteUnqualified(policy.name)} on ${table.getQuotedName()}`;
622
+ }
623
+ // `public` is a keyword and must stay unquoted; other roles are quoted like any identifier
624
+ formatPolicyRoles(roles) {
625
+ return roles.map(role => (role === 'public' ? 'public' : this.quoteUnqualified(role))).join(', ');
626
+ }
543
627
  createRoutine(routine) {
544
628
  if (routine.expression) {
545
629
  return this.flattenDollarQuotedBodies(routine.expression);
@@ -732,6 +816,56 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
732
816
  where (${conditions.join(' or ')})
733
817
  order by t.trigger_name, t.event_manipulation`;
734
818
  }
819
+ async getAllPolicies(connection, tablesBySchemas, ctx) {
820
+ const conditionsFor = (schemaColumn, tableColumn) => [...tablesBySchemas.entries()].map(([schema, tables]) => {
821
+ const names = tables.map(t => this.platform.quoteValue(t.table_name)).join(', ');
822
+ const schemaName = this.platform.quoteValue(schema ?? this.platform.getDefaultSchemaName());
823
+ return `(${schemaColumn} = ${schemaName} and ${tableColumn} in (${names}))`;
824
+ });
825
+ const flagConditions = conditionsFor('ns.nspname', 'cls.relname');
826
+ const policyConditions = conditionsFor('schemaname', 'tablename');
827
+ // pg_class carries the enable/force flags (a table can enable RLS with zero policies)
828
+ const flagRows = await connection.execute(`select cls.relname as table_name, ns.nspname as schema_name, cls.relrowsecurity as enabled, cls.relforcerowsecurity as forced
829
+ from pg_class cls
830
+ join pg_namespace ns on ns.oid = cls.relnamespace
831
+ where (${flagConditions.join(' or ')})`, [], 'all', ctx);
832
+ const policyRows = await connection.execute(`select tablename as table_name, schemaname as schema_name, policyname as name, permissive, roles, cmd, qual, with_check
833
+ from pg_policies
834
+ where (${policyConditions.join(' or ')})
835
+ order by policyname`, [], 'all', ctx);
836
+ const policiesByTable = {};
837
+ for (const row of policyRows) {
838
+ const key = this.getTableKey(row);
839
+ (policiesByTable[key] ??= []).push({
840
+ name: row.name,
841
+ command: row.cmd.toLowerCase(),
842
+ type: row.permissive === 'PERMISSIVE' ? 'permissive' : 'restrictive',
843
+ roles: this.parsePgRoles(row.roles),
844
+ using: row.qual ?? undefined,
845
+ check: row.with_check ?? undefined,
846
+ });
847
+ }
848
+ const ret = {};
849
+ for (const row of flagRows) {
850
+ const key = this.getTableKey(row);
851
+ ret[key] = { policies: policiesByTable[key] ?? [], enabled: row.enabled, forced: row.forced };
852
+ }
853
+ return ret;
854
+ }
855
+ // node-postgres returns `pg_policies.roles` as an unparsed array literal (`{public}`), pglite as an array
856
+ parsePgRoles(value) {
857
+ if (Array.isArray(value)) {
858
+ return value;
859
+ }
860
+ // tokenize the array literal instead of splitting on commas — quoted role names can contain
861
+ // commas, and quoted elements escape `"` and `\` with a backslash
862
+ const roles = [];
863
+ const re = /"((?:[^"\\]|\\.)*)"|[^,]+/g;
864
+ for (const match of value.replace(/^\{|\}$/g, '').matchAll(re)) {
865
+ roles.push(match[1] != null ? match[1].replace(/\\(.)/g, '$1') : match[0]);
866
+ }
867
+ return roles;
868
+ }
735
869
  async getAllForeignKeys(connection, tablesBySchemas, ctx) {
736
870
  const sql = `select nsp1.nspname schema_name, cls1.relname table_name, nsp2.nspname referenced_schema_name,
737
871
  cls2.relname referenced_table_name, a.attname column_name, af.attname referenced_column_name, conname constraint_name,
@@ -966,6 +1100,24 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
966
1100
  for (const { table: localTable, foreignKey } of inboundForeignKeys) {
967
1101
  this.append(ret, this.createForeignKey(localTable, foreignKey));
968
1102
  }
1103
+ // re-enable row level security and recreate the policies (createTable skips them during a rebuild);
1104
+ // emitted after the data copy so `force row level security` can't block the owner's insert
1105
+ this.append(ret, this.getRlsCreateSQL(table));
1106
+ // with `ignorePolicies`, hand-written policies (and the RLS flags they imply) may exist only on the
1107
+ // introspected side — recreate them verbatim, or the rebuild would silently strip them
1108
+ if (this.options.ignorePolicies) {
1109
+ if (diff.fromTable.rlsEnabled && !table.rlsEnabled) {
1110
+ ret.push(`alter table ${table.getQuotedName()} enable row level security`);
1111
+ }
1112
+ if (diff.fromTable.rlsForced && !table.rlsForced) {
1113
+ ret.push(`alter table ${table.getQuotedName()} force row level security`);
1114
+ }
1115
+ for (const policy of diff.fromTable.getPolicies()) {
1116
+ if (!table.hasPolicy(policy.name)) {
1117
+ ret.push(this.createPolicy(table, policy));
1118
+ }
1119
+ }
1120
+ }
969
1121
  }
970
1122
  if (safe) {
971
1123
  ret.push(`-- safe mode: original tables kept in schema "${tmpSchema}"; drop that schema manually once the data is verified`);
@@ -6,6 +6,8 @@ import { SqliteExceptionConverter } from './SqliteExceptionConverter.js';
6
6
  export declare class SqlitePlatform extends AbstractSqlPlatform {
7
7
  protected readonly schemaHelper: SqliteSchemaHelper;
8
8
  protected readonly exceptionConverter: SqliteExceptionConverter;
9
+ /** sqlite treats null as the lowest value when no placement is requested. */
10
+ sortsNullsLowest(): boolean;
9
11
  /** @internal */
10
12
  createNativeQueryBuilder(): SqliteNativeQueryBuilder;
11
13
  usesDefaultKeyword(): boolean;
@@ -5,6 +5,10 @@ import { SqliteExceptionConverter } from './SqliteExceptionConverter.js';
5
5
  export class SqlitePlatform extends AbstractSqlPlatform {
6
6
  schemaHelper = new SqliteSchemaHelper(this);
7
7
  exceptionConverter = new SqliteExceptionConverter();
8
+ /** sqlite treats null as the lowest value when no placement is requested. */
9
+ sortsNullsLowest() {
10
+ return true;
11
+ }
8
12
  /** @internal */
9
13
  createNativeQueryBuilder() {
10
14
  return new SqliteNativeQueryBuilder(this);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/sql",
3
- "version": "7.1.16-dev.10",
3
+ "version": "7.1.16-dev.12",
4
4
  "description": "TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, PostgreSQL and SQLite databases as well as usage with vanilla JavaScript.",
5
5
  "keywords": [
6
6
  "data-mapper",
@@ -53,7 +53,7 @@
53
53
  "@mikro-orm/core": "^7.1.15"
54
54
  },
55
55
  "peerDependencies": {
56
- "@mikro-orm/core": "7.1.16-dev.10"
56
+ "@mikro-orm/core": "7.1.16-dev.12"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">= 22.17.0"
@@ -1065,17 +1065,26 @@ export class QueryBuilder {
1065
1065
  this.limit(1);
1066
1066
  }
1067
1067
  const query = this.toQuery();
1068
- const cached = await this.em?.tryCache(this.mainAlias.entityName, this.#state.cache, [
1069
- 'qb.execute',
1070
- query.sql,
1071
- query.params,
1072
- method,
1073
- ]);
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);
1074
1075
  if (cached?.data !== undefined) {
1075
1076
  return cached.data;
1076
1077
  }
1077
1078
  const loggerContext = { id: this.em?.id, ...this.loggerContext, ...this.#abortOptions };
1078
- 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));
1079
1088
  const meta = this.mainAlias.meta;
1080
1089
  if (!options.mapResults || !meta) {
1081
1090
  await this.em?.storeCache(this.#state.cache, cached, res);
@@ -1125,6 +1134,11 @@ export class QueryBuilder {
1125
1134
  * ```
1126
1135
  */
1127
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
+ }
1128
1142
  options ??= {};
1129
1143
  options.mergeResults ??= true;
1130
1144
  options.mapResults ??= true;
@@ -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;
@@ -328,6 +328,7 @@ export class SchemaComparator {
328
328
  addedIndexes: {},
329
329
  addedChecks: {},
330
330
  addedTriggers: {},
331
+ addedPolicies: {},
331
332
  changedColumns: {},
332
333
  changedForeignKeys: {},
333
334
  changedIndexes: {},
@@ -338,6 +339,7 @@ export class SchemaComparator {
338
339
  removedIndexes: {},
339
340
  removedChecks: {},
340
341
  removedTriggers: {},
342
+ removedPolicies: {},
341
343
  renamedColumns: {},
342
344
  renamedIndexes: {},
343
345
  fromTable,
@@ -517,6 +519,9 @@ export class SchemaComparator {
517
519
  }
518
520
  }
519
521
  }
522
+ if (this.#platform.supportsRowLevelSecurity()) {
523
+ changes += this.diffPolicies(fromTable, toTable, tableDifferences);
524
+ }
520
525
  const fromForeignKeys = { ...fromTable.getForeignKeys() };
521
526
  const toForeignKeys = { ...toTable.getForeignKeys() };
522
527
  for (const fromConstraint of Object.values(fromForeignKeys)) {
@@ -908,6 +913,10 @@ export class SchemaComparator {
908
913
  // multi word type names in casts, the generic `::\w+` below only covers single word ones
909
914
  // the precision is kept, so `timestamptz(3)` and `timestamp(3) with time zone` leave the same residue
910
915
  .replace(/::\s*(?:character\s+varying|bit\s+varying|double\s+precision|(?:timestamp|time)\b(\s*\(\d+\))?(?:\s+with(?:out)?\s+time\s+zone)?)/gi, '$1')
916
+ // Protect dots inside string literals before the quote strip below, or the alias-prefix normalization
917
+ // would mangle literal contents — `current_setting('app.tenant')` and `current_setting('req.tenant')`
918
+ // must not both collapse to `current_settingtenant`
919
+ .replace(/'([^']*)'/g, (_, inner) => `'${inner.replaceAll('.', '\u0000')}'`)
911
920
  // Remove quotes first so we can process identifiers
912
921
  .replace(/['"`]/g, '')
913
922
  // MySQL adds table/alias prefixes to columns (e.g., a.name or table_name.column vs just column)
@@ -985,6 +994,87 @@ export class SchemaComparator {
985
994
  }
986
995
  return this.diffExpression(from.body, to.body);
987
996
  }
997
+ diffPolicies(fromTable, toTable, diff) {
998
+ let changes = 0;
999
+ // `ignorePolicies` makes RLS create-only: declared policies are still added and RLS is still enabled/forced,
1000
+ // but existing policies are never diffed for drop/alter and RLS is never disabled or unforced — this protects
1001
+ // hand-written policies on databases that adopted RLS before the ORM managed it
1002
+ const ignorePolicies = this.#platform.getConfig().get('schemaGenerator').ignorePolicies;
1003
+ // postgres rejects `alter column ... type` on a column referenced by any policy, so a type change forces us to
1004
+ // drop every still-present policy around the alter (dropped before via `getRlsDropSQL`, recreated after via
1005
+ // `getRlsAlterSQL`) even when the policy itself is otherwise unchanged; a `generated`-only change is emitted
1006
+ // as a drop + re-add of the same column, which a policy's column dependency blocks the same way
1007
+ const hasColumnTypeChange = Object.values(diff.changedColumns).some(c => c.changedProperties.has('type')) ||
1008
+ Object.keys(diff.removedColumns).some(name => name in diff.addedColumns);
1009
+ for (const policy of toTable.getPolicies()) {
1010
+ if (!fromTable.hasPolicy(policy.name)) {
1011
+ diff.addedPolicies[policy.name] = policy;
1012
+ this.log(`policy ${policy.name} added to table ${diff.name}`, { policy });
1013
+ changes++;
1014
+ }
1015
+ }
1016
+ if (fromTable.rlsEnabled !== toTable.rlsEnabled && (!ignorePolicies || toTable.rlsEnabled)) {
1017
+ diff.changedRlsEnabled = toTable.rlsEnabled;
1018
+ changes++;
1019
+ }
1020
+ if (fromTable.rlsForced !== toTable.rlsForced && (!ignorePolicies || toTable.rlsForced)) {
1021
+ diff.changedRlsForced = toTable.rlsForced;
1022
+ changes++;
1023
+ }
1024
+ if (ignorePolicies) {
1025
+ // existing policies are unmanaged here, but a type change still needs them dropped and recreated for the
1026
+ // alter to succeed — recreate each verbatim from introspection so the hand-written definition is preserved
1027
+ if (hasColumnTypeChange) {
1028
+ for (const policy of fromTable.getPolicies()) {
1029
+ diff.removedPolicies[policy.name] = policy;
1030
+ diff.addedPolicies[policy.name] = policy;
1031
+ changes += 2;
1032
+ }
1033
+ }
1034
+ return changes;
1035
+ }
1036
+ for (const policy of fromTable.getPolicies()) {
1037
+ const toPolicy = toTable.getPolicy(policy.name);
1038
+ if (!toPolicy) {
1039
+ diff.removedPolicies[policy.name] = policy;
1040
+ this.log(`policy ${policy.name} removed from table ${diff.name}`);
1041
+ changes++;
1042
+ continue;
1043
+ }
1044
+ // changed policies are always dropped (before column drops, which the old expression can block via its
1045
+ // column dependencies) and recreated (after column adds) — postgres could alter some of the changes in
1046
+ // place, but not a policy's command or type, nor unset an expression
1047
+ if (this.diffPolicy(policy, toPolicy)) {
1048
+ diff.removedPolicies[policy.name] = policy;
1049
+ diff.addedPolicies[policy.name] = toPolicy;
1050
+ this.log(`policy ${policy.name} recreated in table ${diff.name}`, { from: policy, to: toPolicy });
1051
+ changes += 2;
1052
+ continue;
1053
+ }
1054
+ // an unchanged policy still blocks a type change on any column, so drop + recreate it around the alter
1055
+ if (hasColumnTypeChange) {
1056
+ diff.removedPolicies[policy.name] = policy;
1057
+ diff.addedPolicies[policy.name] = toPolicy;
1058
+ this.log(`policy ${policy.name} recreated around a column type change in table ${diff.name}`);
1059
+ changes += 2;
1060
+ }
1061
+ }
1062
+ return changes;
1063
+ }
1064
+ diffPolicy(from, to) {
1065
+ // normalize so an omitted `roles` matches introspected `{public}`
1066
+ const normalizeRoles = (roles) => DatabaseTable.isDefaultPolicyRoles(roles) ? '' : [...roles].sort().join(',');
1067
+ if (from.command !== to.command || from.type !== to.type) {
1068
+ return true;
1069
+ }
1070
+ if (normalizeRoles(from.roles) !== normalizeRoles(to.roles)) {
1071
+ return true;
1072
+ }
1073
+ if (this.diffExpression(from.using ?? '', to.using ?? '')) {
1074
+ return true;
1075
+ }
1076
+ return this.diffExpression(from.check ?? '', to.check ?? '');
1077
+ }
988
1078
  parseJsonDefault(defaultValue) {
989
1079
  /* v8 ignore next */
990
1080
  if (!defaultValue) {
@@ -154,6 +154,12 @@ export declare abstract class SchemaHelper {
154
154
  getChangeColumnCommentSQL(tableName: string, to: Column, schemaName?: string): string;
155
155
  /** Whether the column comment is part of the column declaration, as opposed to a separate statement. */
156
156
  protected hasInlineColumnComment(): boolean;
157
+ /** Row level security DDL for a freshly created table (enable/force + create policies). Postgres only. */
158
+ getRlsCreateSQL(table: DatabaseTable): string[];
159
+ /** Drops removed/changed row level security policies; emitted in the pre-alter phase, before any column drop or type alter a policy expression can block. Postgres only. */
160
+ getRlsDropSQL(diff: TableDifference, safe?: boolean): string[];
161
+ /** Row level security DDL for a table difference (enable/disable/force transitions + policy creation). Postgres only. */
162
+ getRlsAlterSQL(diff: TableDifference, safe?: boolean): string[];
157
163
  getNamespaces(connection: AbstractSqlConnection, ctx?: Transaction): Promise<string[]>;
158
164
  protected mapIndexes(indexes: IndexDef[]): Promise<IndexDef[]>;
159
165
  mapForeignKeys(fks: any[], tableName: string, schemaName?: string): Dictionary;
@@ -504,6 +504,7 @@ export class SchemaHelper {
504
504
  if ('changedComment' in diff) {
505
505
  ret.push(this.alterTableComment(diff.toTable, diff.changedComment));
506
506
  }
507
+ this.append(ret, this.getRlsAlterSQL(diff, safe));
507
508
  return ret;
508
509
  }
509
510
  /** Returns SQL to add columns to an existing table. */
@@ -646,6 +647,18 @@ export class SchemaHelper {
646
647
  hasInlineColumnComment() {
647
648
  return false;
648
649
  }
650
+ /** Row level security DDL for a freshly created table (enable/force + create policies). Postgres only. */
651
+ getRlsCreateSQL(table) {
652
+ return [];
653
+ }
654
+ /** Drops removed/changed row level security policies; emitted in the pre-alter phase, before any column drop or type alter a policy expression can block. Postgres only. */
655
+ getRlsDropSQL(diff, safe) {
656
+ return [];
657
+ }
658
+ /** Row level security DDL for a table difference (enable/disable/force transitions + policy creation). Postgres only. */
659
+ getRlsAlterSQL(diff, safe) {
660
+ return [];
661
+ }
649
662
  async getNamespaces(connection, ctx) {
650
663
  return [];
651
664
  }
@@ -801,6 +814,8 @@ export class SchemaHelper {
801
814
  for (const trigger of table.getTriggers()) {
802
815
  this.append(ret, this.createTrigger(table, trigger));
803
816
  }
817
+ // RLS policies can reference other tables, so they are deferred until every table exists (see the
818
+ // callers of getRlsCreateSQL in SqlSchemaGenerator) rather than emitted inline here
804
819
  }
805
820
  return ret;
806
821
  }
@@ -94,6 +94,10 @@ export class SqlSchemaGenerator extends AbstractSchemaGenerator {
94
94
  this.append(ret, fks, true);
95
95
  }
96
96
  }
97
+ // RLS policies are deferred until every table exists, so a policy expression can reference another table
98
+ for (const table of toSchema.getTables()) {
99
+ this.append(ret, this.helper.getRlsCreateSQL(table));
100
+ }
97
101
  const sortedViews = this.sortViewsByDependencies(toSchema.getViews());
98
102
  for (const view of sortedViews) {
99
103
  this.appendViewCreation(ret, view);
@@ -351,6 +355,7 @@ export class SqlSchemaGenerator extends AbstractSchemaGenerator {
351
355
  for (const trigger of newTable.getTriggers()) {
352
356
  this.append(sql, this.helper.createTrigger(newTable, trigger));
353
357
  }
358
+ this.append(sql, this.helper.getRlsCreateSQL(newTable));
354
359
  this.append(ret, sql, true);
355
360
  }
356
361
  }
@@ -410,6 +415,9 @@ export class SqlSchemaGenerator extends AbstractSchemaGenerator {
410
415
  }
411
416
  preAlterTable(diff, safe) {
412
417
  const ret = [];
418
+ // removed/changed policies must be dropped before any column type alter, including the pre-alter
419
+ // uuid-to-text cast on postgres — a policy expression blocks type changes on the columns it references
420
+ this.append(ret, this.helper.getRlsDropSQL(diff, safe));
413
421
  this.append(ret, this.helper.getPreAlterTable(diff, safe));
414
422
  for (const foreignKey of Object.values(diff.removedForeignKeys)) {
415
423
  ret.push(this.helper.dropForeignKey(diff.toTable.getShortestName(), foreignKey.constraintName));
package/typings.d.ts CHANGED
@@ -114,6 +114,15 @@ export interface CheckDef<T = unknown> {
114
114
  definition?: string;
115
115
  columnName?: string;
116
116
  }
117
+ /** Resolved row level security policy for schema operations (all callbacks resolved to strings). */
118
+ export interface SqlPolicyDef {
119
+ name: string;
120
+ command: 'select' | 'insert' | 'update' | 'delete' | 'all';
121
+ type: 'permissive' | 'restrictive';
122
+ roles: string[];
123
+ using?: string;
124
+ check?: string;
125
+ }
117
126
  /** Resolved trigger definition for schema operations (all callbacks resolved to strings). */
118
127
  export interface SqlTriggerDef {
119
128
  name: string;
@@ -191,6 +200,13 @@ export interface TableDifference {
191
200
  addedTriggers: Dictionary<SqlTriggerDef>;
192
201
  changedTriggers: Dictionary<SqlTriggerDef>;
193
202
  removedTriggers: Dictionary<SqlTriggerDef>;
203
+ addedPolicies: Dictionary<SqlPolicyDef>;
204
+ /** Changed policies surface as `removedPolicies` + `addedPolicies` pairs (drop + recreate). */
205
+ removedPolicies: Dictionary<SqlPolicyDef>;
206
+ /** New RLS enable state, present only when it changed. */
207
+ changedRlsEnabled?: boolean;
208
+ /** New RLS force state, present only when it changed. */
209
+ changedRlsForced?: boolean;
194
210
  addedForeignKeys: Dictionary<ForeignKey>;
195
211
  changedForeignKeys: Dictionary<ForeignKey>;
196
212
  removedForeignKeys: Dictionary<ForeignKey>;