@mikro-orm/sql 7.1.16-dev.1 → 7.1.16-dev.11

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.
@@ -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.1",
3
+ "version": "7.1.16-dev.11",
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.1"
56
+ "@mikro-orm/core": "7.1.16-dev.11"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">= 22.17.0"
@@ -13,6 +13,7 @@ export declare class ObjectCriteriaNode<T extends object> extends CriteriaNode<T
13
13
  private inlineArrayChildPayload;
14
14
  private inlineChildPayload;
15
15
  private inlineCondition;
16
+ private isCollectionOperator;
16
17
  private shouldAutoJoin;
17
18
  private autoJoin;
18
19
  private isPrefixed;
@@ -1,7 +1,7 @@
1
1
  import { ALIAS_REPLACEMENT, GroupOperator, QueryFlag, raw, RawQueryFragment, ReferenceKind, Utils, } from '@mikro-orm/core';
2
2
  import { CriteriaNode } from './CriteriaNode.js';
3
3
  import { JoinType, QueryType } from './enums.js';
4
- const COLLECTION_OPERATORS = ['$some', '$none', '$every', '$size'];
4
+ const COLLECTION_OPERATORS = ['$some', '$none', '$every', '$size', '$all'];
5
5
  /**
6
6
  * @internal
7
7
  */
@@ -17,7 +17,7 @@ export class ObjectCriteriaNode extends CriteriaNode {
17
17
  alias = nestedAlias;
18
18
  }
19
19
  if (this.shouldAutoJoin(qb, nestedAlias)) {
20
- if (keys.some(k => COLLECTION_OPERATORS.includes(k))) {
20
+ if (keys.some(k => this.isCollectionOperator(k))) {
21
21
  if (![ReferenceKind.MANY_TO_MANY, ReferenceKind.ONE_TO_MANY].includes(this.prop.kind)) {
22
22
  // ignore collection operators when used on a non-relational property - this can happen when they get into
23
23
  // populateWhere via `infer` on m:n properties with select-in strategy
@@ -34,11 +34,24 @@ export class ObjectCriteriaNode extends CriteriaNode {
34
34
  const primaryKeys = parentMeta.primaryKeys.map(pk => {
35
35
  return [QueryType.SELECT, QueryType.COUNT].includes(qb.type) ? `${knownKey ? alias : ownerAlias}.${pk}` : pk;
36
36
  });
37
+ const conditions = [];
38
+ let matchNothing = false;
37
39
  for (const key of keys) {
38
40
  if (typeof key !== 'string' || !COLLECTION_OPERATORS.includes(key)) {
39
41
  throw new Error('Mixing collection operators with other filters is not allowed.');
40
42
  }
41
43
  const payload = this.payload[key].unwrap();
44
+ // `$all` requires every listed item to be present, which is an intersection of `$some` conditions
45
+ if (key === '$all') {
46
+ // an empty `$all` matches nothing, same as in mongo
47
+ matchNothing ||= payload.length === 0;
48
+ conditions.push(...payload.map(item => ['$some', item]));
49
+ }
50
+ else {
51
+ conditions.push([key, payload]);
52
+ }
53
+ }
54
+ for (const [key, payload] of conditions) {
42
55
  // entities with a fixed schema must resolve the `from` table's own schema in the subquery,
43
56
  // otherwise a nested operator inherits the root entity's schema (GH #7894); for wildcard or
44
57
  // schema-less entities the schema is resolved dynamically and needs to be carried over
@@ -70,6 +83,9 @@ export class ObjectCriteriaNode extends CriteriaNode {
70
83
  [Utils.getPrimaryKeyHash(primaryKeys)]: { [op]: sub.getNativeQuery().toRaw() },
71
84
  });
72
85
  }
86
+ if (matchNothing) {
87
+ $and.push({ [Utils.getPrimaryKeyHash(primaryKeys)]: { $in: [] } });
88
+ }
73
89
  if ($and.length === 1) {
74
90
  return $and[0];
75
91
  }
@@ -154,7 +170,7 @@ export class ObjectCriteriaNode extends CriteriaNode {
154
170
  alias = nestedAlias;
155
171
  }
156
172
  if (this.shouldAutoJoin(qb, nestedAlias)) {
157
- return !keys.some(k => COLLECTION_OPERATORS.includes(k));
173
+ return !keys.some(k => this.isCollectionOperator(k));
158
174
  }
159
175
  return keys.some(field => {
160
176
  const childNode = this.payload[field];
@@ -238,6 +254,14 @@ export class ObjectCriteriaNode extends CriteriaNode {
238
254
  delete o[key];
239
255
  o.$and = $and;
240
256
  }
257
+ isCollectionOperator(key) {
258
+ if (typeof key !== 'string' || !COLLECTION_OPERATORS.includes(key)) {
259
+ return false;
260
+ }
261
+ // `$all` is primarily a mongo array operator, in SQL it is supported only on collections
262
+ return (key !== '$all' ||
263
+ (!!this.prop && [ReferenceKind.MANY_TO_MANY, ReferenceKind.ONE_TO_MANY].includes(this.prop.kind)));
264
+ }
241
265
  shouldAutoJoin(qb, nestedAlias) {
242
266
  if (!this.prop || !this.parent) {
243
267
  return false;
@@ -246,7 +270,7 @@ export class ObjectCriteriaNode extends CriteriaNode {
246
270
  if (keys.every(k => typeof k === 'string' && k.includes('.') && k.startsWith(`${qb.alias}.`))) {
247
271
  return false;
248
272
  }
249
- if (keys.some(k => COLLECTION_OPERATORS.includes(k))) {
273
+ if (keys.some(k => this.isCollectionOperator(k))) {
250
274
  return true;
251
275
  }
252
276
  const meta = this.metadata.find(this.entityName);
@@ -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;
@@ -643,6 +643,10 @@ export class QueryBuilderHelper {
643
643
  return '?';
644
644
  }
645
645
  getOperatorReplacement(op, value) {
646
+ // collection properties expand `$all` into `$some` sub-queries in `ObjectCriteriaNode`, nothing else has a SQL equivalent
647
+ if (op === '$all') {
648
+ throw new Error('The `$all` operator is supported only on collection properties in SQL drivers, use `$contains` for array columns instead.');
649
+ }
646
650
  let replacement = QueryOperator[op];
647
651
  if (op === '$exists') {
648
652
  replacement = value[op] ? 'is not' : 'is';
@@ -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
  }