@mikro-orm/sql 7.2.0-dev.2 → 7.2.0-dev.20

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.
Files changed (46) hide show
  1. package/AbstractSqlConnection.d.ts +30 -3
  2. package/AbstractSqlConnection.js +75 -15
  3. package/AbstractSqlDriver.d.ts +1 -8
  4. package/AbstractSqlDriver.js +124 -55
  5. package/AbstractSqlPlatform.d.ts +4 -2
  6. package/AbstractSqlPlatform.js +35 -1
  7. package/SqlEntityManager.d.ts +2 -2
  8. package/SqlEntityManager.js +5 -4
  9. package/dialects/mssql/MsSqlNativeQueryBuilder.js +1 -1
  10. package/dialects/mysql/BaseMySqlPlatform.d.ts +2 -0
  11. package/dialects/mysql/BaseMySqlPlatform.js +4 -0
  12. package/dialects/mysql/MySqlSchemaHelper.d.ts +1 -0
  13. package/dialects/mysql/MySqlSchemaHelper.js +4 -1
  14. package/dialects/oracledb/OracleNativeQueryBuilder.js +1 -1
  15. package/dialects/postgresql/BasePostgreSqlPlatform.d.ts +3 -1
  16. package/dialects/postgresql/BasePostgreSqlPlatform.js +35 -2
  17. package/dialects/postgresql/PostgreSqlExceptionConverter.js +8 -1
  18. package/dialects/postgresql/PostgreSqlSchemaHelper.d.ts +22 -1
  19. package/dialects/postgresql/PostgreSqlSchemaHelper.js +181 -4
  20. package/dialects/sqlite/BaseSqliteConnection.d.ts +3 -0
  21. package/dialects/sqlite/BaseSqliteConnection.js +15 -5
  22. package/dialects/sqlite/SqlitePlatform.d.ts +2 -0
  23. package/dialects/sqlite/SqlitePlatform.js +4 -0
  24. package/dialects/sqlite/SqliteSchemaHelper.js +2 -2
  25. package/package.json +4 -4
  26. package/plugin/transformer.d.ts +7 -1
  27. package/plugin/transformer.js +60 -1
  28. package/query/CriteriaNodeFactory.js +4 -0
  29. package/query/NativeQueryBuilder.js +1 -1
  30. package/query/ObjectCriteriaNode.d.ts +1 -0
  31. package/query/ObjectCriteriaNode.js +30 -5
  32. package/query/QueryBuilder.d.ts +40 -7
  33. package/query/QueryBuilder.js +181 -37
  34. package/query/QueryBuilderHelper.d.ts +5 -0
  35. package/query/QueryBuilderHelper.js +39 -9
  36. package/schema/DatabaseSchema.d.ts +4 -0
  37. package/schema/DatabaseSchema.js +107 -1
  38. package/schema/DatabaseTable.d.ts +15 -1
  39. package/schema/DatabaseTable.js +113 -19
  40. package/schema/SchemaComparator.d.ts +3 -0
  41. package/schema/SchemaComparator.js +123 -10
  42. package/schema/SchemaHelper.d.ts +26 -1
  43. package/schema/SchemaHelper.js +67 -9
  44. package/schema/SqlSchemaGenerator.d.ts +4 -0
  45. package/schema/SqlSchemaGenerator.js +59 -22
  46. package/typings.d.ts +20 -2
@@ -397,7 +397,10 @@ export class QueryBuilder {
397
397
  * @internal
398
398
  */
399
399
  scheduleFilterCheck(path) {
400
- this.#state.autoJoinedPaths.push(path);
400
+ // deduplicate so filters forming a relation cycle cannot reschedule an already visited path forever
401
+ if (!this.#state.autoJoinedPaths.includes(path)) {
402
+ this.#state.autoJoinedPaths.push(path);
403
+ }
401
404
  }
402
405
  /**
403
406
  * @internal
@@ -433,6 +436,7 @@ export class QueryBuilder {
433
436
  else {
434
437
  join.cond = { ...cond };
435
438
  }
439
+ this.nestReferencedJoins(join);
436
440
  // For polymorphic LEFT JOIN filters, add a WHERE condition to enforce the filter
437
441
  // only for rows matching this target's discriminator value. This ensures rows pointing
438
442
  // to other polymorphic targets are not excluded.
@@ -456,6 +460,48 @@ export class QueryBuilder {
456
460
  }
457
461
  }
458
462
  }
463
+ /**
464
+ * The `on` clause of `condJoin` — its explicit join condition or a filter condition merged into
465
+ * it — can reference the alias of any join in its subtree, both auto-joins created while
466
+ * processing the condition and pre-existing joined paths, all of which render after `condJoin`
467
+ * and would be forward alias references (issues #7681, #8090, #8099). When that happens, fold the
468
+ * subtree into `condJoin`, so it renders as a single parenthesized join group and every alias
469
+ * shares the scope of the outer `on` clause.
470
+ */
471
+ nestReferencedJoins(condJoin) {
472
+ // m:n pivot joins might not have the target join entry created
473
+ if (!condJoin) {
474
+ return;
475
+ }
476
+ const subtree = this.getJoinSubtree(condJoin);
477
+ if (!subtree.some(j => this.condReferencesAlias(condJoin.cond, j.alias))) {
478
+ return;
479
+ }
480
+ for (const j of subtree) {
481
+ const parent = j.ownerAlias === condJoin.alias ? condJoin : subtree.find(p => p.alias === j.ownerAlias);
482
+ if (!parent.nested?.has(j)) {
483
+ const nested = (parent.nested ??= new Set());
484
+ j.type = j.type === JoinType.innerJoin ? JoinType.nestedInnerJoin : JoinType.nestedLeftJoin;
485
+ nested.add(j);
486
+ }
487
+ }
488
+ }
489
+ getJoinSubtree(join) {
490
+ const children = Object.values(this.#state.joins).filter(j => j !== join && j.ownerAlias === join.alias);
491
+ return children.flatMap(j => [j, ...this.getJoinSubtree(j)]);
492
+ }
493
+ condReferencesAlias(cond, alias) {
494
+ if (Array.isArray(cond)) {
495
+ return cond.some(c => this.condReferencesAlias(c, alias));
496
+ }
497
+ if (Utils.isPlainObject(cond)) {
498
+ return Object.entries(cond).some(([key, value]) => {
499
+ const [keyAlias, field] = this.helper.splitField(key);
500
+ return this.helper.getTPTAliasForProperty(field, keyAlias) === alias || this.condReferencesAlias(value, alias);
501
+ });
502
+ }
503
+ return false;
504
+ }
459
505
  withSubQuery(subQuery, alias) {
460
506
  this.ensureNotFinalized();
461
507
  if (isRaw(subQuery)) {
@@ -861,6 +907,8 @@ export class QueryBuilder {
861
907
  if (this.type === QueryType.INSERT) {
862
908
  const returningProps = meta.hydrateProps
863
909
  .filter(prop => prop.returning || (prop.persist !== false && ((prop.primary && prop.autoincrement) || prop.defaultRaw)))
910
+ // a TPT table can only return its own columns
911
+ .filter(prop => meta.inheritanceType !== 'tpt' || prop.primary || meta.ownProps.some(p => p.name === prop.name))
864
912
  .filter(prop => !data || !(prop.name in data));
865
913
  if (returningProps.length > 0) {
866
914
  qb.returning(Utils.flatten(returningProps.map(prop => prop.fieldNames)));
@@ -950,12 +998,13 @@ export class QueryBuilder {
950
998
  }
951
999
  if (!join && options?.ignoreBranching) {
952
1000
  join = joins.find(j => {
953
- return j.path?.replace(/\[\d+]/g, '') === path.replace(/\[\d+]/g, '');
1001
+ return j.path?.replace(/\[\d+]/g, '') === path.replace(/\[\d+]/g, '') && !this.branchesConflict(j.path, path);
954
1002
  });
955
1003
  }
956
1004
  if (!join && options?.matchPopulateJoins && options?.ignoreBranching) {
957
1005
  join = joins.find(j => {
958
- return j.path?.replace(/\[\d+]|\[populate]/g, '') === path.replace(/\[\d+]|\[populate]/g, '');
1006
+ return (j.path?.replace(/\[\d+]|\[populate]/g, '') === path.replace(/\[\d+]|\[populate]/g, '') &&
1007
+ !this.branchesConflict(j.path, path));
959
1008
  });
960
1009
  }
961
1010
  if (!join && options?.matchPopulateJoins) {
@@ -965,6 +1014,12 @@ export class QueryBuilder {
965
1014
  }
966
1015
  return join;
967
1016
  }
1017
+ /** Branch-insensitive path matching still must not cross branches — segments with different explicit branch markers (e.g. `Mobile[0]` vs `Mobile[1]`) belong to sibling joins. */
1018
+ branchesConflict(path1, path2) {
1019
+ const markers = (path) => path.split('.').map(segment => /\[(\d+)]/.exec(segment)?.[1]);
1020
+ const markers2 = markers(path2);
1021
+ return markers(path1).some((m, idx) => m != null && markers2[idx] != null && m !== markers2[idx]);
1022
+ }
968
1023
  /**
969
1024
  * @internal
970
1025
  */
@@ -1010,17 +1065,26 @@ export class QueryBuilder {
1010
1065
  this.limit(1);
1011
1066
  }
1012
1067
  const query = this.toQuery();
1013
- const cached = await this.em?.tryCache(this.mainAlias.entityName, this.#state.cache, [
1014
- 'qb.execute',
1015
- query.sql,
1016
- query.params,
1017
- method,
1018
- ]);
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);
1019
1075
  if (cached?.data !== undefined) {
1020
1076
  return cached.data;
1021
1077
  }
1022
1078
  const loggerContext = { id: this.em?.id, ...this.loggerContext, ...this.#abortOptions };
1023
- 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));
1024
1088
  const meta = this.mainAlias.meta;
1025
1089
  if (!options.mapResults || !meta) {
1026
1090
  await this.em?.storeCache(this.#state.cache, cached, res);
@@ -1070,6 +1134,11 @@ export class QueryBuilder {
1070
1134
  * ```
1071
1135
  */
1072
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
+ }
1073
1142
  options ??= {};
1074
1143
  options.mergeResults ??= true;
1075
1144
  options.mapResults ??= true;
@@ -1104,7 +1173,9 @@ export class QueryBuilder {
1104
1173
  }
1105
1174
  if (stack.length > 0) {
1106
1175
  const merged = this.driver.mergeJoinedResult(stack, this.mainAlias.meta, joinedProps);
1107
- yield this.mapResult(merged[0], options.mapResults);
1176
+ for (const row of merged) {
1177
+ yield this.mapResult(row, options.mapResults);
1178
+ }
1108
1179
  }
1109
1180
  }
1110
1181
  /**
@@ -1368,19 +1439,19 @@ export class QueryBuilder {
1368
1439
  prop.targetMeta = field.mainAlias.meta;
1369
1440
  field = field.getNativeQuery();
1370
1441
  }
1371
- if (isRaw(field)) {
1372
- field = this.platform.formatQuery(field.sql, field.params);
1373
- }
1374
1442
  const key = `${this.alias}.${prop.name}#${alias}`;
1375
- this.#state.joins[key] = {
1376
- prop,
1377
- alias,
1378
- type,
1379
- cond,
1380
- schema,
1381
- subquery: field.toString(),
1382
- ownerAlias: this.alias,
1383
- };
1443
+ const join = { prop, alias, type, cond, schema, ownerAlias: this.alias };
1444
+ // `sql.ref('...')` is a bare table/CTE reference, join it by name instead of wrapping it as a sub-query
1445
+ if (isRaw(field) && field.sql === '??' && field.params.length === 1) {
1446
+ join.table = String(field.params[0]);
1447
+ }
1448
+ else {
1449
+ if (isRaw(field)) {
1450
+ field = this.platform.formatQuery(field.sql, field.params);
1451
+ }
1452
+ join.subquery = field.toString();
1453
+ }
1454
+ this.#state.joins[key] = join;
1384
1455
  return { prop, key };
1385
1456
  }
1386
1457
  if (!subquery && type.includes('lateral')) {
@@ -1412,7 +1483,6 @@ export class QueryBuilder {
1412
1483
  aliased: [QueryType.SELECT, QueryType.COUNT].includes(this.type),
1413
1484
  });
1414
1485
  const criteriaNode = CriteriaNodeFactory.createNode(this.metadata, prop.targetMeta.class, cond);
1415
- const joinCountBefore = Object.keys(this.#state.joins).length;
1416
1486
  cond = criteriaNode.process(this, { ignoreBranching: true, alias });
1417
1487
  let aliasedName = `${fromAlias}.${prop.name}#${alias}`;
1418
1488
  path ??= `${Object.values(this.#state.joins).find(j => j.alias === fromAlias)?.path ?? Utils.className(entityName)}.${prop.name}`;
@@ -1442,22 +1512,31 @@ export class QueryBuilder {
1442
1512
  this.#state.joins[aliasedName] = this.helper.joinManyToOneReference(prop, ownerAlias, alias, type, cond, schema);
1443
1513
  this.#state.joins[aliasedName].path ??= path;
1444
1514
  }
1445
- // auto-joins added by cond processing that depend on the new alias would otherwise produce a
1446
- // forward reference (the auto-join's ON refers to alias, while alias's ON refers back to it);
1447
- // fold them into the new join so both aliases share scope in the outer ON clause (issue #7681)
1448
- const condJoin = this.#state.joins[aliasedName];
1449
- const joinKeys = Object.keys(this.#state.joins);
1450
- for (let i = joinCountBefore; i < joinKeys.length; i++) {
1451
- const j = this.#state.joins[joinKeys[i]];
1452
- if (j === condJoin || j.ownerAlias !== alias) {
1453
- continue;
1454
- }
1455
- const nested = (condJoin.nested ??= new Set());
1456
- j.type = j.type === JoinType.innerJoin ? JoinType.nestedInnerJoin : JoinType.nestedLeftJoin;
1457
- nested.add(j);
1515
+ if (prop.targetMeta.inheritanceType === 'tpt' && prop.targetMeta.tptParent) {
1516
+ this.addTPTParentJoins(prop.targetMeta, alias, path);
1458
1517
  }
1518
+ this.nestReferencedJoins(this.#state.joins[aliasedName]);
1459
1519
  return { prop, key: aliasedName };
1460
1520
  }
1521
+ /**
1522
+ * Walks the TPT inheritance chain of `leafMeta` and INNER JOINs each parent table.
1523
+ * Registers the parent aliases in `state.tptAlias` so column resolution finds them
1524
+ * when conditions reference parent-table columns.
1525
+ * @internal
1526
+ */
1527
+ addTPTParentJoins(leafMeta, leafAlias, basePath) {
1528
+ let childAlias = leafAlias;
1529
+ let childMeta = leafMeta;
1530
+ while (childMeta.tptParent) {
1531
+ const parentMeta = childMeta.tptParent;
1532
+ const parentAlias = this.getNextAlias(parentMeta.className);
1533
+ this.createAlias(parentMeta.class, parentAlias);
1534
+ this.#state.tptAlias[`${leafAlias}:${parentMeta.className}`] = parentAlias;
1535
+ this.addPropertyJoin(childMeta.tptParentProp, childAlias, parentAlias, JoinType.innerJoin, `${basePath}.[tpt]${childMeta.className}`);
1536
+ childAlias = parentAlias;
1537
+ childMeta = parentMeta;
1538
+ }
1539
+ }
1461
1540
  prepareFields(fields, type = 'where', schema) {
1462
1541
  const ret = [];
1463
1542
  const getFieldName = (name, customAlias) => {
@@ -2010,6 +2089,13 @@ export class QueryBuilder {
2010
2089
  for (const k of Object.keys(cond)) {
2011
2090
  if (Utils.isOperator(k)) {
2012
2091
  if (Array.isArray(cond[k])) {
2092
+ if (k === '$or' && !this.canDistributeOrBranches(cond[k], joins)) {
2093
+ // entity filters have no other sink, so their `$or` is kept intact on the outermost targeted join instead
2094
+ if (filter) {
2095
+ this.mergeFilterOrCondition(cond[k], joins);
2096
+ }
2097
+ continue;
2098
+ }
2013
2099
  cond[k].forEach((c) => this.mergeOnConditions(joins, c, filter, k));
2014
2100
  }
2015
2101
  /* v8 ignore next */
@@ -2040,8 +2126,66 @@ export class QueryBuilder {
2040
2126
  else {
2041
2127
  join.cond = { ...join.cond, [k]: cond[k] };
2042
2128
  }
2129
+ this.nestReferencedJoins(join);
2130
+ }
2131
+ }
2132
+ }
2133
+ /**
2134
+ * `$or` branches can be moved to a join's `on` clause only when they all target that same join
2135
+ * and stay flat — a partial `$or` in the `on` clause would drop rows matching a sibling branch,
2136
+ * and nested operators cannot be preserved inside a distributed disjunction, as `mergeOnConditions`
2137
+ * would flatten them into `and` conjuncts.
2138
+ */
2139
+ canDistributeOrBranches(branches, joins) {
2140
+ const aliases = this.getOrBranchAliases(branches);
2141
+ const targeted = joins.filter(j => aliases.has(j.alias));
2142
+ const flat = branches.every(branch => Object.keys(branch).every(k => !Utils.isOperator(k)));
2143
+ return aliases.size === 1 && targeted.length === 1 && flat;
2144
+ }
2145
+ getOrBranchAliases(branches) {
2146
+ const aliases = new Set();
2147
+ const collectAliases = (cond) => {
2148
+ for (const k of Object.keys(cond)) {
2149
+ if (Utils.isOperator(k)) {
2150
+ Utils.asArray(cond[k]).forEach((c) => collectAliases(c));
2151
+ }
2152
+ else {
2153
+ aliases.add(this.helper.splitField(k)[0]);
2154
+ }
2155
+ }
2156
+ };
2157
+ branches.forEach(collectAliases);
2158
+ return aliases;
2159
+ }
2160
+ /**
2161
+ * An entity filter's `$or` that cannot be distributed is applied intact to the `on` clause of the
2162
+ * outermost join common to all targeted joins, nesting the targeted joins under it so the clause
2163
+ * can reference their aliases.
2164
+ */
2165
+ mergeFilterOrCondition(branches, joins) {
2166
+ const aliases = this.getOrBranchAliases(branches);
2167
+ const chainOf = (join) => {
2168
+ const parent = joins.find(j => j.alias === join.ownerAlias);
2169
+ return parent ? [join, ...chainOf(parent)] : [join];
2170
+ };
2171
+ // chains go from each targeted join up to its root, so the first join present in all of them is the outermost common one
2172
+ const chains = joins.filter(j => aliases.has(j.alias)).map(chainOf);
2173
+ const anchor = chains[0]?.find(a => chains.every(chain => chain.includes(a)));
2174
+ /* v8 ignore next 3 */
2175
+ if (!anchor) {
2176
+ return;
2177
+ }
2178
+ for (const chain of chains) {
2179
+ // nest the chain below the anchor, so the anchor's `on` clause can reference the nested aliases
2180
+ for (let i = 0; chain[i] !== anchor; i++) {
2181
+ const nested = (chain[i + 1].nested ??= new Set());
2182
+ if (!nested.has(chain[i])) {
2183
+ chain[i].type = chain[i].type === JoinType.innerJoin ? JoinType.nestedInnerJoin : JoinType.nestedLeftJoin;
2184
+ nested.add(chain[i]);
2185
+ }
2043
2186
  }
2044
2187
  }
2188
+ anchor.cond = anchor.cond.$or ? { $and: [anchor.cond, { $or: branches }] } : { ...anchor.cond, $or: branches };
2045
2189
  }
2046
2190
  /**
2047
2191
  * When adding an inner join on a left joined relation, we need to nest them,
@@ -14,6 +14,11 @@ export declare class QueryBuilderHelper {
14
14
  * Returns the main alias if not a TPT property or if the property belongs to the main entity.
15
15
  */
16
16
  getTPTAliasForProperty(propName: string, defaultAlias: string): string;
17
+ /**
18
+ * Replaces `ALIAS_REPLACEMENT` placeholders, resolving column-qualified ones via
19
+ * `getTPTAliasForProperty()` so TPT inherited columns map to their owning table.
20
+ */
21
+ replaceAliases(sql: string, alias?: string): string;
17
22
  mapper(field: string | Raw | RawQueryFragmentSymbol, type?: QueryType): string;
18
23
  mapper(field: string | Raw | RawQueryFragmentSymbol, type?: QueryType, value?: any, alias?: string | null, schema?: string): string;
19
24
  processData(data: Dictionary, convertCustomTypes: boolean, multi?: boolean): any;
@@ -1,5 +1,7 @@
1
1
  import { ALIAS_REPLACEMENT, ALIAS_REPLACEMENT_RE, ArrayType, JsonType, inspect, isRaw, LockMode, OptimisticLockError, QueryOperator, QueryOrderNumeric, raw, Raw, QueryHelper, ReferenceKind, Utils, ValidationError, } from '@mikro-orm/core';
2
2
  import { EMBEDDABLE_ARRAY_OPS, JoinType, QueryType } from './enums.js';
3
+ /** Captures the column name that follows the alias placeholder. */
4
+ const QUALIFYING_ALIAS_RE = new RegExp(ALIAS_REPLACEMENT_RE + '(?=["\'`\\]]*\\.["\'`\\[]?([\\w$]+))', 'g');
3
5
  /**
4
6
  * @internal
5
7
  */
@@ -49,6 +51,15 @@ export class QueryBuilderHelper {
49
51
  // Property not found in hierarchy, return default alias
50
52
  return defaultAlias;
51
53
  }
54
+ /**
55
+ * Replaces `ALIAS_REPLACEMENT` placeholders, resolving column-qualified ones via
56
+ * `getTPTAliasForProperty()` so TPT inherited columns map to their owning table.
57
+ */
58
+ replaceAliases(sql, alias = this.#alias) {
59
+ return sql
60
+ .replace(QUALIFYING_ALIAS_RE, (_, column) => this.getTPTAliasForProperty(column, alias))
61
+ .replaceAll(ALIAS_REPLACEMENT, alias);
62
+ }
52
63
  mapper(field, type = QueryType.SELECT, value, alias, schema) {
53
64
  if (isRaw(field)) {
54
65
  return raw(field.sql, field.params);
@@ -97,7 +108,6 @@ export class QueryBuilderHelper {
97
108
  // Only apply TPT resolution when `a` is an actual table alias (in aliasMap),
98
109
  // not when it's an embedded property name like 'profile1.identity.links'
99
110
  const isTableAlias = !!this.#aliasMap[a];
100
- const baseAlias = isTableAlias ? a : this.#alias;
101
111
  const resolvedAlias = isTableAlias ? this.getTPTAliasForProperty(prop?.name ?? f, a) : this.#alias;
102
112
  const aliasPrefix = isTableNameAliasRequired ? resolvedAlias + '.' : '';
103
113
  const fkIdx2 = prop?.fieldNames.findIndex(name => name === f) ?? -1;
@@ -249,7 +259,13 @@ export class QueryBuilderHelper {
249
259
  }[join.type] ?? join.type;
250
260
  const conditions = [];
251
261
  const params = [];
252
- schema = join.schema === '*' ? schema : (join.schema ?? schemaOverride);
262
+ if (join.prop.name === '__subquery__') {
263
+ // bare table/CTE references (`sql.ref()` joins) keep their literal name, only an explicit schema applies
264
+ schema = join.schema === '*' ? undefined : join.schema;
265
+ }
266
+ else {
267
+ schema = join.schema === '*' ? schema : (join.schema ?? schemaOverride);
268
+ }
253
269
  if (schema && schema !== this.#platform.getDefaultSchemaName()) {
254
270
  table = `${schema}.${table}`;
255
271
  }
@@ -414,7 +430,8 @@ export class QueryBuilderHelper {
414
430
  }
415
431
  if (k === '$not') {
416
432
  const res = this._appendQueryCondition(type, cond[k]);
417
- parts.push(`not (${res.sql})`);
433
+ // negating a vacuously true condition (e.g. an empty `$and`) matches nothing
434
+ parts.push(res.sql ? `not (${res.sql})` : '1 = 0');
418
435
  res.params.forEach(p => params.push(p));
419
436
  continue;
420
437
  }
@@ -464,7 +481,7 @@ export class QueryBuilderHelper {
464
481
  const op = cond[key] === null ? 'is' : '=';
465
482
  if (Raw.isKnownFragmentSymbol(key)) {
466
483
  const raw = Raw.getKnownFragment(key);
467
- const sql = raw.sql.replaceAll(ALIAS_REPLACEMENT, this.#alias);
484
+ const sql = this.replaceAliases(raw.sql);
468
485
  const value = Utils.asArray(cond[key]);
469
486
  params.push(...raw.params);
470
487
  if (value.length > 0) {
@@ -516,6 +533,11 @@ export class QueryBuilderHelper {
516
533
  const replacement = this.getOperatorReplacement(op, value);
517
534
  const rawField = Raw.isKnownFragmentSymbol(key);
518
535
  const fields = rawField ? [key] : Utils.splitPrimaryKeys(key);
536
+ // a raw key's arity is opaque, so the row-value form is detected from the payload instead
537
+ const rowValues = rawField &&
538
+ ['$in', '$nin'].includes(op) &&
539
+ Array.isArray(value[op]) &&
540
+ value[op].every((v) => Array.isArray(v));
519
541
  if (fields.length > 1 && Array.isArray(value[op])) {
520
542
  const singleTuple = !value[op].every((v) => Array.isArray(v));
521
543
  if (!this.#platform.allowsComparingTuples()) {
@@ -575,7 +597,7 @@ export class QueryBuilderHelper {
575
597
  if (opValueIsRaw || typeof opValue?.toRaw === 'function') {
576
598
  const query = opValueIsRaw ? opValue : opValue.toRaw();
577
599
  const mappedKey = this.mapper(key, type, query, null);
578
- let sql = query.sql.replaceAll(ALIAS_REPLACEMENT, this.#alias);
600
+ let sql = this.replaceAliases(query.sql);
579
601
  if (['$in', '$nin'].includes(op)) {
580
602
  sql = `(${sql})`;
581
603
  }
@@ -584,15 +606,15 @@ export class QueryBuilderHelper {
584
606
  }
585
607
  else {
586
608
  const mappedKey = this.mapper(key, type, opValue, null);
587
- const val = this.getValueReplacement(fields, opValue, params, op, prop);
609
+ const val = this.getValueReplacement(fields, opValue, params, op, prop, rowValues);
588
610
  parts.push(`${this.#platform.quoteIdentifier(mappedKey)} ${replacement} ${val}`);
589
611
  }
590
612
  }
591
613
  return { sql: parts.join(' and '), params };
592
614
  }
593
- getValueReplacement(fields, value, params, key, prop) {
615
+ getValueReplacement(fields, value, params, key, prop, rowValues = false) {
594
616
  if (Array.isArray(value)) {
595
- if (fields.length > 1) {
617
+ if (fields.length > 1 || rowValues) {
596
618
  const tmp = [];
597
619
  for (const field of value) {
598
620
  tmp.push(`(${field.map(() => '?').join(', ')})`);
@@ -621,6 +643,10 @@ export class QueryBuilderHelper {
621
643
  return '?';
622
644
  }
623
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
+ }
624
650
  let replacement = QueryOperator[op];
625
651
  if (op === '$exists') {
626
652
  replacement = value[op] ? 'is not' : 'is';
@@ -741,7 +767,7 @@ export class QueryBuilderHelper {
741
767
  }
742
768
  updateVersionProperty(qb, data) {
743
769
  const meta = this.#metadata.find(this.#entityName);
744
- if (!meta?.versionProperty || meta.versionProperty in data) {
770
+ if (!meta?.ownsVersionProperty() || meta.versionProperty in data) {
745
771
  return;
746
772
  }
747
773
  const versionProperty = meta.properties[meta.versionProperty];
@@ -785,6 +811,10 @@ export class QueryBuilderHelper {
785
811
  appendGroupCondition(type, operator, subCondition) {
786
812
  const parts = [];
787
813
  const params = [];
814
+ // an empty disjunction is false, same as `$in: []`, while an empty conjunction is vacuously true
815
+ if (operator === '$or' && subCondition.length === 0) {
816
+ return { sql: '1 = 0', params };
817
+ }
788
818
  // single sub-condition can be ignored to reduce nesting of parens
789
819
  if (subCondition.length === 1 || operator === '$and') {
790
820
  for (const sub of subCondition) {
@@ -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 */
@@ -49,6 +58,8 @@ export declare class DatabaseTable {
49
58
  getEntityDeclaration(namingStrategy: NamingStrategy, schemaHelper: SchemaHelper, scalarPropertiesForRelations: 'always' | 'never' | 'smart'): EntityMetadata;
50
59
  private foreignKeysToProps;
51
60
  private findFkIndex;
61
+ /** Advanced options require an entity-level declaration, as the property-level `index`/`unique` cannot carry them. */
62
+ private hasAdvancedIndexOptions;
52
63
  private getIndexProperties;
53
64
  private getSafeBaseNameForFkProp;
54
65
  /**
@@ -63,6 +74,8 @@ export declare class DatabaseTable {
63
74
  hasCheck(checkName: string): boolean;
64
75
  getTrigger(triggerName: string): SqlTriggerDef | undefined;
65
76
  hasTrigger(triggerName: string): boolean;
77
+ getPolicy(policyName: string): SqlPolicyDef | undefined;
78
+ hasPolicy(policyName: string): boolean;
66
79
  getPrimaryKey(): IndexDef | undefined;
67
80
  hasPrimaryKey(): boolean;
68
81
  private getForeignKeyDeclaration;
@@ -97,5 +110,6 @@ export declare class DatabaseTable {
97
110
  private processIndexWhere;
98
111
  addCheck(check: CheckDef): void;
99
112
  addTrigger(trigger: SqlTriggerDef): void;
113
+ addPolicy(policy: SqlPolicyDef): void;
100
114
  toJSON(): Dictionary;
101
115
  }