@mikro-orm/core 7.2.2-dev.9 → 7.2.2

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.
@@ -580,6 +580,11 @@ export declare class EntityManager<Driver extends IDatabaseDriver = IDatabaseDri
580
580
  * Checks whether this EntityManager is currently operating inside a database transaction.
581
581
  */
582
582
  isInTransaction(): boolean;
583
+ /**
584
+ * Checks whether transactions are disabled for this EntityManager.
585
+ * @internal
586
+ */
587
+ isTransactionDisabled(): boolean;
583
588
  /**
584
589
  * Gets the transaction context (driver dependent object used to make sure queries are executed on same connection).
585
590
  */
@@ -633,7 +638,7 @@ export declare class EntityManager<Driver extends IDatabaseDriver = IDatabaseDri
633
638
  /**
634
639
  * @internal
635
640
  */
636
- tryCache<T extends object, R>(entityName: EntityName<T>, config: boolean | number | [string, number] | undefined, key: unknown, refresh?: boolean, merge?: boolean): Promise<{
641
+ tryCache<T extends object, R>(entityName: EntityName<T>, config: boolean | number | [string, number] | undefined, key: unknown, refresh?: boolean, merge?: boolean, schema?: string): Promise<{
637
642
  data?: R | null;
638
643
  key: string;
639
644
  } | undefined>;
package/EntityManager.js CHANGED
@@ -133,7 +133,7 @@ export class EntityManager {
133
133
  options.populate = (await em.preparePopulate(entityName, options));
134
134
  const populate = options.populate;
135
135
  const cacheKey = em.cacheKey(entityName, options, 'em.find', where);
136
- const cached = await em.tryCache(entityName, options.cache, cacheKey, options.refresh, true);
136
+ const cached = await em.tryCache(entityName, options.cache, cacheKey, options.refresh, true, options.schema);
137
137
  if (cached?.data) {
138
138
  await em.#entityLoader.populate(entityName, cached.data, populate, {
139
139
  ...options,
@@ -789,6 +789,10 @@ export class EntityManager {
789
789
  .getHydrator(this.metadata)
790
790
  .hydrate(ref, helper(ref).__meta, data, em.#entityFactory, 'full', false, false);
791
791
  Utils.merge(helper(ref).__originalEntityData, this.#comparator.prepareEntity(e));
792
+ // index a refreshed `targetKey` value, `getByKey` ignores the old one as stale
793
+ if (helper(ref).__meta.root.targetKeys) {
794
+ em.#unitOfWork.getIdentityMap().store(ref);
795
+ }
792
796
  found ||= ref === entity;
793
797
  }
794
798
  if (!found) {
@@ -834,7 +838,7 @@ export class EntityManager {
834
838
  validateParams(where);
835
839
  options.populate = (await em.preparePopulate(entityName, options));
836
840
  const cacheKey = em.cacheKey(entityName, options, 'em.findOne', where);
837
- const cached = await em.tryCache(entityName, options.cache, cacheKey, options.refresh, true);
841
+ const cached = await em.tryCache(entityName, options.cache, cacheKey, options.refresh, true, options.schema);
838
842
  if (cached?.data !== undefined) {
839
843
  if (cached.data) {
840
844
  await em.#entityLoader.populate(entityName, [cached.data], options.populate, {
@@ -1110,7 +1114,18 @@ export class EntityManager {
1110
1114
  const ret = [];
1111
1115
  for (const idx of shapes.values()) {
1112
1116
  const res = await this.upsertMany(entityName, idx.map(i => data[i]), options);
1113
- idx.forEach((i, j) => (ret[i] = res[j]));
1117
+ const seen = new Set();
1118
+ let j = 0;
1119
+ // `res` holds every entity once, so skip the rows resolving to an entity an earlier row already took
1120
+ for (const i of idx) {
1121
+ const row = data[i];
1122
+ const entity = Utils.isEntity(row)
1123
+ ? row
1124
+ : em.#unitOfWork.tryGetById(entityName, row, options.schema, false);
1125
+ if (!seen.has(entity)) {
1126
+ seen.add((ret[i] = res[j++]));
1127
+ }
1128
+ }
1114
1129
  }
1115
1130
  // rows resolving to the same entity are returned once, as in the single statement path
1116
1131
  return [...new Set(ret.filter(entity => entity))];
@@ -1123,9 +1138,10 @@ export class EntityManager {
1123
1138
  const generatedFields = new Set();
1124
1139
  const allData = [];
1125
1140
  const allWhere = [];
1141
+ const dataIndexes = [];
1142
+ const result = [];
1126
1143
  const entities = new Map();
1127
1144
  const entitiesByData = new Map();
1128
- const entitiesByAllDataIdx = new Map();
1129
1145
  for (let i = 0; i < data.length; i++) {
1130
1146
  let row = data[i];
1131
1147
  let where;
@@ -1133,14 +1149,12 @@ export class EntityManager {
1133
1149
  const entity = row;
1134
1150
  if (helper(entity).__managed && helper(entity).__em === em && !this.config.get('upsertManaged')) {
1135
1151
  em.#entityFactory.mergeData(meta, entity, row, { initialized: true });
1136
- entities.set(entity, row);
1137
- entitiesByData.set(row, entity);
1152
+ result[i] = entity;
1138
1153
  continue;
1139
1154
  }
1140
1155
  where = helper(entity).getPrimaryKey();
1141
1156
  getOnCreateGeneratedFields(meta, entity).forEach(field => generatedFields.add(field));
1142
1157
  em.#entityFactory.assignDefaultValues(entity, meta);
1143
- entitiesByAllDataIdx.set(allData.length, entity);
1144
1158
  row = em.#comparator.prepareEntity(entity);
1145
1159
  }
1146
1160
  else {
@@ -1150,8 +1164,7 @@ export class EntityManager {
1150
1164
  const exists = em.#unitOfWork.getById(entityName, where, options.schema);
1151
1165
  if (exists) {
1152
1166
  em.assign(exists, row);
1153
- entities.set(exists, row);
1154
- entitiesByData.set(row, exists);
1167
+ result[i] = exists;
1155
1168
  continue;
1156
1169
  }
1157
1170
  }
@@ -1184,22 +1197,25 @@ export class EntityManager {
1184
1197
  validateParams(row, 'insert data');
1185
1198
  allData.push(row);
1186
1199
  allWhere.push(where);
1200
+ dataIndexes.push(i);
1187
1201
  }
1188
- if (entities.size === data.length) {
1189
- return [...entities.keys()];
1202
+ if (allData.length === 0) {
1203
+ return [...new Set(result)];
1190
1204
  }
1191
1205
  // `onCreate` generated values are for the insert clause only, they must not overwrite existing rows
1192
1206
  if (generatedFields.size > 0 && !options.onConflictMergeFields) {
1193
1207
  options.onConflictExcludeFields = [...(options.onConflictExcludeFields ?? []), ...generatedFields];
1194
1208
  }
1195
1209
  if (em.eventManager.hasListeners(EventType.beforeUpsert, meta)) {
1196
- for (const dto of data) {
1197
- const entity = entitiesByData.get(dto) ?? dto;
1210
+ for (const index of dataIndexes) {
1211
+ const entity = data[index];
1198
1212
  await em.eventManager.dispatchEvent(EventType.beforeUpsert, { entity, em, meta }, meta);
1199
1213
  }
1200
- for (const [idx, entity] of entitiesByAllDataIdx) {
1201
- allData[idx] = em.#comparator.prepareEntity(entity);
1202
- }
1214
+ dataIndexes.forEach((index, idx) => {
1215
+ if (Utils.isEntity(data[index])) {
1216
+ allData[idx] = em.#comparator.prepareEntity(data[index]);
1217
+ }
1218
+ });
1203
1219
  }
1204
1220
  const res = await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => em.driver.nativeUpdateMany(entityName, allWhere, allData, {
1205
1221
  upsert: true,
@@ -1207,15 +1223,16 @@ export class EntityManager {
1207
1223
  ...options,
1208
1224
  ctx,
1209
1225
  }));
1210
- entities.clear();
1211
- entitiesByData.clear();
1212
1226
  const loadPK = new Map();
1213
1227
  allData.forEach((row, i) => {
1214
- em.#unitOfWork.getChangeSetPersister().mapReturnedValues(Utils.isEntity(data[i]) ? data[i] : null, Utils.isEntity(data[i]) ? {} : data[i],
1228
+ // Managed inputs skipped by the SQL batch still occupy positions in `data`.
1229
+ const index = dataIndexes[i];
1230
+ const item = data[index];
1231
+ em.#unitOfWork.getChangeSetPersister().mapReturnedValues(Utils.isEntity(item) ? item : null, Utils.isEntity(item) ? {} : item,
1215
1232
  // A suppressed conflict omits its row; positional mapping would hydrate a different entity.
1216
1233
  res.rows?.length === allData.length ? res.rows[i] : undefined, meta, true);
1217
- const entity = Utils.isEntity(data[i])
1218
- ? data[i]
1234
+ const entity = Utils.isEntity(item)
1235
+ ? item
1219
1236
  : em.#entityFactory.create(entityName, row, {
1220
1237
  refresh: true,
1221
1238
  initialized: true,
@@ -1226,18 +1243,19 @@ export class EntityManager {
1226
1243
  }
1227
1244
  entities.set(entity, row);
1228
1245
  entitiesByData.set(row, entity);
1246
+ result[index] = entity;
1229
1247
  });
1230
1248
  // skip the reload only when RETURNING brought back a row for every input
1231
1249
  // (onConflictWhere can suppress writes, leaving some rows out)
1232
1250
  // oxfmt-ignore
1233
1251
  const uniqueFields = options.onConflictFields ?? (Utils.isPlainObject(allWhere[0]) ? Object.keys(allWhere[0]).flatMap(key => Utils.splitPrimaryKeys(key)) : meta.primaryKeys);
1234
1252
  const platform = this.getPlatform();
1235
- const returning = getOnConflictReturningFields(meta, data[0], uniqueFields, options, platform);
1253
+ const returning = getOnConflictReturningFields(meta, data[dataIndexes[0]], uniqueFields, options, platform);
1236
1254
  if (options.onConflictWhere && res.rows?.length !== allData.length) {
1237
1255
  returning.push(...meta.comparableProps.filter(p => !p.lazy && !p.embeddable).map(p => p.name));
1238
1256
  }
1239
1257
  const reloadFields = returning.length > 0 &&
1240
- !((platform.usesReturningStatement() || platform.usesOutputStatement()) && res.rows?.length === data.length);
1258
+ !((platform.usesReturningStatement() || platform.usesOutputStatement()) && res.rows?.length === allData.length);
1241
1259
  if (options.onConflictAction === 'ignore' || (!res.rows?.length && loadPK.size > 0) || reloadFields) {
1242
1260
  const unique = meta.hydrateProps.filter(p => !p.lazy).map(p => p.name);
1243
1261
  const add = new Set(propIndex !== false && propIndex >= 0
@@ -1247,7 +1265,8 @@ export class EntityManager {
1247
1265
  Utils.keys(cond).forEach(key => add.add(key));
1248
1266
  }
1249
1267
  const where = { $or: [] };
1250
- data.forEach((item, idx) => {
1268
+ dataIndexes.forEach((index, idx) => {
1269
+ const item = data[index];
1251
1270
  where.$or[idx] = {};
1252
1271
  const props = Array.isArray(uniqueFields) ? uniqueFields : Object.keys(item);
1253
1272
  props.forEach(prop => {
@@ -1317,7 +1336,7 @@ export class EntityManager {
1317
1336
  await em.eventManager.dispatchEvent(EventType.afterUpsert, { entity, em, meta }, meta);
1318
1337
  }
1319
1338
  }
1320
- return [...entities.keys()];
1339
+ return [...new Set(result)];
1321
1340
  }
1322
1341
  /**
1323
1342
  * Runs your callback wrapped inside a database transaction.
@@ -1974,6 +1993,13 @@ export class EntityManager {
1974
1993
  isInTransaction() {
1975
1994
  return !!this.getContext(false).#transactionContext;
1976
1995
  }
1996
+ /**
1997
+ * Checks whether transactions are disabled for this EntityManager.
1998
+ * @internal
1999
+ */
2000
+ isTransactionDisabled() {
2001
+ return !!this.getContext(false).#disableTransactions;
2002
+ }
1977
2003
  /**
1978
2004
  * Gets the transaction context (driver dependent object used to make sure queries are executed on same connection).
1979
2005
  */
@@ -2329,7 +2355,7 @@ export class EntityManager {
2329
2355
  /**
2330
2356
  * @internal
2331
2357
  */
2332
- async tryCache(entityName, config, key, refresh, merge) {
2358
+ async tryCache(entityName, config, key, refresh, merge, schema) {
2333
2359
  config ??= this.config.get('resultCache').global;
2334
2360
  if (!config) {
2335
2361
  return undefined;
@@ -2351,6 +2377,7 @@ export class EntityManager {
2351
2377
  merge: true,
2352
2378
  convertCustomTypes: false,
2353
2379
  refresh,
2380
+ schema,
2354
2381
  recomputeSnapshot: true,
2355
2382
  };
2356
2383
  if (Array.isArray(cached) && merge) {
@@ -51,6 +51,7 @@ export declare class EntityFactory {
51
51
  assignDefaultValues<T extends object>(entity: T, meta: EntityMetadata<T>, onCreateOnly?: boolean): void;
52
52
  private hydrate;
53
53
  private findEntity;
54
+ private findEntityByPrimaryKey;
54
55
  private processDiscriminatorColumn;
55
56
  /**
56
57
  * denormalize PK to value required by driver (e.g. ObjectId)
@@ -55,6 +55,10 @@ export class EntityFactory {
55
55
  Utils.dropUndefinedProperties(data);
56
56
  this.mergeData(meta2, exists, data, options);
57
57
  wrapped.__processing = false;
58
+ // index a merged `targetKey` value, `getByKey` ignores the old one as stale
59
+ if (meta2.root.targetKeys) {
60
+ this.unitOfWork.getIdentityMap().store(exists);
61
+ }
58
62
  wrapped.__initialized ||= !!options.initialized;
59
63
  if (wrapped.isInitialized()) {
60
64
  if (!wasInitialized) {
@@ -232,9 +236,9 @@ export class EntityFactory {
232
236
  options.convertCustomTypes ??= true;
233
237
  const meta = this.#metadata.get(entityName);
234
238
  const schema = this.#driver.getSchemaName(meta, options);
235
- // Handle alternate key lookup
236
- if (options.key) {
237
- const value = '' + (Array.isArray(id) ? id[0] : Utils.isPlainObject(id) ? id[options.key] : id);
239
+ // Handle alternate key lookup, a `key` pointing at the single PK is a plain PK reference
240
+ if (options.key && !(meta.primaryKeys.length === 1 && meta.primaryKeys[0] === options.key)) {
241
+ const value = Array.isArray(id) ? id[0] : Utils.isPlainObject(id) ? id[options.key] : id;
238
242
  const exists = this.unitOfWork.getByKey(entityName, options.key, value, schema, options.convertCustomTypes);
239
243
  if (exists) {
240
244
  return exists;
@@ -242,6 +246,8 @@ export class EntityFactory {
242
246
  // Create entity stub - storeByKey will set the alternate key property and store in identity map
243
247
  const entity = this.create(entityName, {}, { ...options, initialized: false });
244
248
  this.unitOfWork.storeByKey(entity, options.key, value, schema, options.convertCustomTypes);
249
+ // snapshot the stub like a PK reference, so flush treats it as existing instead of inserting it
250
+ helper(entity).__originalEntityData = this.#comparator.prepareEntity(entity);
245
251
  return entity;
246
252
  }
247
253
  if (meta.simplePK) {
@@ -310,6 +316,12 @@ export class EntityFactory {
310
316
  if (options.merge && !options.newEntity) {
311
317
  this.#hydrator.hydrateReference(entity, meta, data, this, options.convertCustomTypes, options.schema, options.parentSchema);
312
318
  this.unitOfWork.register(entity);
319
+ // relations hydrated together with this entity (joined strategy) resolve it via `targetKey` before it is hydrated
320
+ for (const key of meta.root.targetKeys ?? []) {
321
+ if (data[key] != null) {
322
+ this.unitOfWork.storeByKey(entity, key, data[key], schema, options.convertCustomTypes);
323
+ }
324
+ }
313
325
  }
314
326
  if (options.initialized) {
315
327
  EntityHelper.ensurePropagation(entity);
@@ -379,6 +391,23 @@ export class EntityFactory {
379
391
  }
380
392
  findEntity(data, meta, options) {
381
393
  const schema = this.#driver.getSchemaName(meta, options);
394
+ const exists = this.findEntityByPrimaryKey(data, meta, schema, options);
395
+ // a new entity must not adopt a reference stub, that one is never inserted
396
+ if (exists || Array.isArray(data) || options.newEntity) {
397
+ return exists;
398
+ }
399
+ // a reference created via `targetKey` is only known by that key until the entity is loaded
400
+ for (const key of meta.root.targetKeys ?? []) {
401
+ if (data[key] != null) {
402
+ const ref = this.unitOfWork.getByKey(meta.class, key, data[key], schema, options.convertCustomTypes);
403
+ if (ref && !helper(ref).__initialized) {
404
+ return ref;
405
+ }
406
+ }
407
+ }
408
+ return undefined;
409
+ }
410
+ findEntityByPrimaryKey(data, meta, schema, options) {
382
411
  if (meta.simplePK) {
383
412
  return this.unitOfWork.getById(meta.class, data[meta.primaryKeys[0]], schema);
384
413
  }
@@ -197,9 +197,9 @@ export class EntityLoader {
197
197
  this.initializeCollections(filtered, prop, field, items, customOrder, isPartial, hasLimit);
198
198
  return items;
199
199
  }
200
- async populateScalar(meta, filtered, options) {
201
- const pk = Utils.getPrimaryKeyHash(meta.primaryKeys);
202
- const ids = Utils.unique(filtered.map(e => Utils.getPrimaryKeyValues(e, meta, true)));
200
+ async populateScalar(meta, filtered, options, key) {
201
+ const pk = (key ?? Utils.getPrimaryKeyHash(meta.primaryKeys));
202
+ const ids = Utils.unique(filtered.map(e => (key ? e[key] : Utils.getPrimaryKeyValues(e, meta, true))));
203
203
  const where = this.mergePrimaryCondition(ids, pk, options, meta, this.#metadata, this.#driver.getPlatform());
204
204
  const { filters, convertCustomTypes, lockMode, strategy, populateWhere, connectionType, logging, fields } = options;
205
205
  await this.#em.find(meta.class, where, {
@@ -221,7 +221,8 @@ export class EntityLoader {
221
221
  const needsFkLoad = [];
222
222
  for (const entity of entities) {
223
223
  const refValue = entity[prop.name];
224
- if (refValue && helper(refValue).hasPrimaryKey()) {
224
+ // a `targetKey` reference is identified by its key, it might not have the PK yet
225
+ if (refValue && helper(refValue).getTargetKeyValue(prop.targetKey) != null) {
225
226
  if ((ref && !options.refresh) || // :ref hint - already have reference
226
227
  (!ref && helper(refValue).__initialized && !options.refresh) // already loaded
227
228
  ) {
@@ -246,7 +247,7 @@ export class EntityLoader {
246
247
  if (!ref) {
247
248
  for (const entity of needsFkLoad) {
248
249
  const refValue = entity[prop.name];
249
- if (refValue && helper(refValue).hasPrimaryKey()) {
250
+ if (refValue && helper(refValue).getTargetKeyValue(prop.targetKey) != null) {
250
251
  toPopulate.push(entity);
251
252
  }
252
253
  }
@@ -268,7 +269,7 @@ export class EntityLoader {
268
269
  const allItems = [];
269
270
  await Promise.all([...groups].map(async ([discriminator, children]) => {
270
271
  const targetMeta = this.#metadata.find(prop.discriminatorMap[discriminator]);
271
- await this.populateScalar(targetMeta, children, options);
272
+ await this.populateScalar(targetMeta, children, options, prop.targetKey);
272
273
  allItems.push(...children);
273
274
  }));
274
275
  return allItems;
@@ -83,7 +83,12 @@ export class WrappedEntity {
83
83
  if (!this.__em) {
84
84
  throw ValidationError.entityNotManaged(this.entity);
85
85
  }
86
- return this.__em.findOne(this.entity.constructor, this.entity, {
86
+ // a reference known only by its `targetKey` has no PK, so look it up by the key instead
87
+ const key = this.hasPrimaryKey()
88
+ ? undefined
89
+ : this.__meta.root.targetKeys?.find(k => this.entity[k] != null);
90
+ const where = key ? { [key]: this.entity[key] } : this.entity;
91
+ return this.__em.findOne(this.entity.constructor, where, {
87
92
  ...options,
88
93
  refresh: true,
89
94
  schema: this.__schema,
package/errors.d.ts CHANGED
@@ -10,6 +10,7 @@ export declare class ValidationError<T extends AnyEntity = AnyEntity> extends Er
10
10
  static fromWrongPropertyType(entity: AnyEntity, property: string, expectedType: string, givenType: string, givenValue: string): ValidationError;
11
11
  static fromWrongRepositoryType(entityName: string, repoType: string, method: string): ValidationError;
12
12
  static fromMergeWithoutPK(meta: EntityMetadata): ValidationError;
13
+ static referenceWithoutPK(entity: AnyEntity): ValidationError;
13
14
  static transactionRequired(): ValidationError;
14
15
  static entityNotManaged(entity: AnyEntity): ValidationError;
15
16
  static notEntity(owner: AnyEntity, prop: EntityProperty, data: any): ValidationError;
package/errors.js CHANGED
@@ -28,6 +28,9 @@ export class ValidationError extends Error {
28
28
  static fromMergeWithoutPK(meta) {
29
29
  return new ValidationError(`You cannot merge entity '${meta.className}' without identifier!`);
30
30
  }
31
+ static referenceWithoutPK(entity) {
32
+ return new ValidationError(`Entity '${entity.constructor.name}' is only known by an alternate key and has no primary key, load it first`, entity);
33
+ }
31
34
  static transactionRequired() {
32
35
  return new ValidationError('An open transaction is required for this operation');
33
36
  }
@@ -207,7 +207,7 @@ export class ObjectHydrator extends Hydrator {
207
207
  if (prop.customType?.ensureComparable(meta, prop)) {
208
208
  registerCustomType(prop, this.safeKey(prop.name), 'convertToDatabaseValue', context);
209
209
  ret.push(` if (data${dataKey} != null && typeof data${dataKey} !== 'object' && convertCustomTypes) {`);
210
- ret.push(` data${dataKey} = convertToDatabaseValue_${this.safeKey(prop.name)}(entity${entityKey}.__helper.getPrimaryKey());`);
210
+ ret.push(` data${dataKey} = convertToDatabaseValue_${this.safeKey(prop.name)}(entity${entityKey}.__helper.getTargetKeyValue(${prop.targetKey ? this.quote(prop.targetKey) : ''}));`);
211
211
  ret.push(` }`);
212
212
  }
213
213
  return ret;
@@ -407,7 +407,7 @@ export class ObjectHydrator extends Hydrator {
407
407
  const code = `// compiled hydrator for entity ${meta.className} (${type + normalizeAccessors ? ' normalized' : ''})\n` +
408
408
  `return function(entity, data, factory, newEntity, convertCustomTypes, schema, parentSchema, normalizeAccessors) {\n` +
409
409
  `${lines.join('\n')}\n}`;
410
- const fnKey = `hydrator-${meta.uniqueName}-${type}-${normalizeAccessors}`;
410
+ const fnKey = `hydrator-${meta.compiledName}-${type}-${normalizeAccessors}`;
411
411
  const hydrator = Utils.createFunction(context, code, this.config.get('compiledFunctions'), fnKey);
412
412
  this.#hydrators[key].set(meta.class, hydrator);
413
413
  return hydrator;
@@ -547,7 +547,7 @@ export class MetadataDiscovery {
547
547
  const fieldNames1 = prop.targetMeta.getPrimaryProps().flatMap(pk => pk.fieldNames);
548
548
  const idColumns = fieldNames1.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.discriminator, fieldName, fieldNames1.length > 1));
549
549
  prop.fieldNames ??= [prop.discriminatorColumn, ...idColumns];
550
- prop.joinColumns ??= idColumns;
550
+ prop.joinColumns ??= prop.fieldNames.slice(1);
551
551
  prop.referencedColumnNames ??= fieldNames1;
552
552
  prop.referencedTableName ??= prop.targetMeta.tableName;
553
553
  return;
@@ -564,7 +564,10 @@ export class MetadataDiscovery {
564
564
  }
565
565
  Utils.defaultValue(prop, 'referencedTableName', meta2.tableName);
566
566
  if (!prop.joinColumns) {
567
- prop.joinColumns = fieldNames.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.name, fieldName, fieldNames.length > 1));
567
+ // must match the real FK columns, which ignore `targetKey` naming for simple PK targets
568
+ prop.joinColumns = prop.targetKey
569
+ ? prop.fieldNames
570
+ : fieldNames.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.name, fieldName, fieldNames.length > 1));
568
571
  }
569
572
  if (!prop.referencedColumnNames) {
570
573
  prop.referencedColumnNames = fieldNames;
@@ -670,6 +673,12 @@ export class MetadataDiscovery {
670
673
  if (prop2.kind !== ReferenceKind.SCALAR && prop2.type === meta.className) {
671
674
  meta.referencingProperties.push({ meta: meta2, prop: prop2 });
672
675
  }
676
+ if (prop2.targetKey &&
677
+ (prop2.type === meta.className || prop2.polymorphTargets?.some(t => t.className === meta.className)) &&
678
+ !(meta.primaryKeys.length === 1 && meta.primaryKeys[0] === prop2.targetKey) &&
679
+ !meta.root.targetKeys?.includes(prop2.targetKey)) {
680
+ (meta.root.targetKeys ??= []).push(prop2.targetKey);
681
+ }
673
682
  }
674
683
  }
675
684
  }
@@ -1174,12 +1183,24 @@ export class MetadataDiscovery {
1174
1183
  if (!prop.discriminator && !prop.discriminatorColumn && !prop.discriminatorMap && !Array.isArray(prop.target)) {
1175
1184
  return;
1176
1185
  }
1186
+ const isToOne = [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind);
1187
+ const isUnionTargetMN = prop.kind === ReferenceKind.MANY_TO_MANY && Array.isArray(prop.target);
1177
1188
  prop.polymorphic = true;
1178
1189
  prop.discriminator ??= prop.name;
1190
+ // explicit `fieldNames` use the `[discriminatorColumn, ...fkIdColumns]` layout
1191
+ if (isToOne && prop.fieldNames && prop.fieldNames.length > 1) {
1192
+ prop.discriminatorColumn ??= prop.fieldNames[0];
1193
+ // `EntitySchema` mirrors `fieldNames` into `joinColumns`, let `initManyToOneFields` derive them after embedded prefixing
1194
+ if (Utils.equals(prop.joinColumns, prop.fieldNames)) {
1195
+ delete prop.joinColumns;
1196
+ }
1197
+ }
1179
1198
  prop.discriminatorColumn ??= this.#namingStrategy.discriminatorColumnName(prop.discriminator);
1180
1199
  prop.createForeignKeyConstraint = false;
1181
- const isToOne = [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind);
1182
- const isUnionTargetMN = prop.kind === ReferenceKind.MANY_TO_MANY && Array.isArray(prop.target);
1200
+ // a single field name can only be the FK column, e.g. mirrored from `joinColumn`
1201
+ if (isToOne && prop.fieldNames?.length === 1) {
1202
+ prop.fieldNames = [prop.discriminatorColumn, ...prop.fieldNames];
1203
+ }
1183
1204
  if (isToOne || isUnionTargetMN) {
1184
1205
  const types = prop.type.split(/ ?\| ?/);
1185
1206
  prop.polymorphTargets = discovered.filter(m => types.includes(m.className) && !m.embeddable);
@@ -1439,7 +1460,8 @@ export class MetadataDiscovery {
1439
1460
  }
1440
1461
  }
1441
1462
  rootProp.stiFieldNameMap[meta.discriminatorValue] = prop.fieldNames[0];
1442
- rootProp.stiFieldNames.push(...prop.fieldNames);
1463
+ // subtypes that only differ in type share the column
1464
+ rootProp.stiFieldNames = Utils.unique([...rootProp.stiFieldNames, ...prop.fieldNames]);
1443
1465
  newProp.nullable = true;
1444
1466
  newProp.name = name;
1445
1467
  newProp.hydrate = false;
@@ -32,6 +32,13 @@ export declare class MetadataStorage {
32
32
  getById<T>(id: number): EntityMetadata<T>;
33
33
  /** Returns metadata by class name, optionally throwing if not found. */
34
34
  getByClassName<T = any, V extends boolean = true>(className: string, validate?: V): V extends true ? EntityMetadata<T> : EntityMetadata<T> | undefined;
35
+ /**
36
+ * Returns the first registered metadata with the given table name, in registration order (entities sharing
37
+ * a table, like STI children, resolve to the one registered first). The index is built on first use, as table
38
+ * names are only final once discovery has finished.
39
+ * @internal
40
+ */
41
+ getByTableName<T = any>(tableName: string): EntityMetadata<T> | undefined;
35
42
  /** Returns metadata by unique name, optionally throwing if not found. */
36
43
  getByUniqueName<T = any, V extends boolean = true>(uniqueName: string, validate?: V): V extends true ? EntityMetadata<T> : EntityMetadata<T> | undefined;
37
44
  private validate;
@@ -18,6 +18,8 @@ export class MetadataStorage {
18
18
  #classNameMap;
19
19
  #uniqueNameMap;
20
20
  #ambiguousNames = new Set();
21
+ /** Built on first use by `getByTableName()`, dropped whenever an entity is registered or removed. */
22
+ #tableNameMap;
21
23
  constructor(metadata = {}) {
22
24
  this.#idMap = {};
23
25
  this.#uniqueNameMap = {};
@@ -102,6 +104,7 @@ export class MetadataStorage {
102
104
  /** Registers metadata for the given entity. */
103
105
  set(entityName, meta) {
104
106
  this.#metadataMap.set(entityName, meta);
107
+ this.#tableNameMap = undefined;
105
108
  this.#idMap[meta._id] = meta;
106
109
  this.#uniqueNameMap[meta.uniqueName] = meta;
107
110
  const className = Utils.className(entityName);
@@ -110,6 +113,12 @@ export class MetadataStorage {
110
113
  if (existing && existing !== meta && existing.class !== meta.class) {
111
114
  this.#ambiguousNames.add(className);
112
115
  }
116
+ // colliding names are numbered in storage order, which follows the discovery config, not module evaluation
117
+ const sameName = this.#ambiguousNames.has(className)
118
+ ? [...new Set(this.#metadataMap.values())].filter(m => m.className === meta.className)
119
+ : [meta];
120
+ const ordinal = sameName.indexOf(meta);
121
+ meta.compiledName = ordinal > 0 ? `${meta.className}#${ordinal}` : meta.className;
113
122
  this.#classNameMap[className] = meta;
114
123
  return meta;
115
124
  }
@@ -118,6 +127,7 @@ export class MetadataStorage {
118
127
  const meta = this.find(entityName);
119
128
  if (meta) {
120
129
  this.#metadataMap.delete(meta.class);
130
+ this.#tableNameMap = undefined;
121
131
  delete this.#idMap[meta._id];
122
132
  delete this.#uniqueNameMap[meta.uniqueName];
123
133
  delete this.#classNameMap[meta.className];
@@ -145,6 +155,23 @@ export class MetadataStorage {
145
155
  getByClassName(className, validate = true) {
146
156
  return this.validate(this.#classNameMap[className], className, validate);
147
157
  }
158
+ /**
159
+ * Returns the first registered metadata with the given table name, in registration order (entities sharing
160
+ * a table, like STI children, resolve to the one registered first). The index is built on first use, as table
161
+ * names are only final once discovery has finished.
162
+ * @internal
163
+ */
164
+ getByTableName(tableName) {
165
+ if (!this.#tableNameMap) {
166
+ this.#tableNameMap = new Map();
167
+ for (const meta of this.#metadataMap.values()) {
168
+ if (!this.#tableNameMap.has(meta.tableName)) {
169
+ this.#tableNameMap.set(meta.tableName, meta);
170
+ }
171
+ }
172
+ }
173
+ return this.#tableNameMap.get(tableName);
174
+ }
148
175
  /** Returns metadata by unique name, optionally throwing if not found. */
149
176
  getByUniqueName(uniqueName, validate = true) {
150
177
  return this.validate(this.#uniqueNameMap[uniqueName], uniqueName, validate);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/core",
3
- "version": "7.2.2-dev.9",
3
+ "version": "7.2.2",
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",
package/typings.d.ts CHANGED
@@ -1173,6 +1173,7 @@ export interface EntityMetadata<Entity = any, Class extends EntityCtor<Entity> =
1173
1173
  meta: EntityMetadata<Entity>;
1174
1174
  prop: EntityProperty<Entity>;
1175
1175
  }[];
1176
+ targetKeys?: EntityKey<Entity>[];
1176
1177
  comparableProps: EntityProperty<Entity>[];
1177
1178
  trackingProps: EntityProperty<Entity>[];
1178
1179
  hydrateProps: EntityProperty<Entity>[];
@@ -1254,6 +1255,8 @@ export interface EntityMetadata<Entity = any, Class extends EntityCtor<Entity> =
1254
1255
  orderBy?: QueryOrderMap<Entity> | QueryOrderMap<Entity>[];
1255
1256
  /** @internal can be used for computed numeric cache keys */
1256
1257
  readonly _id: number;
1258
+ /** @internal name that does not depend on module evaluation order (unlike `_id`), used to key `compiledFunctions` */
1259
+ compiledName?: string;
1257
1260
  }
1258
1261
  /** Options for `ISchemaGenerator.create()`. */
1259
1262
  export interface CreateSchemaOptions {
@@ -7,6 +7,7 @@ import { Reference } from '../entity/Reference.js';
7
7
  import { PolymorphicRef } from '../entity/PolymorphicRef.js';
8
8
  import { ReferenceKind } from '../enums.js';
9
9
  import { isRaw } from '../utils/RawQueryFragment.js';
10
+ import { ValidationError } from '../errors.js';
10
11
  /** @internal Computes change sets by comparing entity state against original snapshots. */
11
12
  export class ChangeSetComputer {
12
13
  #comparator;
@@ -48,6 +49,10 @@ export class ChangeSetComputer {
48
49
  if (Utils.equals(data, wrapped.__originalEntityData)) {
49
50
  return null;
50
51
  }
52
+ // a reference known only by its `targetKey` has no PK, so the update would have no condition
53
+ if (!wrapped.hasPrimaryKey()) {
54
+ throw ValidationError.referenceWithoutPK(entity);
55
+ }
51
56
  }
52
57
  const changeSet = new ChangeSet(entity, type, this.computePayload(entity), meta);
53
58
  changeSet.originalEntity = wrapped.__originalEntityData;
@@ -162,21 +167,30 @@ export class ChangeSetComputer {
162
167
  const target = Reference.unwrapReference(rawTarget);
163
168
  const needsProcessing = target != null && (prop.targetKey != null || !target.__helper.hasPrimaryKey());
164
169
  if (needsProcessing) {
170
+ // a reference known only by its `targetKey` has no PK, and is never inserted to generate one
171
+ if (!prop.targetKey && !target.__helper.__initialized) {
172
+ throw ValidationError.referenceWithoutPK(target);
173
+ }
165
174
  let value = prop.targetKey ? target[prop.targetKey] : target.__helper.__identifier;
166
- /* v8 ignore next */
167
- if (prop.targetKey && prop.targetMeta) {
168
- const targetProp = prop.targetMeta.properties[prop.targetKey];
175
+ // use the actual target's meta, a polymorphic relation has several
176
+ if (prop.targetKey) {
177
+ const targetProp = target.__meta.properties[prop.targetKey];
169
178
  if (targetProp?.customType) {
170
179
  value = targetProp.customType.convertToDatabaseValue(value, this.#platform, { mode: 'serialization' });
171
180
  }
172
181
  }
173
182
  if (prop.polymorphic) {
174
183
  const discriminator = QueryHelper.findDiscriminatorValue(prop.discriminatorMap, target.constructor);
175
- Utils.setPayloadProperty(changeSet.payload, changeSet.meta, prop, new PolymorphicRef(discriminator, value), idx);
184
+ value = new PolymorphicRef(discriminator, value);
176
185
  }
177
- else {
178
- Utils.setPayloadProperty(changeSet.payload, changeSet.meta, prop, value, idx);
186
+ // an unchanged `targetKey` FK is not part of the diff
187
+ if (prop.targetKey &&
188
+ changeSet.type === ChangeSetType.UPDATE &&
189
+ !(prop.name in changeSet.payload) &&
190
+ Utils.equals(changeSet.originalEntity?.[prop.name], value)) {
191
+ return;
179
192
  }
193
+ Utils.setPayloadProperty(changeSet.payload, changeSet.meta, prop, value, idx);
180
194
  }
181
195
  });
182
196
  }
@@ -207,7 +207,7 @@ export class ChangeSetPersister {
207
207
  const keys = meta.getOwnConcurrencyCheckKeys();
208
208
  for (const key of keys) {
209
209
  cond[key] = changeSet.originalEntity[key];
210
- if (changeSet.payload[key]) {
210
+ if (Object.hasOwn(changeSet.payload, key)) {
211
211
  tmp.push(key);
212
212
  }
213
213
  }
@@ -322,17 +322,19 @@ export class ChangeSetPersister {
322
322
  }
323
323
  // skip entity references as they don't have version values loaded
324
324
  changeSets = changeSets.filter(cs => helper(cs.entity).__initialized);
325
- const primaryKeys = meta.primaryKeys.concat(...concurrencyCheckKeys);
325
+ const fields = meta.primaryKeys.concat(...concurrencyCheckKeys);
326
326
  const $or = changeSets.map(cs => {
327
- const cond = Utils.getPrimaryKeyCond(cs.originalEntity, primaryKeys);
327
+ const cond = Utils.getPrimaryKeyCond(cs.originalEntity, meta.primaryKeys);
328
+ for (const key of concurrencyCheckKeys) {
329
+ cond[key] = cs.originalEntity[key];
330
+ }
328
331
  if (meta.ownsVersionProperty()) {
329
- // @ts-ignore
330
332
  cond[meta.versionProperty] = this.#platform.convertVersionValue(cs.entity[meta.versionProperty], meta.properties[meta.versionProperty]);
331
333
  }
332
334
  return cond;
333
335
  });
334
336
  options = this.prepareOptions(meta, options, {
335
- fields: primaryKeys,
337
+ fields,
336
338
  orderBy: meta.primaryKeys.reduce((o, pk) => {
337
339
  o[pk] = 'asc';
338
340
  return o;
@@ -344,8 +346,8 @@ export class ChangeSetPersister {
344
346
  if (res.length !== changeSets.length) {
345
347
  // a FK pointing to a composite PK is an array, so the values need to be compared deeply
346
348
  const compare = (a, b, keys) => keys.every(k => equals(a[k], b[k]));
347
- const entity = changeSets.find(cs => {
348
- return !res.some(row => compare(Utils.getPrimaryKeyCond(cs.entity, primaryKeys), row, primaryKeys));
349
+ const entity = changeSets.find((_, index) => {
350
+ return !res.some(row => compare($or[index], row, meta.primaryKeys));
349
351
  }).entity;
350
352
  throw OptimisticLockError.lockFailed(entity);
351
353
  }
@@ -1,9 +1,9 @@
1
1
  import type { AnyEntity, EntityMetadata } from '../typings.js';
2
- /** @internal Stores managed entity instances keyed by their primary key hash, ensuring each row is loaded once. */
2
+ /** @internal Stores managed entity instances keyed by their primary key hash (and `targetKey` values), ensuring each row is loaded once. */
3
3
  export declare class IdentityMap {
4
4
  #private;
5
5
  constructor(defaultSchema?: string);
6
- /** Stores an entity in the identity map under its primary key hash. */
6
+ /** Stores an entity in the identity map under its primary key hash and the `targetKey` values referencing it. */
7
7
  store<T>(item: T): void;
8
8
  /**
9
9
  * Stores an entity under an alternate key (non-PK property).
@@ -25,6 +25,7 @@ export declare class IdentityMap {
25
25
  /** Returns all entities currently in the identity map. */
26
26
  values(): AnyEntity[];
27
27
  [Symbol.iterator](): IterableIterator<AnyEntity>;
28
+ private getEntities;
28
29
  /** Returns all hash keys currently in the identity map. */
29
30
  keys(): string[];
30
31
  /**
@@ -1,15 +1,29 @@
1
- /** @internal Stores managed entity instances keyed by their primary key hash, ensuring each row is loaded once. */
1
+ /** @internal Stores managed entity instances keyed by their primary key hash (and `targetKey` values), ensuring each row is loaded once. */
2
2
  export class IdentityMap {
3
3
  #defaultSchema;
4
4
  #registry = new Map();
5
5
  /** Tracks alternate key hashes for each entity so we can clean them up on delete */
6
6
  #alternateKeys = new WeakMap();
7
+ #storesWithAlternateKeys = new WeakSet();
7
8
  constructor(defaultSchema) {
8
9
  this.#defaultSchema = defaultSchema;
9
10
  }
10
- /** Stores an entity in the identity map under its primary key hash. */
11
+ /** Stores an entity in the identity map under its primary key hash and the `targetKey` values referencing it. */
11
12
  store(item) {
12
- this.getStore(item.__meta.root).set(this.getPkHash(item), item);
13
+ const meta = item.__meta.root;
14
+ const wrapped = item.__helper;
15
+ // PK-less references (known only by a `targetKey`) would share one slot that outlives their later removal
16
+ if (!meta.targetKeys || wrapped.hasPrimaryKey()) {
17
+ this.getStore(meta).set(this.getPkHash(item), item);
18
+ }
19
+ // references resolved via `targetKey` look the entity up by that key instead of the PK
20
+ for (const key of meta.targetKeys ?? []) {
21
+ const value = item[key];
22
+ if (value != null) {
23
+ const schema = wrapped.__schema ?? meta.schema ?? this.#defaultSchema;
24
+ this.storeByKey(item, key, '' + value, schema);
25
+ }
26
+ }
13
27
  }
14
28
  /**
15
29
  * Stores an entity under an alternate key (non-PK property).
@@ -17,7 +31,9 @@ export class IdentityMap {
17
31
  */
18
32
  storeByKey(item, key, value, schema) {
19
33
  const hash = this.getKeyHash(key, value, schema);
20
- this.getStore(item.__meta.root).set(hash, item);
34
+ const store = this.getStore(item.__meta.root);
35
+ store.set(hash, item);
36
+ this.#storesWithAlternateKeys.add(store);
21
37
  // Track this alternate key so we can clean it up when the entity is deleted
22
38
  let keys = this.#alternateKeys.get(item);
23
39
  if (!keys) {
@@ -35,7 +51,10 @@ export class IdentityMap {
35
51
  const altKeys = this.#alternateKeys.get(item);
36
52
  if (altKeys) {
37
53
  for (const hash of altKeys) {
38
- store.delete(hash);
54
+ // the hash might be owned by another entity by now, e.g. after swapping unique values
55
+ if (store.get(hash) === item) {
56
+ store.delete(hash);
57
+ }
39
58
  }
40
59
  this.#alternateKeys.delete(item);
41
60
  }
@@ -69,7 +88,7 @@ export class IdentityMap {
69
88
  values() {
70
89
  const ret = [];
71
90
  for (const store of this.#registry.values()) {
72
- for (const item of store.values()) {
91
+ for (const item of this.getEntities(store)) {
73
92
  ret.push(item);
74
93
  }
75
94
  }
@@ -77,11 +96,15 @@ export class IdentityMap {
77
96
  }
78
97
  *[Symbol.iterator]() {
79
98
  for (const store of this.#registry.values()) {
80
- for (const item of store.values()) {
99
+ for (const item of this.getEntities(store)) {
81
100
  yield item;
82
101
  }
83
102
  }
84
103
  }
104
+ getEntities(store) {
105
+ // an entity indexed by alternate keys occupies several slots of its store
106
+ return this.#storesWithAlternateKeys.has(store) ? new Set(store.values()) : store.values();
107
+ }
85
108
  /** Returns all hash keys currently in the identity map. */
86
109
  keys() {
87
110
  const ret = [];
@@ -89,7 +89,10 @@ export class UnitOfWork {
89
89
  Utils.isPlainObject(data[prop.name])) {
90
90
  // Skip polymorphic relations - they use PolymorphicRef wrapper
91
91
  if (!prop.polymorphic) {
92
- data[prop.name] = Utils.getPrimaryKeyValues(data[prop.name], prop.targetMeta, true);
92
+ // with `targetKey`, the FK holds that value instead of the PK
93
+ data[prop.name] = prop.targetKey
94
+ ? data[prop.name][prop.targetKey]
95
+ : Utils.getPrimaryKeyValues(data[prop.name], prop.targetMeta, true);
93
96
  }
94
97
  }
95
98
  else if (prop.kind === ReferenceKind.EMBEDDED && !prop.object && Utils.isPlainObject(data[prop.name])) {
@@ -205,7 +208,18 @@ export class UnitOfWork {
205
208
  value = prop.customType.convertToJSValue(value, this.#platform, { mode: 'hydration' });
206
209
  }
207
210
  const hash = this.#identityMap.getKeyHash(key, '' + value, schema);
208
- return this.#identityMap.getByHash(meta, hash);
211
+ const entity = this.#identityMap.getByHash(meta, hash);
212
+ // the entry is stale when the key changed via refresh or merge, unless only the unflushed value differs
213
+ if (entity && '' + entity[key] !== '' + value) {
214
+ // the snapshot holds the database value
215
+ const dbValue = prop?.customType
216
+ ? prop.customType.convertToDatabaseValue(value, this.#platform, { mode: 'serialization' })
217
+ : value;
218
+ if ('' + helper(entity).__originalEntityData?.[key] !== '' + dbValue) {
219
+ return undefined;
220
+ }
221
+ }
222
+ return entity;
209
223
  }
210
224
  /**
211
225
  * Stores an entity in the identity map under an alternate key (non-PK property).
@@ -368,6 +382,10 @@ export class UnitOfWork {
368
382
  }
369
383
  /** Marks an entity for removal, cascading to related entities. */
370
384
  remove(entity, visited, options = {}) {
385
+ // a reference known only by its `targetKey` has no PK, so the delete would match nothing
386
+ if (!helper(entity).__initialized && !helper(entity).hasPrimaryKey()) {
387
+ throw ValidationError.referenceWithoutPK(entity);
388
+ }
371
389
  // allow removing not managed entities if they are not part of the persist stack
372
390
  if (helper(entity).__managed || !this.#persistStack.has(entity)) {
373
391
  this.#removeStack.add(entity);
@@ -467,7 +485,10 @@ export class UnitOfWork {
467
485
  }
468
486
  const groups = this.getChangeSetGroups();
469
487
  const platform = this.#em.getPlatform();
470
- const runInTransaction = !this.#em.isInTransaction() && platform.supportsTransactions() && this.#em.config.get('implicitTransactions');
488
+ const runInTransaction = !this.#em.isInTransaction() &&
489
+ !this.#em.isTransactionDisabled() &&
490
+ platform.supportsTransactions() &&
491
+ this.#em.config.get('implicitTransactions');
471
492
  if (runInTransaction) {
472
493
  const loggerContext = Utils.merge({ id: this.#em._id }, this.#em.getLoggerContext({ disableContextResolution: true }));
473
494
  await this.#em.getConnection('write').transactional(trx => this.persistToDatabase(groups, trx), {
@@ -1115,6 +1136,11 @@ export class UnitOfWork {
1115
1136
  for (const changeSet of changeSets) {
1116
1137
  const wrapped = helper(changeSet.entity);
1117
1138
  wrapped.__originalEntityData = this.#comparator.prepareEntity(changeSet.entity);
1139
+ // re-index so the entity is found by its new `targetKey` values instead of the old ones
1140
+ if (changeSet.meta.root.targetKeys?.some(key => key in changeSet.payload)) {
1141
+ this.#identityMap.delete(changeSet.entity);
1142
+ this.#identityMap.store(changeSet.entity);
1143
+ }
1118
1144
  if (!wrapped.__initialized) {
1119
1145
  for (const prop of changeSet.meta.relations) {
1120
1146
  if ([ReferenceKind.MANY_TO_MANY, ReferenceKind.ONE_TO_MANY].includes(prop.kind) &&
@@ -95,7 +95,7 @@ export class EntityComparator {
95
95
  lines.push(` return entity${this.wrap(pk)};`);
96
96
  }
97
97
  const code = `// compiled pk getter for entity ${meta.className}\n` + `return function(entity) {\n${lines.join('\n')}\n}`;
98
- const fnKey = `pkGetter-${meta.uniqueName}`;
98
+ const fnKey = `pkGetter-${meta.compiledName}`;
99
99
  const pkSerializer = Utils.createFunction(context, code, this.#config?.get('compiledFunctions'), fnKey);
100
100
  this.#pkGetters.set(meta, pkSerializer);
101
101
  return pkSerializer;
@@ -147,7 +147,7 @@ export class EntityComparator {
147
147
  }
148
148
  const code = `// compiled pk getter (with converted custom types) for entity ${meta.className}\n` +
149
149
  `return function(entity) {\n${lines.join('\n')}\n}`;
150
- const fnKey = `pkGetterConverted-${meta.uniqueName}`;
150
+ const fnKey = `pkGetterConverted-${meta.compiledName}`;
151
151
  const pkSerializer = Utils.createFunction(context, code, this.#config?.get('compiledFunctions'), fnKey);
152
152
  this.#pkGettersConverted.set(meta, pkSerializer);
153
153
  return pkSerializer;
@@ -206,7 +206,7 @@ export class EntityComparator {
206
206
  }
207
207
  }
208
208
  const code = `// compiled pk serializer for entity ${meta.className}\n` + `return function(entity) {\n${lines.join('\n')}\n}`;
209
- const fnKey = `pkSerializer-${meta.uniqueName}`;
209
+ const fnKey = `pkSerializer-${meta.compiledName}`;
210
210
  const pkSerializer = Utils.createFunction(context, code, this.#config?.get('compiledFunctions'), fnKey);
211
211
  this.#pkSerializers.set(meta, pkSerializer);
212
212
  return pkSerializer;
@@ -236,7 +236,7 @@ export class EntityComparator {
236
236
  })
237
237
  .forEach(prop => lines.push(this.getPropertySnapshot(meta, prop, context, this.wrap(prop.name), this.wrap(prop.name), [prop.name])));
238
238
  const code = `return function(entity) {\n const ret = {};\n${lines.join('\n')}\n return ret;\n}`;
239
- const fnKey = `snapshotGenerator-${meta.uniqueName}`;
239
+ const fnKey = `snapshotGenerator-${meta.compiledName}`;
240
240
  const snapshotGenerator = Utils.createFunction(context, code, this.#config?.get('compiledFunctions'), fnKey);
241
241
  this.#snapshotGenerators.set(meta, snapshotGenerator);
242
242
  return snapshotGenerator;
@@ -350,14 +350,27 @@ export class EntityComparator {
350
350
  if (prop.embedded && (meta.embeddable || meta.properties[prop.embedded[0]].object)) {
351
351
  continue;
352
352
  }
353
+ let defined = `typeof ${this.propName(prop.fieldNames[0])} !== 'undefined'`;
354
+ // STI subtypes with conflicting columns share the property, so another subtype's null column must not override it
355
+ if (prop.renamedFrom) {
356
+ lines.push(`${padding} ${this.propName(prop.fieldNames[0], 'mapped')} = true;`);
357
+ defined = `${this.propName(prop.fieldNames[0])} != null`;
358
+ // a column shared with a differently typed subtype is left for each subtype's hydrator to convert
359
+ if (meta.props.some(p => p !== prop && p.name === prop.name && p.fieldNames?.[0] === prop.fieldNames[0])) {
360
+ lines.push(`${padding} if (${defined}) {`);
361
+ lines.push(`${padding} ret${this.wrap(prop.name)} = ${this.propName(prop.fieldNames[0])};`);
362
+ lines.push(`${padding} }`);
363
+ continue;
364
+ }
365
+ }
353
366
  if (prop.runtimeType === 'boolean') {
354
- lines.push(`${padding} if (typeof ${this.propName(prop.fieldNames[0])} !== 'undefined') {`);
367
+ lines.push(`${padding} if (${defined}) {`);
355
368
  lines.push(`${padding} ret${this.wrap(prop.name)} = ${this.propName(prop.fieldNames[0])} == null ? ${this.propName(prop.fieldNames[0])} : !!${this.propName(prop.fieldNames[0])};`);
356
369
  lines.push(`${padding} ${this.propName(prop.fieldNames[0], 'mapped')} = true;`);
357
370
  lines.push(`${padding} }`);
358
371
  }
359
372
  else if (prop.runtimeType === 'Date' && !this.#platform.isNumericProperty(prop)) {
360
- lines.push(`${padding} if (typeof ${this.propName(prop.fieldNames[0])} !== 'undefined') {`);
373
+ lines.push(`${padding} if (${defined}) {`);
361
374
  context.set('parseDate', (value) => this.#platform.parseDate(value));
362
375
  parseDate('ret' + this.wrap(prop.name), this.propName(prop.fieldNames[0]), padding);
363
376
  lines.push(`${padding} ${this.propName(prop.fieldNames[0], 'mapped')} = true;`);
@@ -372,13 +385,13 @@ export class EntityComparator {
372
385
  }
373
386
  return item == null ? item : this.getResultMapper(prop.targetMeta)(item);
374
387
  });
375
- lines.push(`${padding} if (typeof ${this.propName(prop.fieldNames[0])} !== 'undefined') {`);
388
+ lines.push(`${padding} if (${defined}) {`);
376
389
  lines.push(`${padding} ret${this.wrap(prop.name)} = ${this.propName(prop.fieldNames[0])} == null ? ${this.propName(prop.fieldNames[0])} : mapEmbeddedResult_${idx}(${this.propName(prop.fieldNames[0])});`);
377
390
  lines.push(`${padding} ${this.propName(prop.fieldNames[0], 'mapped')} = true;`);
378
391
  lines.push(`${padding} }`);
379
392
  }
380
393
  else if (prop.kind !== ReferenceKind.EMBEDDED) {
381
- lines.push(`${padding} if (typeof ${this.propName(prop.fieldNames[0])} !== 'undefined') {`);
394
+ lines.push(`${padding} if (${defined}) {`);
382
395
  lines.push(`${padding} ret${this.wrap(prop.name)} = ${this.propName(prop.fieldNames[0])};`);
383
396
  lines.push(`${padding} ${this.propName(prop.fieldNames[0], 'mapped')} = true;`);
384
397
  lines.push(`${padding} }`);
@@ -420,7 +433,7 @@ export class EntityComparator {
420
433
  lines.push(` for (let k in result) { if (Object.hasOwn(result, k) && !mapped[k] && ret[k] === undefined${knownKeysGuard}) ret[k] = result[k]; }`);
421
434
  const code = `// compiled mapper for entity ${meta.className}\n` +
422
435
  `return function(result) {\n const ret = {};\n${lines.join('\n')}\n return ret;\n}`;
423
- const fnKey = `resultMapper-${meta.uniqueName}`;
436
+ const fnKey = `resultMapper-${meta.compiledName}`;
424
437
  const resultMapper = Utils.createFunction(context, code, this.#config?.get('compiledFunctions'), fnKey);
425
438
  this.#mappers.set(meta, resultMapper);
426
439
  return resultMapper;
@@ -608,9 +621,24 @@ export class EntityComparator {
608
621
  ret += ` if (discriminator !== undefined) break;\n`;
609
622
  ret += ` __cls${level} = Object.getPrototypeOf(__cls${level});\n`;
610
623
  ret += ` }\n`;
611
- ret += ` const pk = val${level}?.__helper?.__identifier && !val${level}?.__helper?.hasPrimaryKey()\n`;
612
- ret += ` ? val${level}.__helper.__identifier\n`;
613
- ret += ` : toArray(val${level}?.__helper?.getPrimaryKey(true));\n`;
624
+ if (prop.targetKey) {
625
+ // the FK holds the `targetKey` value in the format of the actual target's column
626
+ const targetKey = prop.targetKey;
627
+ const targetKeyValueKey = `targetKeyValue_${this.safeKey(prop.name)}`;
628
+ context.set(targetKeyValueKey, (val) => {
629
+ const customType = val.__meta.properties[targetKey]?.customType;
630
+ const value = val[targetKey];
631
+ return customType
632
+ ? customType.convertToDatabaseValue(value, this.#platform, { mode: 'serialization' })
633
+ : value;
634
+ });
635
+ ret += ` const pk = ${targetKeyValueKey}(val${level});\n`;
636
+ }
637
+ else {
638
+ ret += ` const pk = val${level}?.__helper?.__identifier && !val${level}?.__helper?.hasPrimaryKey()\n`;
639
+ ret += ` ? val${level}.__helper.__identifier\n`;
640
+ ret += ` : toArray(val${level}?.__helper?.getPrimaryKey(true));\n`;
641
+ }
614
642
  ret += ` ret${dataKey} = new PolymorphicRef(discriminator, pk);\n`;
615
643
  ret += ` }\n`;
616
644
  }
@@ -624,8 +652,8 @@ export class EntityComparator {
624
652
  ret += ` } else if (typeof entity${entityKey} !== 'undefined') {\n`;
625
653
  ret += ` const val${level} = entity${entityKey}${unwrap};\n`;
626
654
  if (targetProp?.customType) {
627
- // If targetKey property has a custom type, convert to database value
628
- const convertorKey = this.registerCustomType(targetProp, context);
655
+ // keyed by the relation name to avoid clashing with a same-named owner prop, the relation itself lacks the custom type with an explicit `columnType`
656
+ const convertorKey = this.registerCustomType({ ...prop, customType: targetProp.customType }, context);
629
657
  ret += ` ret${dataKey} = convertToDatabaseValue_${convertorKey}(val${level}${targetAccess});\n`;
630
658
  }
631
659
  else {
@@ -688,7 +716,7 @@ export class EntityComparator {
688
716
  lines.push(`}`);
689
717
  const code = `// compiled comparator for entity ${meta.className}\n` +
690
718
  `return function(last, current, options) {\n const diff = {};\n${lines.join('\n')}\n return diff;\n}`;
691
- const fnKey = `comparator-${meta.uniqueName}`;
719
+ const fnKey = `comparator-${meta.compiledName}`;
692
720
  const comparator = Utils.createFunction(context, code, this.#config?.get('compiledFunctions'), fnKey);
693
721
  this.#comparators.set(meta, comparator);
694
722
  return comparator;
@@ -35,6 +35,12 @@ export declare class QueryHelper {
35
35
  */
36
36
  static liftGroupOperators<T extends object>(where: Dictionary, meta: EntityMetadata<T>, metadata: MetadataStorage, key?: string): string | undefined;
37
37
  static inlinePrimaryKeyObjects<T extends object>(where: Dictionary, meta: EntityMetadata<T>, metadata: MetadataStorage, key?: string): boolean;
38
+ /**
39
+ * A bare value on a polymorphic relation would be compared to the discriminator column, so PK values are turned
40
+ * into a PK condition instead, e.g. `1` becomes `{ id: 1 }` and `[{ id: 1 }, { id: 2 }]` becomes `{ id: { $in: [1, 2] } }`.
41
+ * Null checks and `[type, id]` tuples keep their meaning.
42
+ */
43
+ private static inlinePolymorphicPrimaryKeys;
38
44
  /** Copies the plain object and array structure of the condition, keeping the leaf values (e.g. entities) by reference. */
39
45
  private static cloneWhere;
40
46
  static processWhere<T extends object>(options: ProcessWhereOptions<T>): FilterQuery<T>;
@@ -4,7 +4,7 @@ import { ARRAY_OPERATORS, GroupOperator, JSON_KEY_OPERATORS, ReferenceKind } fro
4
4
  import { JsonType } from '../types/JsonType.js';
5
5
  import { helper } from '../entity/wrap.js';
6
6
  import { isRaw, Raw } from './RawQueryFragment.js';
7
- import { MetadataError } from '../errors.js';
7
+ import { MetadataError, ValidationError } from '../errors.js';
8
8
  /** @internal */
9
9
  export class QueryHelper {
10
10
  static SUPPORTED_OPERATORS = ['>', '<', '<=', '>=', '!', '!='];
@@ -102,18 +102,23 @@ export class QueryHelper {
102
102
  for (const k of keys) {
103
103
  const value = where[k];
104
104
  const prop = meta.properties[k];
105
- // Polymorphic relations use multiple columns (discriminator + FK), so they cannot
106
- // participate in the standard single-column FK expansion. Query by discriminator
107
- // column directly instead, e.g. { likeableType: 'post', likeableId: 1 }.
108
- if (!prop || ![ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) || prop.polymorphic) {
105
+ if (!prop || ![ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind)) {
109
106
  continue;
110
107
  }
111
108
  const op = this.liftGroupOperators(value, prop.targetMeta, metadata, k);
112
109
  if (op) {
113
110
  delete where[k];
114
- where[op] = value[op].map((v) => {
111
+ const group = value[op].map((v) => {
115
112
  return { [k]: v };
116
113
  });
114
+ if (op in where) {
115
+ // Sibling groups are conjunctive, even when both use `$or`.
116
+ where.$and ??= [];
117
+ where.$and.push({ [op]: group });
118
+ }
119
+ else {
120
+ where[op] = group;
121
+ }
117
122
  }
118
123
  }
119
124
  return undefined;
@@ -122,7 +127,7 @@ export class QueryHelper {
122
127
  if (Array.isArray(where)) {
123
128
  where.forEach((item, i) => {
124
129
  if (this.inlinePrimaryKeyObjects(item, meta, metadata, key)) {
125
- where[i] = Utils.getPrimaryKeyValues(item, meta, false);
130
+ where[i] = Utils.getPrimaryKeyValues(item, meta, true);
126
131
  }
127
132
  });
128
133
  }
@@ -152,15 +157,19 @@ export class QueryHelper {
152
157
  Object.keys(where).forEach(k => {
153
158
  const prop = meta.properties[k];
154
159
  const meta2 = metadata.find(prop?.targetMeta?.class) || meta;
160
+ if (prop?.polymorphic) {
161
+ where[k] = this.inlinePolymorphicPrimaryKeys(where[k], meta2, prop);
162
+ }
155
163
  if (this.inlinePrimaryKeyObjects(where[k], meta2, metadata, k)) {
156
164
  // Skip the PK collapse when an owning M:1/1:1 relation's FK column count does not match
157
165
  // the target's PK column count (e.g. FK references target PK + an extra unique column).
158
166
  // The criteria layer would otherwise emit a malformed predicate such as
159
167
  // `(joinCol1, joinCol2) = scalar`. Limited to owning relations — `joinColumns` on the
160
168
  // inverse side (1:m) describes the owning entity's FK columns, not the LHS tuple.
169
+ // Polymorphic relations are skipped too, a scalar would be compared to the discriminator column.
161
170
  if (prop?.owner &&
162
171
  [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) &&
163
- prop.joinColumns.length !== meta2.primaryKeys.length) {
172
+ (prop.polymorphic || prop.joinColumns.length !== meta2.primaryKeys.length)) {
164
173
  return;
165
174
  }
166
175
  where[k] = Utils.getPrimaryKeyValues(where[k], meta2, true);
@@ -168,6 +177,37 @@ export class QueryHelper {
168
177
  });
169
178
  return false;
170
179
  }
180
+ /**
181
+ * A bare value on a polymorphic relation would be compared to the discriminator column, so PK values are turned
182
+ * into a PK condition instead, e.g. `1` becomes `{ id: 1 }` and `[{ id: 1 }, { id: 2 }]` becomes `{ id: { $in: [1, 2] } }`.
183
+ * Null checks and `[type, id]` tuples keep their meaning.
184
+ */
185
+ static inlinePolymorphicPrimaryKeys(value, meta, prop) {
186
+ const pk = meta.primaryKeys[0];
187
+ const isScalar = (v) => v != null && !Array.isArray(v) && Utils.isPrimaryKey(v);
188
+ const isPrimaryKeyObject = (v) => Utils.isPlainObject(v) && Utils.getObjectKeysSize(v) === 1 && pk in v;
189
+ // lists of plain values are how `[type, id]` tuples get normalized (`{ $in: [type, id] }`), so only PK objects are inlined
190
+ const isList = (v) => Array.isArray(v) && v.length > 0 && v.every(isPrimaryKeyObject);
191
+ let cond;
192
+ if (isScalar(value)) {
193
+ cond = value;
194
+ }
195
+ else if (isList(value)) {
196
+ cond = { $in: value.map(v => v[pk]) };
197
+ }
198
+ else if (Utils.isPlainObject(value) &&
199
+ Utils.hasObjectKeys(value) &&
200
+ Object.entries(value).every(([op, v]) => Utils.isOperator(op) && !(op in GroupOperator) && op !== '$not' && (isScalar(v) || isList(v)))) {
201
+ cond = Object.fromEntries(Object.entries(value).map(([op, v]) => [op, isList(v) ? v.map(i => i[pk]) : v]));
202
+ }
203
+ else {
204
+ return value;
205
+ }
206
+ if (meta.compositePK) {
207
+ throw new ValidationError(`Polymorphic relation ${prop.name} targets entities with a composite primary key, use PK objects or entity references in the condition instead.`);
208
+ }
209
+ return { [pk]: cond };
210
+ }
171
211
  /** Copies the plain object and array structure of the condition, keeping the leaf values (e.g. entities) by reference. */
172
212
  static cloneWhere(where) {
173
213
  if (Array.isArray(where)) {
@@ -486,6 +526,9 @@ export class QueryHelper {
486
526
  if (expanded !== w) {
487
527
  where[k] = expanded;
488
528
  }
529
+ else if (Array.isArray(w)) {
530
+ where[k] = w.map(expandItem);
531
+ }
489
532
  else if (Utils.isPlainObject(w)) {
490
533
  for (const op of Object.keys(w)) {
491
534
  if (!Utils.isOperator(op, false)) {
@@ -1,6 +1,7 @@
1
1
  import type { EntityManager } from '../EntityManager.js';
2
2
  /** Uses `AsyncLocalStorage` to maintain a transaction-scoped EntityManager context across async operations. */
3
3
  export declare class TransactionContext {
4
+ #private;
4
5
  readonly em: EntityManager;
5
6
  private static storage;
6
7
  readonly id: number;
@@ -1,8 +1,10 @@
1
+ var _a;
1
2
  import { createAsyncContext } from './AsyncContext.js';
2
3
  /** Uses `AsyncLocalStorage` to maintain a transaction-scoped EntityManager context across async operations. */
3
4
  export class TransactionContext {
4
5
  em;
5
6
  static storage = createAsyncContext();
7
+ #parent = _a.currentTransactionContext();
6
8
  id;
7
9
  constructor(em) {
8
10
  this.em = em;
@@ -12,7 +14,7 @@ export class TransactionContext {
12
14
  * Creates new TransactionContext instance and runs the code inside its domain.
13
15
  */
14
16
  static create(em, next) {
15
- const context = new TransactionContext(em);
17
+ const context = new _a(em);
16
18
  return this.storage.run(context, next);
17
19
  }
18
20
  /**
@@ -25,7 +27,11 @@ export class TransactionContext {
25
27
  * Returns current EntityManager (if available).
26
28
  */
27
29
  static getEntityManager(name = 'default') {
28
- const context = TransactionContext.currentTransactionContext();
29
- return context?.em.name === name ? context.em : undefined;
30
+ let context = _a.currentTransactionContext();
31
+ while (context && context.em.name !== name) {
32
+ context = context.#parent;
33
+ }
34
+ return context?.em;
30
35
  }
31
36
  }
37
+ _a = TransactionContext;
@@ -81,10 +81,10 @@ export class TransactionManager {
81
81
  */
82
82
  async executeWithoutTransaction(em, cb, options) {
83
83
  const suspended = this.suspendTransaction(em);
84
- const fork = this.createFork(em, { ...options, disableTransactions: true });
84
+ const fork = this.createFork(em, options);
85
85
  const propagateToUpperContext = this.shouldPropagateToUpperContext(em);
86
86
  try {
87
- return await this.executeTransactionFlow(fork, cb, propagateToUpperContext, em);
87
+ return await TransactionContext.create(fork, () => this.executeTransactionFlow(fork, cb, propagateToUpperContext, em));
88
88
  }
89
89
  finally {
90
90
  this.resumeTransaction(em, suspended);
@@ -189,6 +189,10 @@ export class TransactionManager {
189
189
  if (wrapped.__originalEntityData) {
190
190
  parentWrapped.__originalEntityData = originalEntityData;
191
191
  }
192
+ // index a merged `targetKey` value, `getByKey` ignores the old one as stale
193
+ if (meta.root.targetKeys) {
194
+ parentUoW.getIdentityMap().store(parentEntity);
195
+ }
192
196
  }
193
197
  else {
194
198
  parentUoW.merge(entity, new Set([entity]));
package/utils/Utils.js CHANGED
@@ -153,7 +153,7 @@ export function parseJsonSafe(value) {
153
153
  /** Collection of general-purpose utility methods used throughout the ORM. */
154
154
  export class Utils {
155
155
  static PK_SEPARATOR = '~~~';
156
- static #ORM_VERSION = '7.2.2-dev.9';
156
+ static #ORM_VERSION = '7.2.2';
157
157
  /** Default session variable name backing an RLS filter argument (`current_setting('mikro.<filter>.<arg>')`). */
158
158
  static getRlsSettingName(filterName, argName) {
159
159
  return `mikro.${filterName}.${argName}`;