@mikro-orm/core 7.2.2-dev.3 → 7.2.2-dev.30

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, {
@@ -994,13 +998,14 @@ export class EntityManager {
994
998
  });
995
999
  const uniqueFields = options.onConflictFields ??
996
1000
  (Utils.isPlainObject(where) ? Object.keys(where) : meta.primaryKeys);
997
- const returning = getOnConflictReturningFields(meta, data, uniqueFields, options);
1001
+ const platform = this.getPlatform();
1002
+ const returning = getOnConflictReturningFields(meta, data, uniqueFields, options, platform);
998
1003
  if (options.onConflictWhere && !ret.row) {
999
1004
  returning.push(...Object.keys(data));
1000
1005
  }
1001
1006
  if (options.onConflictAction === 'ignore' ||
1002
1007
  !helper(entity).hasPrimaryKey() ||
1003
- (returning.length > 0 && !(this.getPlatform().usesReturningStatement() && ret.row))) {
1008
+ (returning.length > 0 && !((platform.usesReturningStatement() || platform.usesOutputStatement()) && ret.row))) {
1004
1009
  const where = {};
1005
1010
  if (Array.isArray(uniqueFields)) {
1006
1011
  for (const prop of uniqueFields) {
@@ -1091,6 +1096,41 @@ export class EntityManager {
1091
1096
  }
1092
1097
  return ret;
1093
1098
  }
1099
+ // merge fields are derived from the first row, rows of a different shape need their own statement
1100
+ if (!options.onConflictMergeFields && options.onConflictAction !== 'ignore') {
1101
+ const pks = this.metadata.get(entityName).primaryKeys;
1102
+ const shapes = new Map();
1103
+ data.forEach((row, i) => {
1104
+ // entity instances carry their unset properties as `undefined` keys, while in plain rows those are merged as `null`
1105
+ const shape = Object.keys(row)
1106
+ .filter(key => !pks.includes(key) && !(Utils.isEntity(row) && row[key] === undefined))
1107
+ .sort()
1108
+ .join();
1109
+ const idx = shapes.get(shape) ?? [];
1110
+ shapes.set(shape, idx);
1111
+ idx.push(i);
1112
+ });
1113
+ if (shapes.size > 1) {
1114
+ const ret = [];
1115
+ for (const idx of shapes.values()) {
1116
+ const res = await this.upsertMany(entityName, idx.map(i => data[i]), options);
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
+ }
1129
+ }
1130
+ // rows resolving to the same entity are returned once, as in the single statement path
1131
+ return [...new Set(ret.filter(entity => entity))];
1132
+ }
1133
+ }
1094
1134
  // the rows are replaced with their processed copies below, keep the caller's array intact
1095
1135
  data = [...data];
1096
1136
  const meta = this.metadata.get(entityName);
@@ -1098,9 +1138,10 @@ export class EntityManager {
1098
1138
  const generatedFields = new Set();
1099
1139
  const allData = [];
1100
1140
  const allWhere = [];
1141
+ const dataIndexes = [];
1142
+ const result = [];
1101
1143
  const entities = new Map();
1102
1144
  const entitiesByData = new Map();
1103
- const entitiesByAllDataIdx = new Map();
1104
1145
  for (let i = 0; i < data.length; i++) {
1105
1146
  let row = data[i];
1106
1147
  let where;
@@ -1108,14 +1149,12 @@ export class EntityManager {
1108
1149
  const entity = row;
1109
1150
  if (helper(entity).__managed && helper(entity).__em === em && !this.config.get('upsertManaged')) {
1110
1151
  em.#entityFactory.mergeData(meta, entity, row, { initialized: true });
1111
- entities.set(entity, row);
1112
- entitiesByData.set(row, entity);
1152
+ result[i] = entity;
1113
1153
  continue;
1114
1154
  }
1115
1155
  where = helper(entity).getPrimaryKey();
1116
1156
  getOnCreateGeneratedFields(meta, entity).forEach(field => generatedFields.add(field));
1117
1157
  em.#entityFactory.assignDefaultValues(entity, meta);
1118
- entitiesByAllDataIdx.set(allData.length, entity);
1119
1158
  row = em.#comparator.prepareEntity(entity);
1120
1159
  }
1121
1160
  else {
@@ -1125,8 +1164,7 @@ export class EntityManager {
1125
1164
  const exists = em.#unitOfWork.getById(entityName, where, options.schema);
1126
1165
  if (exists) {
1127
1166
  em.assign(exists, row);
1128
- entities.set(exists, row);
1129
- entitiesByData.set(row, exists);
1167
+ result[i] = exists;
1130
1168
  continue;
1131
1169
  }
1132
1170
  }
@@ -1159,22 +1197,25 @@ export class EntityManager {
1159
1197
  validateParams(row, 'insert data');
1160
1198
  allData.push(row);
1161
1199
  allWhere.push(where);
1200
+ dataIndexes.push(i);
1162
1201
  }
1163
- if (entities.size === data.length) {
1164
- return [...entities.keys()];
1202
+ if (allData.length === 0) {
1203
+ return [...new Set(result)];
1165
1204
  }
1166
1205
  // `onCreate` generated values are for the insert clause only, they must not overwrite existing rows
1167
1206
  if (generatedFields.size > 0 && !options.onConflictMergeFields) {
1168
1207
  options.onConflictExcludeFields = [...(options.onConflictExcludeFields ?? []), ...generatedFields];
1169
1208
  }
1170
1209
  if (em.eventManager.hasListeners(EventType.beforeUpsert, meta)) {
1171
- for (const dto of data) {
1172
- const entity = entitiesByData.get(dto) ?? dto;
1210
+ for (const index of dataIndexes) {
1211
+ const entity = data[index];
1173
1212
  await em.eventManager.dispatchEvent(EventType.beforeUpsert, { entity, em, meta }, meta);
1174
1213
  }
1175
- for (const [idx, entity] of entitiesByAllDataIdx) {
1176
- allData[idx] = em.#comparator.prepareEntity(entity);
1177
- }
1214
+ dataIndexes.forEach((index, idx) => {
1215
+ if (Utils.isEntity(data[index])) {
1216
+ allData[idx] = em.#comparator.prepareEntity(data[index]);
1217
+ }
1218
+ });
1178
1219
  }
1179
1220
  const res = await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => em.driver.nativeUpdateMany(entityName, allWhere, allData, {
1180
1221
  upsert: true,
@@ -1182,15 +1223,16 @@ export class EntityManager {
1182
1223
  ...options,
1183
1224
  ctx,
1184
1225
  }));
1185
- entities.clear();
1186
- entitiesByData.clear();
1187
1226
  const loadPK = new Map();
1188
1227
  allData.forEach((row, i) => {
1189
- 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,
1190
1232
  // A suppressed conflict omits its row; positional mapping would hydrate a different entity.
1191
1233
  res.rows?.length === allData.length ? res.rows[i] : undefined, meta, true);
1192
- const entity = Utils.isEntity(data[i])
1193
- ? data[i]
1234
+ const entity = Utils.isEntity(item)
1235
+ ? item
1194
1236
  : em.#entityFactory.create(entityName, row, {
1195
1237
  refresh: true,
1196
1238
  initialized: true,
@@ -1201,16 +1243,19 @@ export class EntityManager {
1201
1243
  }
1202
1244
  entities.set(entity, row);
1203
1245
  entitiesByData.set(row, entity);
1246
+ result[index] = entity;
1204
1247
  });
1205
1248
  // skip the reload only when RETURNING brought back a row for every input
1206
1249
  // (onConflictWhere can suppress writes, leaving some rows out)
1207
1250
  // oxfmt-ignore
1208
1251
  const uniqueFields = options.onConflictFields ?? (Utils.isPlainObject(allWhere[0]) ? Object.keys(allWhere[0]).flatMap(key => Utils.splitPrimaryKeys(key)) : meta.primaryKeys);
1209
- const returning = getOnConflictReturningFields(meta, data[0], uniqueFields, options);
1252
+ const platform = this.getPlatform();
1253
+ const returning = getOnConflictReturningFields(meta, data[dataIndexes[0]], uniqueFields, options, platform);
1210
1254
  if (options.onConflictWhere && res.rows?.length !== allData.length) {
1211
1255
  returning.push(...meta.comparableProps.filter(p => !p.lazy && !p.embeddable).map(p => p.name));
1212
1256
  }
1213
- const reloadFields = returning.length > 0 && !(this.getPlatform().usesReturningStatement() && res.rows?.length === data.length);
1257
+ const reloadFields = returning.length > 0 &&
1258
+ !((platform.usesReturningStatement() || platform.usesOutputStatement()) && res.rows?.length === allData.length);
1214
1259
  if (options.onConflictAction === 'ignore' || (!res.rows?.length && loadPK.size > 0) || reloadFields) {
1215
1260
  const unique = meta.hydrateProps.filter(p => !p.lazy).map(p => p.name);
1216
1261
  const add = new Set(propIndex !== false && propIndex >= 0
@@ -1220,7 +1265,8 @@ export class EntityManager {
1220
1265
  Utils.keys(cond).forEach(key => add.add(key));
1221
1266
  }
1222
1267
  const where = { $or: [] };
1223
- data.forEach((item, idx) => {
1268
+ dataIndexes.forEach((index, idx) => {
1269
+ const item = data[index];
1224
1270
  where.$or[idx] = {};
1225
1271
  const props = Array.isArray(uniqueFields) ? uniqueFields : Object.keys(item);
1226
1272
  props.forEach(prop => {
@@ -1290,7 +1336,7 @@ export class EntityManager {
1290
1336
  await em.eventManager.dispatchEvent(EventType.afterUpsert, { entity, em, meta }, meta);
1291
1337
  }
1292
1338
  }
1293
- return [...entities.keys()];
1339
+ return [...new Set(result)];
1294
1340
  }
1295
1341
  /**
1296
1342
  * Runs your callback wrapped inside a database transaction.
@@ -1947,6 +1993,13 @@ export class EntityManager {
1947
1993
  isInTransaction() {
1948
1994
  return !!this.getContext(false).#transactionContext;
1949
1995
  }
1996
+ /**
1997
+ * Checks whether transactions are disabled for this EntityManager.
1998
+ * @internal
1999
+ */
2000
+ isTransactionDisabled() {
2001
+ return !!this.getContext(false).#disableTransactions;
2002
+ }
1950
2003
  /**
1951
2004
  * Gets the transaction context (driver dependent object used to make sure queries are executed on same connection).
1952
2005
  */
@@ -2302,7 +2355,7 @@ export class EntityManager {
2302
2355
  /**
2303
2356
  * @internal
2304
2357
  */
2305
- async tryCache(entityName, config, key, refresh, merge) {
2358
+ async tryCache(entityName, config, key, refresh, merge, schema) {
2306
2359
  config ??= this.config.get('resultCache').global;
2307
2360
  if (!config) {
2308
2361
  return undefined;
@@ -2324,6 +2377,7 @@ export class EntityManager {
2324
2377
  merge: true,
2325
2378
  convertCustomTypes: false,
2326
2379
  refresh,
2380
+ schema,
2327
2381
  recomputeSnapshot: true,
2328
2382
  };
2329
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,
@@ -267,7 +267,7 @@ export declare class UniversalPropertyOptionsBuilder<in out Value, in out Option
267
267
  autoincrement: false;
268
268
  }, IncludeKeys>, IncludeKeys>;
269
269
  /**
270
- * Add the property to the `returning` statement.
270
+ * Add the property to the `returning` clause of inserts, updates and upserts, reloading the database value into the entity.
271
271
  */
272
272
  returning(returning?: boolean): Pick<UniversalPropertyOptionsBuilder<Value, Options, IncludeKeys>, IncludeKeys>;
273
273
  /**
@@ -88,7 +88,7 @@ export class UniversalPropertyOptionsBuilder {
88
88
  return this.assignOptions({ autoincrement });
89
89
  }
90
90
  /**
91
- * Add the property to the `returning` statement.
91
+ * Add the property to the `returning` clause of inserts, updates and upserts, reloading the database value into the entity.
92
92
  */
93
93
  returning(returning = true) {
94
94
  return this.assignOptions({ returning });
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;
@@ -128,6 +128,8 @@ export declare class MetadataDiscovery {
128
128
  private initRelation;
129
129
  /** Resolves the `through` option of a virtual to-one relation into a read-only formula property. */
130
130
  private initThroughRelation;
131
+ /** `returning` is only honoured for physical columns that can be hydrated back into the entity. */
132
+ private initReturning;
131
133
  private initColumnType;
132
134
  private getMappedType;
133
135
  private getPrefix;
@@ -174,6 +174,7 @@ export class MetadataDiscovery {
174
174
  this.initRelation(p);
175
175
  this.initThroughRelation(m, p);
176
176
  this.initColumnType(p);
177
+ this.initReturning(p);
177
178
  });
178
179
  forEachProp((m, p) => this.initIndexes(m, p));
179
180
  filtered.forEach(meta => this.autoWireBidirectionalProperties(meta));
@@ -546,7 +547,7 @@ export class MetadataDiscovery {
546
547
  const fieldNames1 = prop.targetMeta.getPrimaryProps().flatMap(pk => pk.fieldNames);
547
548
  const idColumns = fieldNames1.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.discriminator, fieldName, fieldNames1.length > 1));
548
549
  prop.fieldNames ??= [prop.discriminatorColumn, ...idColumns];
549
- prop.joinColumns ??= idColumns;
550
+ prop.joinColumns ??= prop.fieldNames.slice(1);
550
551
  prop.referencedColumnNames ??= fieldNames1;
551
552
  prop.referencedTableName ??= prop.targetMeta.tableName;
552
553
  return;
@@ -563,7 +564,10 @@ export class MetadataDiscovery {
563
564
  }
564
565
  Utils.defaultValue(prop, 'referencedTableName', meta2.tableName);
565
566
  if (!prop.joinColumns) {
566
- 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));
567
571
  }
568
572
  if (!prop.referencedColumnNames) {
569
573
  prop.referencedColumnNames = fieldNames;
@@ -669,6 +673,12 @@ export class MetadataDiscovery {
669
673
  if (prop2.kind !== ReferenceKind.SCALAR && prop2.type === meta.className) {
670
674
  meta.referencingProperties.push({ meta: meta2, prop: prop2 });
671
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
+ }
672
682
  }
673
683
  }
674
684
  }
@@ -1173,12 +1183,24 @@ export class MetadataDiscovery {
1173
1183
  if (!prop.discriminator && !prop.discriminatorColumn && !prop.discriminatorMap && !Array.isArray(prop.target)) {
1174
1184
  return;
1175
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);
1176
1188
  prop.polymorphic = true;
1177
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
+ }
1178
1198
  prop.discriminatorColumn ??= this.#namingStrategy.discriminatorColumnName(prop.discriminator);
1179
1199
  prop.createForeignKeyConstraint = false;
1180
- const isToOne = [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind);
1181
- 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
+ }
1182
1204
  if (isToOne || isUnionTargetMN) {
1183
1205
  const types = prop.type.split(/ ?\| ?/);
1184
1206
  prop.polymorphTargets = discovered.filter(m => types.includes(m.className) && !m.embeddable);
@@ -1438,7 +1460,8 @@ export class MetadataDiscovery {
1438
1460
  }
1439
1461
  }
1440
1462
  rootProp.stiFieldNameMap[meta.discriminatorValue] = prop.fieldNames[0];
1441
- rootProp.stiFieldNames.push(...prop.fieldNames);
1463
+ // subtypes that only differ in type share the column
1464
+ rootProp.stiFieldNames = Utils.unique([...rootProp.stiFieldNames, ...prop.fieldNames]);
1442
1465
  newProp.nullable = true;
1443
1466
  newProp.name = name;
1444
1467
  newProp.hydrate = false;
@@ -2129,6 +2152,16 @@ export class MetadataDiscovery {
2129
2152
  prop.persist = false;
2130
2153
  prop.formula = columns => this.#platform.getThroughRelationFormula(prop, columns);
2131
2154
  }
2155
+ /** `returning` is only honoured for physical columns that can be hydrated back into the entity. */
2156
+ initReturning(prop) {
2157
+ const owning = prop.kind === ReferenceKind.SCALAR ||
2158
+ prop.kind === ReferenceKind.MANY_TO_ONE ||
2159
+ (prop.kind === ReferenceKind.ONE_TO_ONE && prop.owner) ||
2160
+ (prop.kind === ReferenceKind.EMBEDDED && prop.object);
2161
+ if (prop.returning && (!owning || prop.persist === false || prop.hydrate === false || prop.formula)) {
2162
+ prop.returning = false;
2163
+ }
2164
+ }
2132
2165
  initColumnType(prop) {
2133
2166
  this.initUnsigned(prop);
2134
2167
  // Get the target properties for FK relations - use targetKey property if specified, otherwise PKs
@@ -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);