@mikro-orm/core 7.3.0-dev.2 → 7.3.0-dev.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/EntityManager.js +131 -32
  2. package/drivers/DatabaseDriver.d.ts +2 -0
  3. package/drivers/DatabaseDriver.js +16 -3
  4. package/entity/EntityAssigner.js +15 -3
  5. package/entity/EntityFactory.d.ts +8 -0
  6. package/entity/EntityFactory.js +69 -3
  7. package/entity/EntityLoader.js +31 -15
  8. package/entity/WrappedEntity.js +6 -1
  9. package/entity/defineEntity.d.ts +32 -19
  10. package/entity/defineEntity.js +9 -0
  11. package/entity/validators.js +4 -1
  12. package/errors.d.ts +7 -0
  13. package/errors.js +15 -0
  14. package/hydration/ObjectHydrator.js +6 -2
  15. package/metadata/MetadataDiscovery.d.ts +10 -0
  16. package/metadata/MetadataDiscovery.js +178 -30
  17. package/metadata/MetadataStorage.d.ts +8 -0
  18. package/metadata/MetadataStorage.js +47 -3
  19. package/metadata/MetadataValidator.js +2 -1
  20. package/metadata/types.d.ts +12 -0
  21. package/package.json +1 -1
  22. package/platforms/Platform.d.ts +6 -0
  23. package/platforms/Platform.js +26 -0
  24. package/serialization/EntitySerializer.js +52 -12
  25. package/types/DecimalType.js +34 -1
  26. package/types/DoubleType.d.ts +7 -3
  27. package/types/DoubleType.js +8 -3
  28. package/typings.d.ts +26 -6
  29. package/typings.js +5 -2
  30. package/unit-of-work/ChangeSetComputer.js +22 -7
  31. package/unit-of-work/ChangeSetPersister.js +24 -5
  32. package/unit-of-work/CommitOrderCalculator.js +2 -1
  33. package/unit-of-work/IdentityMap.d.ts +3 -2
  34. package/unit-of-work/IdentityMap.js +30 -7
  35. package/unit-of-work/UnitOfWork.js +45 -7
  36. package/utils/Configuration.d.ts +12 -1
  37. package/utils/EntityComparator.d.ts +5 -0
  38. package/utils/EntityComparator.js +87 -19
  39. package/utils/QueryHelper.d.ts +14 -0
  40. package/utils/QueryHelper.js +90 -13
  41. package/utils/RawQueryFragment.d.ts +5 -1
  42. package/utils/RawQueryFragment.js +27 -19
  43. package/utils/TransactionManager.js +4 -0
  44. package/utils/Utils.d.ts +2 -0
  45. package/utils/Utils.js +24 -8
  46. package/utils/clone.js +5 -7
  47. package/utils/env-vars.js +2 -0
  48. package/utils/fs-utils.js +19 -15
  49. package/utils/upsert-utils.js +2 -1
package/EntityManager.js CHANGED
@@ -743,10 +743,15 @@ export class EntityManager {
743
743
  if (Utils.isEmpty(options.orderBy) && !Raw.hasObjectFragments(options.orderBy)) {
744
744
  throw new Error('Explicit `orderBy` option required');
745
745
  }
746
+ const meta = this.metadata.get(entityName);
747
+ // same merge as em.find, so the cursor and the query see the same orderBy
748
+ if (meta.orderBy) {
749
+ options.orderBy = QueryHelper.mergeOrderBy(options.orderBy, meta.orderBy);
750
+ }
746
751
  const [entities, count] = options.includeCount !== false
747
752
  ? await em.findAndCount(entityName, options.where, options)
748
753
  : [await em.find(entityName, options.where, options)];
749
- return new Cursor(entities, count, options, this.metadata.get(entityName));
754
+ return new Cursor(entities, count, options, meta);
750
755
  }
751
756
  /**
752
757
  * Refreshes the persistent state of an entity from the database, overriding any local changes that have not yet been
@@ -781,26 +786,46 @@ export class EntityManager {
781
786
  em.#unitOfWork.unsetIdentity(entity);
782
787
  return null;
783
788
  }
789
+ // `serialize()` applies `Type.toJSON()` and `serializedName`, take only relations and embeddables from it, scalars from the entity
790
+ const getData = (e, data = helper(e).serialize({
791
+ ignoreSerializers: true,
792
+ includeHidden: true,
793
+ convertCustomTypes: false,
794
+ })) => {
795
+ for (const prop of helper(e).__meta.props) {
796
+ if (prop.kind === ReferenceKind.SCALAR && !prop.ref) {
797
+ if (helper(e).__initialized || prop.primary) {
798
+ data[prop.name] = e[prop.name];
799
+ }
800
+ continue;
801
+ }
802
+ if (prop.serializedName && prop.serializedName in data) {
803
+ data[prop.name] = data[prop.serializedName];
804
+ }
805
+ if (prop.kind === ReferenceKind.EMBEDDED && e[prop.name] != null) {
806
+ Utils.asArray(e[prop.name]).forEach((item, i) => getData(item, prop.array ? data[prop.name][i] : data[prop.name]));
807
+ }
808
+ }
809
+ return data;
810
+ };
811
+ const hydrate = (target, source) => {
812
+ em.config
813
+ .getHydrator(this.metadata)
814
+ .hydrate(target, helper(target).__meta, getData(source), em.#entityFactory, 'full', false, false);
815
+ Utils.merge(helper(target).__originalEntityData, this.#comparator.prepareEntity(source));
816
+ };
784
817
  let found = false;
785
818
  for (const e of fork.#unitOfWork.getIdentityMap()) {
786
819
  const ref = em.getReference(e.constructor, helper(e).getPrimaryKey());
787
- const data = helper(e).serialize({ ignoreSerializers: true, includeHidden: true, convertCustomTypes: false });
788
- em.config
789
- .getHydrator(this.metadata)
790
- .hydrate(ref, helper(ref).__meta, data, em.#entityFactory, 'full', false, false);
791
- Utils.merge(helper(ref).__originalEntityData, this.#comparator.prepareEntity(e));
820
+ hydrate(ref, e);
821
+ // index a refreshed `targetKey` value, `getByKey` ignores the old one as stale
822
+ if (helper(ref).__meta.root.targetKeys) {
823
+ em.#unitOfWork.getIdentityMap().store(ref);
824
+ }
792
825
  found ||= ref === entity;
793
826
  }
794
827
  if (!found) {
795
- const data = helper(reloaded).serialize({
796
- ignoreSerializers: true,
797
- includeHidden: true,
798
- convertCustomTypes: false,
799
- });
800
- em.config
801
- .getHydrator(this.metadata)
802
- .hydrate(entity, wrapped.__meta, data, em.#entityFactory, 'full', false, false);
803
- Utils.merge(wrapped.__originalEntityData, this.#comparator.prepareEntity(reloaded));
828
+ hydrate(entity, reloaded);
804
829
  }
805
830
  return entity;
806
831
  }
@@ -1021,6 +1046,15 @@ export class EntityManager {
1021
1046
  where[meta.primaryKeys[0]] = ret.insertId;
1022
1047
  }
1023
1048
  }
1049
+ // a `null` unique value cannot identify the row, and as it never conflicts, the row was inserted
1050
+ if (!Utils.hasObjectKeys(where)) {
1051
+ const insertId = meta.simplePK && ret.insertId != null ? { [meta.primaryKeys[0]]: ret.insertId } : {};
1052
+ Object.assign(where, Utils.getPrimaryKeyCond(entity, meta.primaryKeys) ?? insertId);
1053
+ }
1054
+ // an empty condition would hydrate the entity from an arbitrary row, mongo still gets one for nested conflict fields
1055
+ if (!Utils.hasObjectKeys(where) && platform.usesPivotTable()) {
1056
+ throw new Error(`Cannot find the upserted ${meta.className} row, as neither its primary key nor a unique value is known`);
1057
+ }
1024
1058
  const data2 = await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => this.driver.findOne(meta.class, where, {
1025
1059
  fields: returning.concat(...(options.onConflictMergeFields ?? [])),
1026
1060
  ctx,
@@ -1028,6 +1062,9 @@ export class EntityManager {
1028
1062
  connectionType: 'write',
1029
1063
  schema: options.schema,
1030
1064
  }));
1065
+ if (!data2) {
1066
+ throw new Error(`Cannot find the upserted ${meta.className} row for condition ${JSON.stringify(where)}`);
1067
+ }
1031
1068
  em.getHydrator().hydrate(entity, meta, data2, em.#entityFactory, 'full', false, true);
1032
1069
  }
1033
1070
  // recompute the data as there might be some values missing (e.g. those with db column defaults)
@@ -1179,8 +1216,10 @@ export class EntityManager {
1179
1216
  unique.findIndex(p => data[p] ?? data[p.substring(0, p.indexOf('.'))] != null);
1180
1217
  const tmp = getWhereCondition(meta, options.onConflictFields, row, where);
1181
1218
  propIndex = tmp.propIndex;
1219
+ // a partial composite PK cannot identify the row
1220
+ const partialPK = meta.compositePK && !Utils.isPlainObject(tmp.where) && meta.primaryKeys.some(pk => row[pk] == null);
1182
1221
  where = QueryHelper.processWhere({
1183
- where: tmp.where,
1222
+ where: partialPK ? {} : tmp.where,
1184
1223
  entityName,
1185
1224
  metadata: this.metadata,
1186
1225
  platform: this.getPlatform(),
@@ -1195,6 +1234,25 @@ export class EntityManager {
1195
1234
  allWhere.push(where);
1196
1235
  dataIndexes.push(i);
1197
1236
  }
1237
+ // mongo inserts the rows with an empty condition as part of the batch and reports their ids
1238
+ const positional = !this.getPlatform().usesPivotTable() ||
1239
+ ((this.getPlatform().usesReturningStatement() || this.getPlatform().usesOutputStatement()) &&
1240
+ options.onConflictAction !== 'ignore' &&
1241
+ !options.onConflictWhere);
1242
+ const hasUniqueKey = !!options.onConflictFields || meta.uniques.length > 0 || meta.props.some(p => p.unique && !p.primary);
1243
+ // only an autoincrement PK can be derived from the `insertId` of the batch
1244
+ const autoincrement = meta.getPrimaryProps().some(p => p.autoincrement);
1245
+ // a TPT child table does not return its rows, so they are never mapped by position
1246
+ const peel = !!meta.tptParent || (!positional && (hasUniqueKey || !autoincrement));
1247
+ // a row with a `null` unique value has nothing to be reloaded by, the single row path maps its PK from the insert
1248
+ for (let idx = 0; peel && idx < allWhere.length; idx++) {
1249
+ if (!Utils.hasObjectKeys(allWhere[idx])) {
1250
+ result[dataIndexes[idx]] = await em.upsert(entityName, data[dataIndexes[idx]], options);
1251
+ allData.splice(idx, 1);
1252
+ allWhere.splice(idx, 1);
1253
+ dataIndexes.splice(idx--, 1);
1254
+ }
1255
+ }
1198
1256
  if (allData.length === 0) {
1199
1257
  return [...new Set(result)];
1200
1258
  }
@@ -1244,7 +1302,11 @@ export class EntityManager {
1244
1302
  // skip the reload only when RETURNING brought back a row for every input
1245
1303
  // (onConflictWhere can suppress writes, leaving some rows out)
1246
1304
  // oxfmt-ignore
1247
- const uniqueFields = options.onConflictFields ?? (Utils.isPlainObject(allWhere[0]) ? Object.keys(allWhere[0]).flatMap(key => Utils.splitPrimaryKeys(key)) : meta.primaryKeys);
1305
+ const getUniqueFields = (where) => options.onConflictFields ?? (Utils.isPlainObject(where) ? Object.keys(where).flatMap(key => Utils.splitPrimaryKeys(key)) : meta.primaryKeys);
1306
+ // a row without a condition has no unique fields to derive
1307
+ const uniqueFields = getUniqueFields(allWhere.find(cond => Utils.hasObjectKeys(cond)) ?? allWhere[0]);
1308
+ // an empty condition identifies no row, it must not turn into a reload condition matching every row
1309
+ const getRowUniqueFields = (where) => Utils.isPlainObject(where) && !Utils.hasObjectKeys(where) ? uniqueFields : getUniqueFields(where);
1248
1310
  const platform = this.getPlatform();
1249
1311
  const returning = getOnConflictReturningFields(meta, data[dataIndexes[0]], uniqueFields, options, platform);
1250
1312
  if (options.onConflictWhere && res.rows?.length !== allData.length) {
@@ -1261,10 +1323,14 @@ export class EntityManager {
1261
1323
  Utils.keys(cond).forEach(key => add.add(key));
1262
1324
  }
1263
1325
  const where = { $or: [] };
1326
+ // a row without a condition was inserted, only the primary key reported for it can identify it
1327
+ const insertedPKs = allData.map((row, idx) => Utils.hasObjectKeys(allWhere[idx]) ? null : Utils.getPrimaryKeyCond(entitiesByData.get(row), meta.primaryKeys));
1264
1328
  dataIndexes.forEach((index, idx) => {
1265
1329
  const item = data[index];
1266
- where.$or[idx] = {};
1267
- const props = Array.isArray(uniqueFields) ? uniqueFields : Object.keys(item);
1330
+ where.$or[idx] = { ...insertedPKs[idx] };
1331
+ // the inputs of one batch can be identified by different unique keys
1332
+ const fields = insertedPKs[idx] ? [] : getRowUniqueFields(allWhere[idx]);
1333
+ const props = Array.isArray(fields) ? fields : Object.keys(item);
1268
1334
  props.forEach(prop => {
1269
1335
  where.$or[idx][prop] = item[prop];
1270
1336
  });
@@ -1279,8 +1345,8 @@ export class EntityManager {
1279
1345
  connectionType: 'write',
1280
1346
  schema: options.schema,
1281
1347
  }));
1282
- for (const [entity, cond] of loadPK.entries()) {
1283
- const row = data2.find(row => {
1348
+ if (loadPK.size > 0) {
1349
+ const comparableRows = data2.map(row => {
1284
1350
  const tmp = {};
1285
1351
  add.forEach(k => {
1286
1352
  const prop = meta.properties[k];
@@ -1288,25 +1354,58 @@ export class EntityManager {
1288
1354
  tmp[k] = prop?.customType ? prop.customType.convertToDatabaseValue(row[k], this.getPlatform()) : row[k];
1289
1355
  }
1290
1356
  });
1291
- return this.#comparator.matching(entityName, cond, tmp);
1357
+ return tmp;
1292
1358
  });
1293
- /* v8 ignore next */
1294
- if (!row) {
1295
- throw new Error(`Cannot find matching entity for condition ${JSON.stringify(cond)}`);
1359
+ // rows carry the keys of every condition in the batch, compare only this one's
1360
+ const isMatch = (cond, idx) => this.#comparator.matching(entityName, cond, Object.fromEntries(Object.entries(comparableRows[idx]).filter(([k]) => k in cond)));
1361
+ // When every condition key is a comparable prop checked with `!==`, a matching row has the same primitive
1362
+ // values, so rows can be grouped by those values once instead of comparing every row for every entity.
1363
+ // Props outside `comparableProps` are skipped by the comparator, so they never qualify for grouping.
1364
+ const groupableProps = new Set(meta.comparableProps.filter(prop => this.#comparator.isStrictlyCompared(prop)));
1365
+ const groups = new Map();
1366
+ // `null` and `undefined` share a key, which can only make a group wider than what `matching` accepts
1367
+ const groupKey = (data, keys) => keys.map(k => (data[k] == null ? 'null' : `${typeof data[k]}:${data[k]}`)).join('\0');
1368
+ const findRowIndex = (cond) => {
1369
+ const keys = Utils.keys(cond);
1370
+ if (!keys.every(k => groupableProps.has(meta.properties[k]))) {
1371
+ return comparableRows.findIndex((_, idx) => isMatch(cond, idx));
1372
+ }
1373
+ const signature = keys.join('\0');
1374
+ if (!groups.has(signature)) {
1375
+ const rowsByKey = new Map();
1376
+ comparableRows.forEach((tmp, idx) => {
1377
+ const rowKey = groupKey(tmp, keys);
1378
+ const indexes = rowsByKey.get(rowKey) ?? [];
1379
+ indexes.push(idx);
1380
+ rowsByKey.set(rowKey, indexes);
1381
+ });
1382
+ groups.set(signature, rowsByKey);
1383
+ }
1384
+ const group = groups.get(signature);
1385
+ return group.get(groupKey(cond, keys))?.find(idx => isMatch(cond, idx)) ?? -1;
1386
+ };
1387
+ for (const [entity, cond] of loadPK.entries()) {
1388
+ const row = data2[findRowIndex(cond)];
1389
+ /* v8 ignore next */
1390
+ if (!row) {
1391
+ throw new Error(`Cannot find matching entity for condition ${JSON.stringify(cond)}`);
1392
+ }
1393
+ em.getHydrator().hydrate(entity, meta, row, em.#entityFactory, 'full', false, true);
1296
1394
  }
1297
- em.getHydrator().hydrate(entity, meta, row, em.#entityFactory, 'full', false, true);
1298
1395
  }
1299
1396
  if (loadPK.size !== data2.length && Array.isArray(uniqueFields)) {
1300
1397
  for (let i = 0; i < allData.length; i++) {
1301
1398
  const data = allData[i];
1302
- const cond = uniqueFields.reduce((a, b) => {
1303
- // @ts-ignore
1304
- a[b] = data[b];
1305
- return a;
1306
- }, {});
1399
+ const fields = (insertedPKs[i] ? meta.primaryKeys : getRowUniqueFields(allWhere[i]));
1400
+ const cond = insertedPKs[i] ??
1401
+ fields.reduce((a, b) => {
1402
+ // @ts-ignore
1403
+ a[b] = data[b];
1404
+ return a;
1405
+ }, {});
1307
1406
  const entity = entitiesByData.get(data);
1308
1407
  const row = data2.find(item => {
1309
- const pk = uniqueFields.reduce((a, b) => {
1408
+ const pk = fields.reduce((a, b) => {
1310
1409
  // @ts-ignore
1311
1410
  a[b] = item[b];
1312
1411
  return a;
@@ -81,6 +81,8 @@ export declare abstract class DatabaseDriver<C extends Connection> implements ID
81
81
  */
82
82
  private mapCursorOffset;
83
83
  protected createCursorCondition<T extends object>(definition: (readonly [keyof T & string, QueryOrder])[], offsets: Dictionary[], inverse: boolean, meta: EntityMetadata<T>, fromJson?: boolean): FilterQuery<T>;
84
+ /** @internal Polymorphic embeddable variants can declare same-named properties with different field names. */
85
+ protected variantProps(prop: EntityProperty, data?: Dictionary): Dictionary<EntityProperty>;
84
86
  /** @internal */
85
87
  mapDataToFieldNames(data: Dictionary, stringifyJsonArrays: boolean, properties?: Record<string, EntityProperty>, convertCustomTypes?: boolean, object?: boolean): Dictionary;
86
88
  protected inlineEmbeddables<T extends object>(meta: EntityMetadata<T>, data: T, where?: boolean): void;
@@ -403,6 +403,19 @@ export class DatabaseDriver {
403
403
  }
404
404
  return { ...atOrPast, ...past };
405
405
  }
406
+ /** @internal Polymorphic embeddable variants can declare same-named properties with different field names. */
407
+ variantProps(prop, data) {
408
+ const meta = prop.targetMeta;
409
+ // eslint-disable-next-line eqeqeq
410
+ const variant = meta?.polymorphs?.find(m => m.discriminatorValue == data?.[meta.discriminatorColumn]);
411
+ const props = variant ? { ...prop.embeddedProps } : prop.embeddedProps;
412
+ for (const p of variant ? Object.values(props) : []) {
413
+ if (variant.properties[p.embedded[1]] && Utils.isSameKind(variant.properties[p.embedded[1]], p)) {
414
+ props[p.embedded[1]] = p;
415
+ }
416
+ }
417
+ return props;
418
+ }
406
419
  /** @internal */
407
420
  mapDataToFieldNames(data, stringifyJsonArrays, properties, convertCustomTypes, object) {
408
421
  if (!properties || data == null) {
@@ -417,17 +430,17 @@ export class DatabaseDriver {
417
430
  if (prop.embeddedProps && !prop.object && !object) {
418
431
  const copy = data[k];
419
432
  delete data[k];
420
- Object.assign(data, this.mapDataToFieldNames(copy, stringifyJsonArrays, prop.embeddedProps, convertCustomTypes));
433
+ Object.assign(data, this.mapDataToFieldNames(copy, stringifyJsonArrays, this.variantProps(prop, copy), convertCustomTypes));
421
434
  return;
422
435
  }
423
436
  if (prop.embeddedProps && (object || prop.object)) {
424
437
  const copy = data[k];
425
438
  delete data[k];
426
439
  if (prop.array) {
427
- data[prop.fieldNames[0]] = copy?.map((item) => this.mapDataToFieldNames(item, stringifyJsonArrays, prop.embeddedProps, convertCustomTypes, true));
440
+ data[prop.fieldNames[0]] = copy?.map((item) => this.mapDataToFieldNames(item, stringifyJsonArrays, this.variantProps(prop, item), convertCustomTypes, true));
428
441
  }
429
442
  else {
430
- data[prop.fieldNames[0]] = this.mapDataToFieldNames(copy, stringifyJsonArrays, prop.embeddedProps, convertCustomTypes, true);
443
+ data[prop.fieldNames[0]] = this.mapDataToFieldNames(copy, stringifyJsonArrays, this.variantProps(prop, copy), convertCustomTypes, true);
431
444
  }
432
445
  if (stringifyJsonArrays && prop.array && !object) {
433
446
  data[prop.fieldNames[0]] = this.platform.convertJsonToDatabaseValue(data[prop.fieldNames[0]]);
@@ -234,14 +234,26 @@ export class EntityAssigner {
234
234
  entity[propName].push(...Object.values(tmp));
235
235
  });
236
236
  }
237
+ const polymorphs = prop.targetMeta?.polymorphs;
238
+ // eslint-disable-next-line eqeqeq
239
+ const variant = polymorphs?.find(m => m.discriminatorValue == value[prop.targetMeta.discriminatorColumn]);
237
240
  const create = () => EntityAssigner.validateEM(em) &&
238
- em.getEntityFactory().createEmbeddable(prop.targetMeta.class, value, {
241
+ em.getEntityFactory().createEmbeddable((variant ?? prop.targetMeta).class, value, {
239
242
  convertCustomTypes: options.convertCustomTypes,
240
243
  newEntity: options.mergeEmbeddedProperties ? !('propName' in entity) : true,
241
244
  });
242
- entity[propName] = (options.mergeEmbeddedProperties ? entity[propName] || create() : create());
245
+ // a different polymorphic variant cannot be merged into the current one
246
+ const merge = options.mergeEmbeddedProperties && (!variant || entity[propName]?.constructor === variant.class);
247
+ entity[propName] = (merge ? entity[propName] || create() : create());
248
+ const props = polymorphs ? { ...prop.embeddedProps } : prop.embeddedProps;
249
+ // polymorphic variants can declare same-named properties of different kinds or relation targets
250
+ for (const p of polymorphs ? entity[propName].__meta.props : []) {
251
+ if (props[p.name] && props[p.name].targetMeta?.class !== p.targetMeta?.class) {
252
+ props[p.name] = { ...p, nullable: prop.nullable || p.nullable };
253
+ }
254
+ }
243
255
  Object.keys(value).forEach(key => {
244
- EntityAssigner.assignProperty(entity[propName], key, prop.embeddedProps, value, options);
256
+ EntityAssigner.assignProperty(entity[propName], key, props, value, options);
245
257
  });
246
258
  }
247
259
  static createCollectionItem(item, em, prop, invalid, options) {
@@ -38,6 +38,13 @@ export declare class EntityFactory {
38
38
  constructor(em: EntityManager);
39
39
  /** Creates a new entity instance or returns an existing one from the identity map, hydrating it with the provided data. */
40
40
  create<T extends object, P extends string = string>(entityName: EntityName<T>, data: EntityData<T>, options?: FactoryOptions): New<T, P>;
41
+ /**
42
+ * Whether merging `data` into an initialized entity would change nothing: every key is a scalar the entity already
43
+ * holds by identity, and keys `mergeData` always re-applies (formula, version, generated, discriminator) match the snapshot.
44
+ */
45
+ private isNoopMerge;
46
+ /** Whether hydrating `value` into `prop` would leave `current` as is, mirroring the hydrator's Date and boolean coercion. */
47
+ private holdsValue;
41
48
  /** Merges new data into an existing entity, preserving user-modified properties. */
42
49
  mergeData<T extends object>(meta: EntityMetadata<T>, entity: T, data: EntityData<T>, options?: FactoryOptions): void;
43
50
  /** Creates or retrieves an uninitialized entity reference by its primary key or alternate key. */
@@ -51,6 +58,7 @@ export declare class EntityFactory {
51
58
  assignDefaultValues<T extends object>(entity: T, meta: EntityMetadata<T>, onCreateOnly?: boolean): void;
52
59
  private hydrate;
53
60
  private findEntity;
61
+ private findEntityByPrimaryKey;
54
62
  private processDiscriminatorColumn;
55
63
  /**
56
64
  * 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) {
@@ -145,8 +149,45 @@ export class EntityFactory {
145
149
  wrapped.__processing = false;
146
150
  return entity;
147
151
  }
152
+ /**
153
+ * Whether merging `data` into an initialized entity would change nothing: every key is a scalar the entity already
154
+ * holds by identity, and keys `mergeData` always re-applies (formula, version, generated, discriminator) match the snapshot.
155
+ */
156
+ isNoopMerge(meta, entity, data, options) {
157
+ const wrapped = helper(entity);
158
+ if (!wrapped.__initialized || options.normalizeAccessors) {
159
+ return false;
160
+ }
161
+ for (const key of Utils.keys(data)) {
162
+ const prop = meta.properties[key];
163
+ const value = data[key];
164
+ // custom types are left to the full merge, as their JS and database values can differ
165
+ if (!prop || prop.customType || value === undefined || !this.holdsValue(prop, entity[key], value)) {
166
+ return false;
167
+ }
168
+ const reapplied = prop.formula || prop.version || prop.generated || key === meta.root.discriminatorColumn;
169
+ if (reapplied && (wrapped.__originalEntityData?.[key] !== value || !wrapped.__loadedProperties.has(key))) {
170
+ return false;
171
+ }
172
+ }
173
+ return true;
174
+ }
175
+ /** Whether hydrating `value` into `prop` would leave `current` as is, mirroring the hydrator's Date and boolean coercion. */
176
+ holdsValue(prop, current, value) {
177
+ if (current instanceof Date && value instanceof Date) {
178
+ return current.getTime() === value.getTime();
179
+ }
180
+ if (prop.runtimeType === 'boolean' && value != null) {
181
+ return current === !!value;
182
+ }
183
+ return current === value;
184
+ }
148
185
  /** Merges new data into an existing entity, preserving user-modified properties. */
149
186
  mergeData(meta, entity, data, options = {}) {
187
+ // joined rows often repeat exactly what the entity already holds, skip the snapshot and diffing then
188
+ if (this.isNoopMerge(meta, entity, data, options)) {
189
+ return;
190
+ }
150
191
  // merge unchanged properties automatically
151
192
  data = QueryHelper.processParams(data);
152
193
  const existsData = this.#comparator.prepareEntity(entity);
@@ -232,9 +273,9 @@ export class EntityFactory {
232
273
  options.convertCustomTypes ??= true;
233
274
  const meta = this.#metadata.get(entityName);
234
275
  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);
276
+ // Handle alternate key lookup, a `key` pointing at the single PK is a plain PK reference
277
+ if (options.key && !(meta.primaryKeys.length === 1 && meta.primaryKeys[0] === options.key)) {
278
+ const value = Array.isArray(id) ? id[0] : Utils.isPlainObject(id) ? id[options.key] : id;
238
279
  const exists = this.unitOfWork.getByKey(entityName, options.key, value, schema, options.convertCustomTypes);
239
280
  if (exists) {
240
281
  return exists;
@@ -242,6 +283,8 @@ export class EntityFactory {
242
283
  // Create entity stub - storeByKey will set the alternate key property and store in identity map
243
284
  const entity = this.create(entityName, {}, { ...options, initialized: false });
244
285
  this.unitOfWork.storeByKey(entity, options.key, value, schema, options.convertCustomTypes);
286
+ // snapshot the stub like a PK reference, so flush treats it as existing instead of inserting it
287
+ helper(entity).__originalEntityData = this.#comparator.prepareEntity(entity);
245
288
  return entity;
246
289
  }
247
290
  if (meta.simplePK) {
@@ -310,6 +353,12 @@ export class EntityFactory {
310
353
  if (options.merge && !options.newEntity) {
311
354
  this.#hydrator.hydrateReference(entity, meta, data, this, options.convertCustomTypes, options.schema, options.parentSchema);
312
355
  this.unitOfWork.register(entity);
356
+ // relations hydrated together with this entity (joined strategy) resolve it via `targetKey` before it is hydrated
357
+ for (const key of meta.root.targetKeys ?? []) {
358
+ if (data[key] != null) {
359
+ this.unitOfWork.storeByKey(entity, key, data[key], schema, options.convertCustomTypes);
360
+ }
361
+ }
313
362
  }
314
363
  if (options.initialized) {
315
364
  EntityHelper.ensurePropagation(entity);
@@ -379,6 +428,23 @@ export class EntityFactory {
379
428
  }
380
429
  findEntity(data, meta, options) {
381
430
  const schema = this.#driver.getSchemaName(meta, options);
431
+ const exists = this.findEntityByPrimaryKey(data, meta, schema, options);
432
+ // a new entity must not adopt a reference stub, that one is never inserted
433
+ if (exists || Array.isArray(data) || options.newEntity) {
434
+ return exists;
435
+ }
436
+ // a reference created via `targetKey` is only known by that key until the entity is loaded
437
+ for (const key of meta.root.targetKeys ?? []) {
438
+ if (data[key] != null) {
439
+ const ref = this.unitOfWork.getByKey(meta.class, key, data[key], schema, options.convertCustomTypes);
440
+ if (ref && !helper(ref).__initialized) {
441
+ return ref;
442
+ }
443
+ }
444
+ }
445
+ return undefined;
446
+ }
447
+ findEntityByPrimaryKey(data, meta, schema, options) {
382
448
  if (meta.simplePK) {
383
449
  return this.unitOfWork.getById(meta.class, data[meta.primaryKeys[0]], schema);
384
450
  }
@@ -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;
@@ -311,18 +312,25 @@ export class EntityLoader {
311
312
  }
312
313
  initializeManyToMany(filtered, children, prop, field, customOrder, partial, readonly) {
313
314
  if (prop.mappedBy) {
315
+ // group children by owner in one pass
316
+ const map = new Map(filtered.map(entity => [entity, []]));
317
+ for (const child of children) {
318
+ for (const owner of child[prop.mappedBy].getItems(false)) {
319
+ map.get(owner)?.push(child);
320
+ }
321
+ }
314
322
  for (const entity of filtered) {
315
- const items = children.filter(child => child[prop.mappedBy].contains(entity, false));
316
- entity[field].hydrate(items, true, partial, readonly);
323
+ entity[field].hydrate(map.get(entity), true, partial, readonly);
317
324
  }
318
325
  }
319
326
  else {
320
327
  // owning side of M:N without pivot table needs to be reordered
328
+ const childIndex = new Map(children.map((child, idx) => [child, idx]));
321
329
  for (const entity of filtered) {
322
- const order = !customOrder ? [...entity[prop.name].getItems(false)] : []; // copy order of references
323
- const items = children.filter(child => entity[prop.name].contains(child, false));
324
- if (!customOrder) {
325
- items.sort((a, b) => order.indexOf(a) - order.indexOf(b));
330
+ // keep the order of references, or the order of loaded children when a custom order is used
331
+ const items = entity[prop.name].getItems(false).filter(item => childIndex.has(item));
332
+ if (customOrder) {
333
+ items.sort((a, b) => childIndex.get(a) - childIndex.get(b));
326
334
  }
327
335
  entity[field].hydrate(items, true, partial, readonly);
328
336
  }
@@ -672,12 +680,20 @@ export class EntityLoader {
672
680
  const subCond = Utils.isPlainObject(where[prop.name]) ? { ...where[prop.name] } : {};
673
681
  const meta2 = prop.targetMeta;
674
682
  const pk = Utils.getPrimaryKeyHash(meta2.primaryKeys);
683
+ // `$not` wrapping entity conditions stays on the target entity, only operators on the relation itself map to its PK
684
+ const isRelationOperator = (key, value) => {
685
+ if (key === '$not' && Utils.isPlainObject(value)) {
686
+ return Utils.getObjectQueryKeys(value).every(k => Utils.isOperator(k, false));
687
+ }
688
+ return Utils.isOperator(key, false);
689
+ };
675
690
  ['$and', '$or'].forEach(op => {
676
691
  if (where[op]) {
677
692
  const child = where[op]
678
693
  .map((cond) => cond[prop.name])
679
694
  .filter((sub) => sub != null &&
680
- !(Utils.isPlainObject(sub) && Utils.getObjectQueryKeys(sub).every(key => Utils.isOperator(key, false))))
695
+ !(Utils.isPlainObject(sub) &&
696
+ Utils.getObjectQueryKeys(sub).every(key => isRelationOperator(key, sub[key]))))
681
697
  .map((cond) => {
682
698
  if (Utils.isPrimaryKey(cond)) {
683
699
  return { [pk]: cond };
@@ -690,7 +706,7 @@ export class EntityLoader {
690
706
  }
691
707
  }
692
708
  });
693
- const operators = Object.keys(subCond).filter(key => Utils.isOperator(key, false));
709
+ const operators = Object.keys(subCond).filter(key => isRelationOperator(key, subCond[key]));
694
710
  if (operators.length > 0) {
695
711
  subCond[pk] = Utils.isPlainObject(subCond[pk]) ? { ...subCond[pk] } : (subCond[pk] ?? {});
696
712
  operators.forEach(op => {
@@ -808,7 +824,7 @@ export class EntityLoader {
808
824
  if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(wrapped.__meta.properties[f].kind)) {
809
825
  return entity[f].getItems(false).every((item) => this.isPropertyLoaded(item, r.join('.')));
810
826
  }
811
- return this.isPropertyLoaded(entity[f], r.join('.'));
827
+ return this.isPropertyLoaded(Reference.unwrapReference(entity[f]), r.join('.'));
812
828
  }
813
829
  filterReferences(entities, field, options, ref, populate) {
814
830
  if (ref) {
@@ -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,