@mikro-orm/sql 7.1.0-dev.4 → 7.1.0-dev.41

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 (55) hide show
  1. package/AbstractSqlConnection.d.ts +1 -1
  2. package/AbstractSqlConnection.js +27 -6
  3. package/AbstractSqlDriver.d.ts +26 -1
  4. package/AbstractSqlDriver.js +286 -35
  5. package/AbstractSqlPlatform.d.ts +15 -3
  6. package/AbstractSqlPlatform.js +25 -7
  7. package/PivotCollectionPersister.d.ts +2 -2
  8. package/PivotCollectionPersister.js +19 -3
  9. package/README.md +2 -1
  10. package/SqlEntityManager.d.ts +46 -3
  11. package/SqlEntityManager.js +77 -7
  12. package/SqlMikroORM.d.ts +23 -0
  13. package/SqlMikroORM.js +23 -0
  14. package/dialects/mysql/BaseMySqlPlatform.d.ts +4 -5
  15. package/dialects/mysql/BaseMySqlPlatform.js +9 -10
  16. package/dialects/mysql/MySqlSchemaHelper.d.ts +13 -3
  17. package/dialects/mysql/MySqlSchemaHelper.js +145 -21
  18. package/dialects/oracledb/OracleDialect.d.ts +1 -1
  19. package/dialects/oracledb/OracleDialect.js +2 -1
  20. package/dialects/postgresql/BasePostgreSqlEntityManager.d.ts +19 -0
  21. package/dialects/postgresql/BasePostgreSqlEntityManager.js +24 -0
  22. package/dialects/postgresql/BasePostgreSqlPlatform.d.ts +11 -5
  23. package/dialects/postgresql/BasePostgreSqlPlatform.js +75 -17
  24. package/dialects/postgresql/PostgreSqlSchemaHelper.d.ts +31 -1
  25. package/dialects/postgresql/PostgreSqlSchemaHelper.js +230 -5
  26. package/dialects/postgresql/index.d.ts +2 -0
  27. package/dialects/postgresql/index.js +2 -0
  28. package/dialects/postgresql/typeOverrides.d.ts +14 -0
  29. package/dialects/postgresql/typeOverrides.js +12 -0
  30. package/dialects/sqlite/SqlitePlatform.d.ts +2 -1
  31. package/dialects/sqlite/SqlitePlatform.js +4 -0
  32. package/dialects/sqlite/SqliteSchemaHelper.d.ts +9 -2
  33. package/dialects/sqlite/SqliteSchemaHelper.js +148 -19
  34. package/index.d.ts +2 -0
  35. package/index.js +2 -0
  36. package/package.json +4 -4
  37. package/plugin/transformer.d.ts +11 -3
  38. package/plugin/transformer.js +138 -29
  39. package/query/CriteriaNode.d.ts +1 -1
  40. package/query/CriteriaNode.js +2 -2
  41. package/query/ObjectCriteriaNode.js +1 -1
  42. package/query/QueryBuilder.d.ts +42 -1
  43. package/query/QueryBuilder.js +78 -7
  44. package/schema/DatabaseSchema.js +26 -4
  45. package/schema/DatabaseTable.d.ts +20 -1
  46. package/schema/DatabaseTable.js +182 -31
  47. package/schema/SchemaComparator.d.ts +10 -0
  48. package/schema/SchemaComparator.js +104 -1
  49. package/schema/SchemaHelper.d.ts +63 -1
  50. package/schema/SchemaHelper.js +235 -6
  51. package/schema/SqlSchemaGenerator.d.ts +2 -2
  52. package/schema/SqlSchemaGenerator.js +16 -9
  53. package/schema/partitioning.d.ts +13 -0
  54. package/schema/partitioning.js +326 -0
  55. package/typings.d.ts +34 -2
@@ -3,6 +3,28 @@ import { QueryBuilder } from './query/QueryBuilder.js';
3
3
  import { JoinType, QueryType } from './query/enums.js';
4
4
  import { SqlEntityManager } from './SqlEntityManager.js';
5
5
  import { PivotCollectionPersister } from './PivotCollectionPersister.js';
6
+ /** Extracts cancellation controls from any options bag that extends `AbortQueryOptions`. */
7
+ function pickAbortOptions(options) {
8
+ if (!options || (options.signal == null && options.inflightQueryAbortStrategy == null)) {
9
+ return undefined;
10
+ }
11
+ return {
12
+ signal: options.signal,
13
+ inflightQueryAbortStrategy: options.inflightQueryAbortStrategy,
14
+ };
15
+ }
16
+ /**
17
+ * Returns a `loggerContext` payload that carries the abort fields alongside any existing
18
+ * context. The connection layer strips them before logging — this avoids widening the public
19
+ * `Connection.execute()` signature.
20
+ */
21
+ function withAbortContext(loggerContext, options) {
22
+ const abort = pickAbortOptions(options);
23
+ if (!abort) {
24
+ return loggerContext;
25
+ }
26
+ return { ...loggerContext, ...abort };
27
+ }
6
28
  /** Base class for SQL database drivers, implementing find/insert/update/delete using QueryBuilder. */
7
29
  export class AbstractSqlDriver extends DatabaseDriver {
8
30
  [EntityManagerType];
@@ -34,6 +56,13 @@ export class AbstractSqlDriver extends DatabaseDriver {
34
56
  return { alias, name: meta.tableName, schema: effectiveSchema, qualifiedName, toString: () => alias };
35
57
  }
36
58
  validateSqlOptions(options) {
59
+ if (options.using && !options.indexHint) {
60
+ const names = Utils.asArray(options.using);
61
+ const hint = this.platform.formatIndexHint(names);
62
+ if (hint) {
63
+ options.indexHint = hint;
64
+ }
65
+ }
37
66
  if (options.collation != null && typeof options.collation !== 'string') {
38
67
  throw new Error('Collation option for SQL drivers must be a string (collation name). Use a CollationOptions object only with MongoDB.');
39
68
  }
@@ -50,7 +79,10 @@ export class AbstractSqlDriver extends DatabaseDriver {
50
79
  const populate = this.autoJoinOneToOneOwner(meta, options.populate, options.fields);
51
80
  const joinedProps = this.joinedProps(meta, populate, options);
52
81
  const schema = this.getSchemaName(meta, options);
53
- const qb = this.createQueryBuilder(meta.class, options.ctx, connectionType, false, options.logging, undefined, options.em).withSchema(schema);
82
+ const qb = this.createQueryBuilder(meta.class, options.ctx, connectionType, false, options.logging, undefined, options.em)
83
+ .withSchema(schema)
84
+ .cache(false);
85
+ qb.setAbortOptions(pickAbortOptions(options));
54
86
  const fields = this.buildFields(meta, populate, joinedProps, qb, qb.alias, options, schema);
55
87
  const orderBy = this.buildOrderBy(qb, meta, populate, options);
56
88
  const populateWhere = this.buildPopulateWhere(meta, joinedProps, options);
@@ -88,6 +120,9 @@ export class AbstractSqlDriver extends DatabaseDriver {
88
120
  if (options.em) {
89
121
  await qb.applyJoinedFilters(options.em, options.filters);
90
122
  }
123
+ if (options._partitionLimit) {
124
+ qb.setPartitionLimit(options._partitionLimit);
125
+ }
91
126
  return qb;
92
127
  }
93
128
  async find(entityName, where, options = {}) {
@@ -204,7 +239,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
204
239
  const asKeyword = this.platform.usesAsKeyword() ? ' as ' : ' ';
205
240
  native.from(raw(`(${expression})${asKeyword}${this.platform.quoteIdentifier(qb.alias)}`));
206
241
  const query = native.compile();
207
- const res = await this.execute(query.sql, query.params, 'all', options.ctx);
242
+ const res = await this.execute(query.sql, query.params, 'all', options.ctx, withAbortContext(options.loggerContext, options));
208
243
  if (type === QueryType.COUNT) {
209
244
  return res[0].count;
210
245
  }
@@ -221,7 +256,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
221
256
  native.from(raw(`(${expression})${asKeyword}${this.platform.quoteIdentifier(qb.alias)}`));
222
257
  const query = native.compile();
223
258
  const connectionType = this.resolveConnectionType({ ctx: options.ctx, connectionType: options.connectionType });
224
- const res = this.getConnection(connectionType).stream(query.sql, query.params, options.ctx, options.loggerContext);
259
+ const res = this.getConnection(connectionType).stream(query.sql, query.params, options.ctx, withAbortContext(options.loggerContext, options), options.chunkSize);
225
260
  for await (const row of res) {
226
261
  yield this.mapResult(row, meta);
227
262
  }
@@ -529,6 +564,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
529
564
  const joinedProps = this.joinedProps(meta, populate, options);
530
565
  const schema = this.getSchemaName(meta, options);
531
566
  const qb = this.createQueryBuilder(entityName, options.ctx, options.connectionType, false, options.logging);
567
+ qb.setAbortOptions(pickAbortOptions(options));
532
568
  const populateWhere = this.buildPopulateWhere(meta, joinedProps, options);
533
569
  if (meta && !Utils.isEmpty(populate)) {
534
570
  this.buildFields(meta, populate, joinedProps, qb, qb.alias, options, schema);
@@ -554,6 +590,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
554
590
  const meta = this.metadata.get(entityName);
555
591
  const collections = this.extractManyToMany(meta, data);
556
592
  const qb = this.createQueryBuilder(entityName, options.ctx, 'write', options.convertCustomTypes, options.loggerContext).withSchema(this.getSchemaName(meta, options));
593
+ qb.setAbortOptions(pickAbortOptions(options));
557
594
  const res = await this.rethrow(qb.insert(data).execute('run', false));
558
595
  res.row = res.row || {};
559
596
  let pk;
@@ -586,9 +623,12 @@ export class AbstractSqlDriver extends DatabaseDriver {
586
623
  const props = this.getCloneableProps(meta);
587
624
  const mappedOverrides = this.mapCloneOverrides(overrides, meta, options);
588
625
  const { selectFields, insertColumns } = this.buildCloneFields(props, mappedOverrides, meta);
626
+ const abort = pickAbortOptions(options);
589
627
  const selectQb = this.createQueryBuilder(meta.class, options.ctx, 'read', options.convertCustomTypes, options.loggerContext).withSchema(this.getSchemaName(meta, options));
590
628
  selectQb.select(selectFields).where(where);
629
+ selectQb.setAbortOptions(abort);
591
630
  const insertQb = this.createQueryBuilder(meta.class, options.ctx, 'write', options.convertCustomTypes, options.loggerContext).withSchema(this.getSchemaName(meta, options));
631
+ insertQb.setAbortOptions(abort);
592
632
  return this.rethrow(insertQb
593
633
  .insertFrom(selectQb, { columns: insertColumns })
594
634
  .execute('run', false));
@@ -618,9 +658,12 @@ export class AbstractSqlDriver extends DatabaseDriver {
618
658
  }
619
659
  }
620
660
  const sourceWhere = tableMeta === rootMeta ? where : (Utils.extractPK(where, tableMeta) ?? where);
661
+ const abort = pickAbortOptions(options);
621
662
  const selectQb = this.createQueryBuilder(tableMeta.class, options.ctx, 'read', options.convertCustomTypes, options.loggerContext).withSchema(this.getSchemaName(tableMeta, options));
622
663
  selectQb.select(selectFields).where(sourceWhere);
664
+ selectQb.setAbortOptions(abort);
623
665
  const insertQb = this.createQueryBuilder(tableMeta.class, options.ctx, 'write', options.convertCustomTypes, options.loggerContext).withSchema(this.getSchemaName(tableMeta, options));
666
+ insertQb.setAbortOptions(abort);
624
667
  const res = await this.rethrow(insertQb
625
668
  .insertFrom(selectQb, { columns: insertColumns })
626
669
  .execute('run', false));
@@ -828,7 +871,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
828
871
  if (transform) {
829
872
  sql = transform(sql);
830
873
  }
831
- const res = await this.execute(sql, params, 'run', options.ctx, options.loggerContext);
874
+ const res = await this.execute(sql, params, 'run', options.ctx, withAbortContext(options.loggerContext, options));
832
875
  let pk;
833
876
  /* v8 ignore next */
834
877
  if (pks.length > 1) {
@@ -861,6 +904,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
861
904
  }
862
905
  if (Utils.hasObjectKeys(data)) {
863
906
  const qb = this.createQueryBuilder(entityName, options.ctx, 'write', options.convertCustomTypes, options.loggerContext).withSchema(this.getSchemaName(meta, options));
907
+ qb.setAbortOptions(pickAbortOptions(options));
864
908
  if (options.upsert) {
865
909
  /* v8 ignore next */
866
910
  const uniqueFields = options.onConflictFields ??
@@ -906,6 +950,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
906
950
  ? Object.keys(where[0]).flatMap(key => Utils.splitPrimaryKeys(key))
907
951
  : meta.primaryKeys);
908
952
  const qb = this.createQueryBuilder(entityName, options.ctx, 'write', options.convertCustomTypes, options.loggerContext).withSchema(this.getSchemaName(meta, options));
953
+ qb.setAbortOptions(pickAbortOptions(options));
909
954
  const returning = getOnConflictReturningFields(meta, data[0], uniqueFields, options);
910
955
  qb.insert(data)
911
956
  .onConflict(uniqueFields)
@@ -1051,7 +1096,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
1051
1096
  if (transform) {
1052
1097
  sql = transform(sql, params);
1053
1098
  }
1054
- const res = await this.rethrow(this.execute(sql, params, 'run', options.ctx, options.loggerContext));
1099
+ const res = await this.rethrow(this.execute(sql, params, 'run', options.ctx, withAbortContext(options.loggerContext, options)));
1055
1100
  for (let i = 0; i < collections.length; i++) {
1056
1101
  await this.processManyToMany(meta, where[i], collections[i], false, options);
1057
1102
  }
@@ -1069,6 +1114,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
1069
1114
  const qb = this.createQueryBuilder(entityName, options.ctx, 'write', false, options.loggerContext)
1070
1115
  .delete(where)
1071
1116
  .withSchema(this.getSchemaName(meta, options));
1117
+ qb.setAbortOptions(pickAbortOptions(options));
1072
1118
  return this.rethrow(qb.execute('run', false));
1073
1119
  }
1074
1120
  /**
@@ -1099,8 +1145,29 @@ export class AbstractSqlDriver extends DatabaseDriver {
1099
1145
  const pks = wrapped.getPrimaryKeys(true);
1100
1146
  const snap = coll.getSnapshot();
1101
1147
  const includes = (arr, item) => !!arr.find(i => this.comparePrimaryKeyArrays(i, item));
1102
- const snapshot = snap ? snap.map(item => helper(item).getPrimaryKeys(true)) : [];
1103
- const current = coll.getItems(false).map(item => helper(item).getPrimaryKeys(true));
1148
+ // For union-target polymorphic M:N, prepend the per-row discriminator value so the pivot
1149
+ // persister can write it alongside the FK id. Memoized per sync-run because a collection can
1150
+ // hold hundreds of items of the same few types, and findDiscriminatorValue walks the prototype
1151
+ // chain + re-scans Object.entries each call.
1152
+ const isUnionTargetMN = QueryHelper.isUnionTargetPolymorphic(coll.property);
1153
+ const classToDisc = new Map();
1154
+ const toDiff = (item) => {
1155
+ const keys = helper(item).getPrimaryKeys(true);
1156
+ if (!isUnionTargetMN) {
1157
+ return keys;
1158
+ }
1159
+ let disc = classToDisc.get(item.constructor);
1160
+ if (!classToDisc.has(item.constructor)) {
1161
+ disc = QueryHelper.findDiscriminatorValue(coll.property.discriminatorMap, item.constructor);
1162
+ if (disc === undefined) {
1163
+ throw new Error(`Cannot resolve discriminator value for ${item.constructor.name} in ${coll.property.name}; the class is not part of the union target list.`);
1164
+ }
1165
+ classToDisc.set(item.constructor, disc);
1166
+ }
1167
+ return [disc, ...keys];
1168
+ };
1169
+ const snapshot = snap ? snap.map(toDiff) : [];
1170
+ const current = coll.getItems(false).map(toDiff);
1104
1171
  const deleteDiff = snap ? snapshot.filter(item => !includes(current, item)) : true;
1105
1172
  const insertDiff = current.filter(item => !includes(snapshot, item));
1106
1173
  const target = snapshot.filter(item => includes(current, item)).concat(...insertDiff);
@@ -1118,6 +1185,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
1118
1185
  if (coll.property.kind === ReferenceKind.ONE_TO_MANY) {
1119
1186
  const cols = coll.property.referencedColumnNames;
1120
1187
  const qb = this.createQueryBuilder(coll.property.targetMeta.class, options?.ctx, 'write').withSchema(this.getSchemaName(meta, options));
1188
+ qb.setAbortOptions(pickAbortOptions(options));
1121
1189
  if (coll.getSnapshot() === undefined) {
1122
1190
  if (coll.property.orphanRemoval) {
1123
1191
  const query = qb
@@ -1159,7 +1227,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
1159
1227
  schema = this.config.get('schema');
1160
1228
  }
1161
1229
  const tableName = `${schema ?? '_'}.${pivotMeta.tableName}`;
1162
- const persister = (groups[tableName] ??= new PivotCollectionPersister(pivotMeta, this, options?.ctx, schema, options?.loggerContext));
1230
+ const persister = (groups[tableName] ??= new PivotCollectionPersister(pivotMeta, this, options?.ctx, schema, options?.loggerContext, pickAbortOptions(options)));
1163
1231
  persister.enqueueUpdate(coll.property, insertDiff, deleteDiff, pks, coll.isInitialized());
1164
1232
  }
1165
1233
  for (const persister of Utils.values(groups)) {
@@ -1172,21 +1240,16 @@ export class AbstractSqlDriver extends DatabaseDriver {
1172
1240
  return {};
1173
1241
  }
1174
1242
  const pivotMeta = this.metadata.get(prop.pivotEntity);
1243
+ if (prop.discriminatorColumn && QueryHelper.isUnionTargetPolymorphic(prop)) {
1244
+ return this.loadFromUnionTargetPolymorphicPivotTable(prop, owners, where, orderBy, ctx, options, pivotJoin);
1245
+ }
1175
1246
  if (prop.polymorphic && prop.discriminatorColumn && prop.discriminatorValue) {
1176
1247
  return this.loadFromPolymorphicPivotTable(prop, owners, where, orderBy, ctx, options, pivotJoin);
1177
1248
  }
1178
1249
  const pivotProp1 = pivotMeta.relations[prop.owner ? 1 : 0];
1179
1250
  const pivotProp2 = pivotMeta.relations[prop.owner ? 0 : 1];
1180
1251
  const ownerMeta = pivotProp2.targetMeta;
1181
- // The pivot query builder doesn't convert custom types, so we need to manually
1182
- // convert owner PKs to DB format for the query and convert result FKs back to
1183
- // JS format for consistent key hashing in buildPivotResultMap.
1184
- const pkProp = ownerMeta.properties[ownerMeta.primaryKeys[0]];
1185
- const needsConversion = pkProp?.customType?.ensureComparable(ownerMeta, pkProp) && !ownerMeta.compositePK;
1186
- let ownerPks = ownerMeta.compositePK ? owners : owners.map(o => o[0]);
1187
- if (needsConversion) {
1188
- ownerPks = ownerPks.map(v => pkProp.customType.convertToDatabaseValue(v, this.platform, { mode: 'query' }));
1189
- }
1252
+ const { ownerPks, needsConversion, pkProp } = this.convertOwnerPksForPivotQuery(owners, ownerMeta);
1190
1253
  const cond = {
1191
1254
  [pivotProp2.name]: { $in: ownerPks },
1192
1255
  };
@@ -1201,7 +1264,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
1201
1264
  const fields = pivotJoin
1202
1265
  ? [pivotProp1.name, pivotProp2.name]
1203
1266
  : [pivotProp1.name, pivotProp2.name, ...childFields];
1204
- const res = await this.find(pivotMeta.class, where, {
1267
+ const pivotFindOptions = {
1205
1268
  ctx,
1206
1269
  ...options,
1207
1270
  fields,
@@ -1217,10 +1280,13 @@ export class AbstractSqlDriver extends DatabaseDriver {
1217
1280
  },
1218
1281
  ],
1219
1282
  populateWhere: undefined,
1220
- // @ts-ignore
1221
1283
  _populateWhere: 'infer',
1222
1284
  populateFilter: this.wrapPopulateFilter(options, pivotProp2.name),
1223
- });
1285
+ };
1286
+ if (pivotFindOptions._partitionLimit) {
1287
+ pivotFindOptions._partitionLimit.partitionBy = pivotProp2.name;
1288
+ }
1289
+ const res = await this.find(pivotMeta.class, where, pivotFindOptions);
1224
1290
  // Convert result FK values back to JS format so key hashing
1225
1291
  // in buildPivotResultMap is consistent with the owner keys.
1226
1292
  if (needsConversion) {
@@ -1340,6 +1406,102 @@ export class AbstractSqlDriver extends DatabaseDriver {
1340
1406
  });
1341
1407
  return this.buildPivotResultMap(owners, res, tagProp.name, ownerRelationName);
1342
1408
  }
1409
+ /**
1410
+ * Load a union-target polymorphic M:N pivot (e.g. Post.attachments -> Image | Video).
1411
+ * Each pivot row's discriminator column selects which target table to hydrate.
1412
+ */
1413
+ async loadFromUnionTargetPolymorphicPivotTable(prop, owners, where = {}, orderBy, ctx, options = {}, pivotJoin) {
1414
+ // :ref hints cannot be honored for union-target — EntityLoader.getReference needs a concrete
1415
+ // class per item, but the ref-mode map only carries flat PK values and would hydrate every
1416
+ // row as the first polymorph target. Fail loudly instead of silently corrupting the collection.
1417
+ if (pivotJoin) {
1418
+ throw new Error(`The ':ref' populate hint is not supported for union-target polymorphic M:N on ${prop.name}. Use a regular populate hint instead.`);
1419
+ }
1420
+ const pivotMeta = this.metadata.get(prop.pivotEntity);
1421
+ const targets = prop.polymorphTargets;
1422
+ const ownerProp = pivotMeta.relations.find(r => r.persist !== false && !r.polymorphic);
1423
+ const discriminatorColumn = prop.discriminatorColumn;
1424
+ const ownerMeta = ownerProp.targetMeta;
1425
+ const { ownerPks, needsConversion, pkProp } = this.convertOwnerPksForPivotQuery(owners, ownerMeta);
1426
+ const pivotRows = (await this.find(pivotMeta.class, { [ownerProp.name]: { $in: ownerPks } }, {
1427
+ ctx,
1428
+ orderBy: this.getPivotOrderBy(prop, ownerProp, orderBy, options?.orderBy),
1429
+ fields: [ownerProp.name, discriminatorColumn, prop.discriminator],
1430
+ populateWhere: undefined,
1431
+ // @ts-ignore
1432
+ _populateWhere: 'infer',
1433
+ }));
1434
+ /* v8 ignore next 7 - custom-type PK conversion, tested via loadFromPivotTable path */
1435
+ if (needsConversion) {
1436
+ for (const item of pivotRows) {
1437
+ const fk = item[ownerProp.name];
1438
+ if (fk != null) {
1439
+ item[ownerProp.name] = pkProp.customType.convertToJSValue(fk, this.platform);
1440
+ }
1441
+ }
1442
+ }
1443
+ const classMeta = new Map(targets.map(t => [t.class, t]));
1444
+ const rowsByTarget = new Map();
1445
+ for (const row of pivotRows) {
1446
+ const discValue = row[discriminatorColumn];
1447
+ const targetClass = prop.discriminatorMap[discValue];
1448
+ const targetMeta = classMeta.get(targetClass);
1449
+ /* v8 ignore next 3 - defensive: unknown discriminator value */
1450
+ if (!targetMeta) {
1451
+ continue;
1452
+ }
1453
+ const list = rowsByTarget.get(targetMeta) ?? [];
1454
+ list.push(row);
1455
+ rowsByTarget.set(targetMeta, list);
1456
+ }
1457
+ // Strip the outer find's orderBy/fields/exclude before bulk-loading targets by PK — those apply
1458
+ // to the owner query, not each polymorph target (Image and Video wouldn't share an orderBy field).
1459
+ // populateFilter is a filter on the populated collection; since union-target splits the pivot
1460
+ // and target queries, we merge it into the target-level `where` instead of wrapping it on the
1461
+ // pivot query (where joins to target tables aren't available).
1462
+ // Hoisted above the loop since `options` doesn't change per target.
1463
+ const { orderBy: _o, fields: _f, exclude: _e, populateFilter, ...childOptions } = options;
1464
+ const populate = options.populate ?? [];
1465
+ const orphanedRows = new Set();
1466
+ for (const [targetMeta, rows] of rowsByTarget) {
1467
+ const targetIds = rows.map(r => r[prop.discriminator]);
1468
+ // Union-target pivot stores one scalar FK per row; composite-PK targets are rejected at
1469
+ // metadata validation time, so a single primary key column is guaranteed here.
1470
+ const pkCol = targetMeta.primaryKeys[0];
1471
+ let cond = { [pkCol]: { $in: targetIds } };
1472
+ if (!Utils.isEmpty(where)) {
1473
+ cond = { $and: [cond, where] };
1474
+ }
1475
+ if (!Utils.isEmpty(populateFilter)) {
1476
+ cond = { $and: [cond, populateFilter] };
1477
+ }
1478
+ const results = (await this.find(targetMeta.class, cond, {
1479
+ ctx,
1480
+ ...childOptions,
1481
+ populate: populate,
1482
+ }));
1483
+ const byPk = new Map();
1484
+ for (const row of results) {
1485
+ Object.defineProperty(row, 'constructor', {
1486
+ value: targetMeta.class,
1487
+ enumerable: false,
1488
+ configurable: true,
1489
+ });
1490
+ byPk.set(Utils.getPrimaryKeyHash([row[pkCol]]), row);
1491
+ }
1492
+ for (const row of rows) {
1493
+ const pkHash = Utils.getPrimaryKeyHash([row[prop.discriminator]]);
1494
+ const entity = byPk.get(pkHash);
1495
+ if (entity == null) {
1496
+ orphanedRows.add(row);
1497
+ continue;
1498
+ }
1499
+ row[prop.discriminator] = entity;
1500
+ }
1501
+ }
1502
+ const result = orphanedRows.size > 0 ? pivotRows.filter(r => !orphanedRows.has(r)) : pivotRows;
1503
+ return this.buildPivotResultMap(owners, result, ownerProp.name, prop.discriminator);
1504
+ }
1343
1505
  /**
1344
1506
  * Build a map from owner PKs to their related entities from pivot table results.
1345
1507
  */
@@ -1364,6 +1526,21 @@ export class AbstractSqlDriver extends DatabaseDriver {
1364
1526
  }
1365
1527
  return undefined;
1366
1528
  }
1529
+ /**
1530
+ * The pivot query builder doesn't convert custom types — manually convert owner PKs to the DB
1531
+ * representation. Returns `needsConversion` + `pkProp` so the caller can convert result FKs back
1532
+ * to JS format for consistent key hashing in `buildPivotResultMap`.
1533
+ */
1534
+ convertOwnerPksForPivotQuery(owners, ownerMeta) {
1535
+ const pkProp = ownerMeta.properties[ownerMeta.primaryKeys[0]];
1536
+ const needsConversion = !!pkProp?.customType?.ensureComparable(ownerMeta, pkProp) && !ownerMeta.compositePK;
1537
+ let ownerPks = ownerMeta.compositePK ? owners : owners.map(o => o[0]);
1538
+ /* v8 ignore next 4 - custom-type PK conversion, tested via loadFromPivotTable path */
1539
+ if (needsConversion) {
1540
+ ownerPks = ownerPks.map(v => pkProp.customType.convertToDatabaseValue(v, this.platform, { mode: 'query' }));
1541
+ }
1542
+ return { ownerPks, needsConversion, pkProp };
1543
+ }
1367
1544
  getPivotOrderBy(prop, pivotProp, orderBy, parentOrderBy) {
1368
1545
  if (!Utils.isEmpty(orderBy) || RawQueryFragment.hasObjectFragments(orderBy)) {
1369
1546
  return Utils.asArray(orderBy).map(o => ({ [pivotProp.name]: o }));
@@ -1430,6 +1607,12 @@ export class AbstractSqlDriver extends DatabaseDriver {
1430
1607
  if (ref && [ReferenceKind.ONE_TO_ONE, ReferenceKind.MANY_TO_ONE].includes(prop.kind)) {
1431
1608
  return true;
1432
1609
  }
1610
+ // Union-target polymorphic M:N cannot be loaded via a single JOIN because rows span multiple
1611
+ // target tables; fall through to SELECT_IN which dispatches through `loadFromPivotTable`.
1612
+ // Polymorphic M:1 (to-one with target_type discriminator) is handled via LEFT JOINs elsewhere.
1613
+ if (prop.kind === ReferenceKind.MANY_TO_MANY && QueryHelper.isUnionTargetPolymorphic(prop) && prop.owner) {
1614
+ return false;
1615
+ }
1433
1616
  // skip redundant joins for 1:1 owner population hints when using `mapToPk`
1434
1617
  if (prop.kind === ReferenceKind.ONE_TO_ONE && prop.mapToPk && prop.owner) {
1435
1618
  return false;
@@ -1544,6 +1727,13 @@ export class AbstractSqlDriver extends DatabaseDriver {
1544
1727
  const targetPath = `${pathPrefix}${basePath}[${targetMeta.className}]`;
1545
1728
  const schema = targetMeta.schema === '*' ? (options?.schema ?? this.config.get('schema')) : targetMeta.schema;
1546
1729
  qb.addPolymorphicJoin(prop, targetMeta, options.parentTableAlias, tableAlias, JoinType.leftJoin, targetPath, schema);
1730
+ // For polymorphic targets that are TPT child entities, INNER JOIN parent tables so that
1731
+ // filter conditions referencing parent-table columns resolve to the correct alias. The
1732
+ // INNER JOINs get nested inside the polymorphic LEFT JOIN by processNestedJoins, which
1733
+ // keeps the resulting query valid for rows pointing to other polymorphic targets.
1734
+ if (targetMeta.inheritanceType === 'tpt' && targetMeta.tptParent) {
1735
+ this.addTPTParentJoinsForRelation(qb, targetMeta, tableAlias, targetPath);
1736
+ }
1547
1737
  // For polymorphic targets that are TPT base classes, also LEFT JOIN
1548
1738
  // all descendant tables so child-specific fields can be selected.
1549
1739
  if (targetMeta.inheritanceType === 'tpt' && targetMeta.tptChildren?.length && !ref) {
@@ -1592,17 +1782,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
1592
1782
  qb.join(field, tableAlias, {}, joinType, path, schema);
1593
1783
  // For relations to TPT child entities, INNER JOIN parent tables (GH #7469)
1594
1784
  if (meta2.inheritanceType === 'tpt' && meta2.tptParent) {
1595
- let childAlias = tableAlias;
1596
- let childMeta = meta2;
1597
- while (childMeta.tptParent) {
1598
- const parentMeta = childMeta.tptParent;
1599
- const parentAlias = qb.getNextAlias(parentMeta.className);
1600
- qb.createAlias(parentMeta.class, parentAlias);
1601
- qb.state.tptAlias[`${tableAlias}:${parentMeta.className}`] = parentAlias;
1602
- qb.addPropertyJoin(childMeta.tptParentProp, childAlias, parentAlias, JoinType.innerJoin, `${path}.[tpt]${childMeta.className}`);
1603
- childAlias = parentAlias;
1604
- childMeta = parentMeta;
1605
- }
1785
+ this.addTPTParentJoinsForRelation(qb, meta2, tableAlias, path);
1606
1786
  }
1607
1787
  // For relations to TPT base classes, add LEFT JOINs for all child tables (polymorphic loading)
1608
1788
  if (meta2.inheritanceType === 'tpt' && meta2.tptChildren?.length && !ref) {
@@ -1650,6 +1830,25 @@ export class AbstractSqlDriver extends DatabaseDriver {
1650
1830
  }
1651
1831
  return fields;
1652
1832
  }
1833
+ /**
1834
+ * Walks the TPT inheritance chain of `leafMeta` and INNER JOINs each parent table.
1835
+ * Registers the parent aliases in `qb.state.tptAlias` so column resolution finds them
1836
+ * when filter conditions reference parent-table columns.
1837
+ * @internal
1838
+ */
1839
+ addTPTParentJoinsForRelation(qb, leafMeta, leafAlias, basePath) {
1840
+ let childAlias = leafAlias;
1841
+ let childMeta = leafMeta;
1842
+ while (childMeta.tptParent) {
1843
+ const parentMeta = childMeta.tptParent;
1844
+ const parentAlias = qb.getNextAlias(parentMeta.className);
1845
+ qb.createAlias(parentMeta.class, parentAlias);
1846
+ qb.state.tptAlias[`${leafAlias}:${parentMeta.className}`] = parentAlias;
1847
+ qb.addPropertyJoin(childMeta.tptParentProp, childAlias, parentAlias, JoinType.innerJoin, `${basePath}.[tpt]${childMeta.className}`);
1848
+ childAlias = parentAlias;
1849
+ childMeta = parentMeta;
1850
+ }
1851
+ }
1653
1852
  /**
1654
1853
  * Adds LEFT JOINs and fields for TPT polymorphic loading when populating a relation to a TPT base class.
1655
1854
  * @internal
@@ -1761,18 +1960,19 @@ export class AbstractSqlDriver extends DatabaseDriver {
1761
1960
  });
1762
1961
  }
1763
1962
  const aliased = this.platform.quoteIdentifier(`${tableAlias}__${prop.fieldNames[0]}`);
1963
+ const sourceAlias = qb.helper.getTPTAliasForProperty(prop.name, tableAlias);
1764
1964
  if (prop.customTypes?.some(type => !!type?.convertToJSValueSQL)) {
1765
1965
  return prop.fieldNames.map((col, idx) => {
1766
1966
  if (!prop.customTypes[idx]?.convertToJSValueSQL) {
1767
1967
  return col;
1768
1968
  }
1769
- const prefixed = this.platform.quoteIdentifier(`${tableAlias}.${col}`);
1969
+ const prefixed = this.platform.quoteIdentifier(`${sourceAlias}.${col}`);
1770
1970
  const aliased = this.platform.quoteIdentifier(`${tableAlias}__${col}`);
1771
1971
  return raw(`${prop.customTypes[idx].convertToJSValueSQL(prefixed, this.platform)} as ${aliased}`);
1772
1972
  });
1773
1973
  }
1774
1974
  if (prop.customType?.convertToJSValueSQL) {
1775
- const prefixed = this.platform.quoteIdentifier(`${tableAlias}.${prop.fieldNames[0]}`);
1975
+ const prefixed = this.platform.quoteIdentifier(`${sourceAlias}.${prop.fieldNames[0]}`);
1776
1976
  return [raw(`${prop.customType.convertToJSValueSQL(prefixed, this.platform)} as ${aliased}`)];
1777
1977
  }
1778
1978
  if (prop.formula) {
@@ -1781,7 +1981,6 @@ export class AbstractSqlDriver extends DatabaseDriver {
1781
1981
  const columns = meta.createColumnMappingObject(tableAlias);
1782
1982
  return [raw(`${this.evaluateFormula(prop.formula, columns, table)} as ${aliased}`)];
1783
1983
  }
1784
- const sourceAlias = qb.helper.getTPTAliasForProperty(prop.name, tableAlias);
1785
1984
  return prop.fieldNames.map(fieldName => {
1786
1985
  return raw('?? as ??', [`${sourceAlias}.${fieldName}`, `${tableAlias}__${fieldName}`]);
1787
1986
  });
@@ -1798,6 +1997,57 @@ export class AbstractSqlDriver extends DatabaseDriver {
1798
1997
  }
1799
1998
  return qb;
1800
1999
  }
2000
+ /**
2001
+ * Renders a `FilterQuery` predicate into a SQL fragment (without the `WHERE` keyword and
2002
+ * without table-alias prefixes) suitable for inlining into a partial-index DDL statement.
2003
+ * Used by `DatabaseTable.addIndex` when the user passes an object `where` on `@Index` /
2004
+ * `@Unique`. Strings are returned unchanged.
2005
+ */
2006
+ renderPartialIndexWhere(entityName, where) {
2007
+ if (typeof where === 'string') {
2008
+ return where;
2009
+ }
2010
+ const name = Utils.className(entityName);
2011
+ if (where == null || (Utils.isPlainObject(where) && Object.keys(where).length === 0)) {
2012
+ throw new Error(`Cannot render partial-index predicate for entity '${name}': \`where\` is empty.`);
2013
+ }
2014
+ const alias = '__p';
2015
+ const qb = this.createQueryBuilder(entityName, undefined, undefined, undefined, undefined, alias);
2016
+ qb.where(where);
2017
+ const sql = qb.getFormattedQuery();
2018
+ // Relation traversal produces join clauses whose aliased identifiers can't be inlined
2019
+ // into a CREATE INDEX ... WHERE clause — reject with a clear error rather than emitting broken DDL.
2020
+ if (/\bjoin\b/i.test(sql.split(/\bwhere\b/i)[0])) {
2021
+ throw new Error(`Cannot render partial-index predicate for entity '${name}': \`where\` may not traverse relations.`);
2022
+ }
2023
+ // Anchor at end-of-string only — the synthetic QB has no top-level order by / limit /
2024
+ // group by / having / offset, so any such keyword inside the captured predicate is
2025
+ // inside a subquery and must not terminate the match.
2026
+ const match = /\bwhere\s+([\s\S]+)$/i.exec(sql);
2027
+ if (!match) {
2028
+ throw new Error(`Failed to render partial-index predicate for entity '${name}': ${sql}`);
2029
+ }
2030
+ const quote = (s) => this.platform.quoteIdentifier(s);
2031
+ const aliasPrefix = new RegExp(`${quote(alias).replace(/[[\]]/g, '\\$&')}\\.`, 'g');
2032
+ const stripped = match[1].replace(aliasPrefix, '').trim();
2033
+ // Any qualified column reference remaining after the alias strip points at another table or
2034
+ // subquery and can't be inlined into a CREATE INDEX ... WHERE predicate. Covers both
2035
+ // QB-generated sub-aliases (quoted, e.g. `"e0"."col"`) and raw fragments with bare refs
2036
+ // (e.g. `raw('other_table.col = 1')`). String literals are erased first so dots inside
2037
+ // them (e.g. JSON path operands like `'$.path'`) don't trip the guard.
2038
+ // Both patterns use a `(?!\s*\()` lookahead so schema-qualified function calls
2039
+ // (`pg_catalog.lower(name)`, `"public".my_func(col)`) are accepted — only `<id>.<id>` not
2040
+ // followed by `(` is treated as a cross-table column reference.
2041
+ const withoutStrings = stripped.replace(/'(?:[^']|'')*'/g, "''");
2042
+ const quotedIdent = String.raw `(?:"(?:[^"]|"")+"|\`(?:[^\`]|\`\`)+\`|\[(?:[^\]]|\]\])+\])`;
2043
+ const anyIdent = `(?:${quotedIdent}|[A-Za-z_]\\w*)`;
2044
+ const quotedCrossRef = new RegExp(`${quotedIdent}\\s*\\.\\s*${anyIdent}(?!\\s*\\()`);
2045
+ const bareCrossRef = /\b[A-Za-z_]\w*\s*\.\s*[A-Za-z_]\w*\b(?!\s*\()/;
2046
+ if (quotedCrossRef.test(withoutStrings) || bareCrossRef.test(withoutStrings)) {
2047
+ throw new Error(`Cannot render partial-index predicate for entity '${name}': \`where\` references another table or subquery which cannot be inlined into a CREATE INDEX ... WHERE clause.`);
2048
+ }
2049
+ return stripped;
2050
+ }
1801
2051
  resolveConnectionType(args) {
1802
2052
  if (args.ctx) {
1803
2053
  return 'write';
@@ -1824,7 +2074,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
1824
2074
  for (const prop of meta.relations) {
1825
2075
  if (collections[prop.name]) {
1826
2076
  const pivotMeta = this.metadata.get(prop.pivotEntity);
1827
- const persister = new PivotCollectionPersister(pivotMeta, this, options?.ctx, options?.schema, options?.loggerContext);
2077
+ const persister = new PivotCollectionPersister(pivotMeta, this, options?.ctx, options?.schema, options?.loggerContext, pickAbortOptions(options));
1828
2078
  persister.enqueueUpdate(prop, collections[prop.name], clear, pks);
1829
2079
  await this.rethrow(persister.execute());
1830
2080
  }
@@ -1833,6 +2083,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
1833
2083
  async lockPessimistic(entity, options) {
1834
2084
  const meta = helper(entity).__meta;
1835
2085
  const qb = this.createQueryBuilder(meta.class, options.ctx, undefined, undefined, options.logging).withSchema(options.schema ?? meta.schema);
2086
+ qb.setAbortOptions(pickAbortOptions(options));
1836
2087
  const cond = Utils.getPrimaryKeyCond(entity, meta.primaryKeys);
1837
2088
  qb.select(raw('1'))
1838
2089
  .where(cond)
@@ -30,7 +30,8 @@ export declare abstract class AbstractSqlPlatform extends Platform {
30
30
  getSearchJsonPropertyKey(path: string[], type: string, aliased: boolean, value?: unknown): string | RawQueryFragment;
31
31
  /**
32
32
  * Quotes a key for use inside a JSON path expression (e.g. `$.key`).
33
- * Simple alphanumeric keys are left unquoted; others are wrapped in double quotes.
33
+ * Simple alphanumeric keys are left unquoted; others are wrapped in double quotes
34
+ * with embedded `\` and `"` escaped per the JSON path string syntax.
34
35
  * @internal
35
36
  */
36
37
  quoteJsonKey(key: string): string;
@@ -50,8 +51,19 @@ export declare abstract class AbstractSqlPlatform extends Platform {
50
51
  * @internal
51
52
  */
52
53
  quoteCollation(collation: string): string;
53
- /** @internal */
54
- protected validateCollationName(collation: string): void;
54
+ /**
55
+ * PG ICU locale names include hyphens (`en-US-x-icu`) and libc locales include dots (`en_US.utf8`),
56
+ * so word-chars alone would reject valid real-world collations.
57
+ * @internal
58
+ */
59
+ validateCollationName(collation: string): void;
60
+ /**
61
+ * Whether collation names compare case-insensitively in this dialect. MySQL/MariaDB, MSSQL, and
62
+ * SQLite use case-insensitive collation identifiers; PostgreSQL stores them as case-sensitive
63
+ * names in `pg_collation` (e.g. `en-US-x-icu` is distinct from `EN-US-X-ICU`).
64
+ * @internal
65
+ */
66
+ caseInsensitiveCollationNames(): boolean;
55
67
  /** @internal */
56
68
  validateJsonPropertyName(name: string): void;
57
69
  /**
@@ -66,18 +66,23 @@ export class AbstractSqlPlatform extends Platform {
66
66
  }
67
67
  getSearchJsonPropertyKey(path, type, aliased, value) {
68
68
  const [a, ...b] = path;
69
+ const jsonPath = this.quoteValue(`$.${b.map(this.quoteJsonKey).join('.')}`);
69
70
  if (aliased) {
70
- return raw(alias => `json_extract(${this.quoteIdentifier(`${alias}.${a}`)}, '$.${b.map(this.quoteJsonKey).join('.')}')`);
71
+ return raw(alias => `json_extract(${this.quoteIdentifier(`${alias}.${a}`)}, ${jsonPath})`);
71
72
  }
72
- return raw(`json_extract(${this.quoteIdentifier(a)}, '$.${b.map(this.quoteJsonKey).join('.')}')`);
73
+ return raw(`json_extract(${this.quoteIdentifier(a)}, ${jsonPath})`);
73
74
  }
74
75
  /**
75
76
  * Quotes a key for use inside a JSON path expression (e.g. `$.key`).
76
- * Simple alphanumeric keys are left unquoted; others are wrapped in double quotes.
77
+ * Simple alphanumeric keys are left unquoted; others are wrapped in double quotes
78
+ * with embedded `\` and `"` escaped per the JSON path string syntax.
77
79
  * @internal
78
80
  */
79
81
  quoteJsonKey(key) {
80
- return /^[a-z]\w*$/i.exec(key) ? key : `"${key}"`;
82
+ if (/^[a-z]\w*$/i.test(key)) {
83
+ return key;
84
+ }
85
+ return `"${key.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
81
86
  }
82
87
  getJsonIndexDefinition(index) {
83
88
  return index.columnNames.map(column => {
@@ -123,12 +128,25 @@ export class AbstractSqlPlatform extends Platform {
123
128
  this.validateCollationName(collation);
124
129
  return this.quoteIdentifier(collation);
125
130
  }
126
- /** @internal */
131
+ /**
132
+ * PG ICU locale names include hyphens (`en-US-x-icu`) and libc locales include dots (`en_US.utf8`),
133
+ * so word-chars alone would reject valid real-world collations.
134
+ * @internal
135
+ */
127
136
  validateCollationName(collation) {
128
- if (!/^[\w]+$/.test(collation)) {
129
- throw new Error(`Invalid collation name: '${collation}'. Collation names must contain only word characters.`);
137
+ if (!/^[\w\-.]+$/.test(collation)) {
138
+ throw new Error(`Invalid collation name: '${collation}'. Collation names must contain only word characters, hyphens, and dots.`);
130
139
  }
131
140
  }
141
+ /**
142
+ * Whether collation names compare case-insensitively in this dialect. MySQL/MariaDB, MSSQL, and
143
+ * SQLite use case-insensitive collation identifiers; PostgreSQL stores them as case-sensitive
144
+ * names in `pg_collation` (e.g. `en-US-x-icu` is distinct from `EN-US-X-ICU`).
145
+ * @internal
146
+ */
147
+ caseInsensitiveCollationNames() {
148
+ return true;
149
+ }
132
150
  /** @internal */
133
151
  validateJsonPropertyName(name) {
134
152
  if (!AbstractSqlPlatform.#JSON_PROPERTY_NAME_RE.test(name)) {