@mikro-orm/sql 7.1.16-dev.10 → 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.
- package/AbstractSqlConnection.d.ts +21 -3
- package/AbstractSqlConnection.js +54 -4
- package/AbstractSqlDriver.d.ts +1 -1
- package/AbstractSqlDriver.js +16 -3
- package/AbstractSqlPlatform.d.ts +3 -1
- package/AbstractSqlPlatform.js +32 -1
- package/README.md +1 -0
- package/SqlEntityManager.d.ts +2 -2
- package/SqlEntityManager.js +1 -1
- package/dialects/mysql/BaseMySqlPlatform.d.ts +2 -0
- package/dialects/mysql/BaseMySqlPlatform.js +4 -0
- package/dialects/postgresql/BasePostgreSqlPlatform.d.ts +2 -0
- package/dialects/postgresql/BasePostgreSqlPlatform.js +33 -1
- package/dialects/postgresql/PostgreSqlExceptionConverter.js +8 -1
- package/dialects/postgresql/PostgreSqlSchemaHelper.d.ts +18 -1
- package/dialects/postgresql/PostgreSqlSchemaHelper.js +152 -0
- package/dialects/sqlite/SqlitePlatform.d.ts +2 -0
- package/dialects/sqlite/SqlitePlatform.js +4 -0
- package/package.json +2 -2
- package/query/QueryBuilder.js +21 -7
- package/schema/DatabaseSchema.d.ts +4 -0
- package/schema/DatabaseSchema.js +107 -1
- package/schema/DatabaseTable.d.ts +13 -1
- package/schema/DatabaseTable.js +50 -1
- package/schema/SchemaComparator.d.ts +2 -0
- package/schema/SchemaComparator.js +90 -0
- package/schema/SchemaHelper.d.ts +6 -0
- package/schema/SchemaHelper.js +15 -0
- package/schema/SqlSchemaGenerator.js +8 -0
- package/typings.d.ts +16 -0
|
@@ -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[]
|
|
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[]
|
|
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;
|
package/AbstractSqlConnection.js
CHANGED
|
@@ -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
|
}
|
package/AbstractSqlDriver.d.ts
CHANGED
|
@@ -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
|
package/AbstractSqlDriver.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/AbstractSqlPlatform.d.ts
CHANGED
|
@@ -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;
|
package/AbstractSqlPlatform.js
CHANGED
|
@@ -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
|
package/SqlEntityManager.d.ts
CHANGED
|
@@ -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
|
*/
|
package/SqlEntityManager.js
CHANGED
|
@@ -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',
|
|
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.
|
|
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.
|
|
56
|
+
"@mikro-orm/core": "7.1.16-dev.11"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
|
59
59
|
"node": ">= 22.17.0"
|
package/query/QueryBuilder.js
CHANGED
|
@@ -1065,17 +1065,26 @@ export class QueryBuilder {
|
|
|
1065
1065
|
this.limit(1);
|
|
1066
1066
|
}
|
|
1067
1067
|
const query = this.toQuery();
|
|
1068
|
-
const
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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
|
|
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
|
/**
|
package/schema/DatabaseSchema.js
CHANGED
|
@@ -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
|
}
|
package/schema/DatabaseTable.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/schema/SchemaHelper.d.ts
CHANGED
|
@@ -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;
|
package/schema/SchemaHelper.js
CHANGED
|
@@ -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>;
|