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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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)
@@ -963,13 +972,13 @@ export class SchemaComparator {
963
972
  }
964
973
  diffTrigger(from, to) {
965
974
  // Raw DDL expression cannot be meaningfully compared to introspected
966
- // trigger metadata, so skip diffing when the metadata side uses it.
967
- if (to.expression) {
975
+ // trigger metadata, so it needs special handling when either side uses it.
976
+ if (from.expression || to.expression) {
968
977
  // Both sides have expression — compare the raw DDL directly
969
- if (from.expression) {
978
+ if (from.expression && to.expression) {
970
979
  return this.diffExpression(from.expression, to.expression);
971
980
  }
972
- // Only metadata side has expression — the raw DDL cannot be compared to
981
+ // Only one side has expression — the raw DDL cannot be compared to
973
982
  // introspected metadata. Changes to the expression value won't be detected;
974
983
  // drop and recreate the trigger manually to apply expression changes.
975
984
  return false;
@@ -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>;
@@ -277,6 +293,8 @@ export interface IQueryBuilder<T> {
277
293
  with(name: string, query: AnyQueryBuilder | NativeQueryBuilder | RawQueryFragment, options?: CteOptions): this;
278
294
  withRecursive(name: string, query: AnyQueryBuilder | NativeQueryBuilder | RawQueryFragment, options?: CteOptions): this;
279
295
  scheduleFilterCheck(path: string): void;
296
+ /** @internal */
297
+ ensureTPTJoins(): void;
280
298
  withSchema(schema: string): this;
281
299
  }
282
300
  export interface ICriteriaNodeProcessOptions {