@mikro-orm/core 7.0.0-dev.156 → 7.0.0-dev.157

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.
@@ -382,10 +382,26 @@ export declare class EntityManager<Driver extends IDatabaseDriver = IDatabaseDri
382
382
  * Shortcut for `wrap(entity).assign(data, { em })`
383
383
  */
384
384
  assign<Entity extends object, Naked extends FromEntityType<Entity> = FromEntityType<Entity>, Convert extends boolean = false, Data extends EntityData<Naked, Convert> | Partial<EntityDTO<Naked>> = EntityData<Naked, Convert> | Partial<EntityDTO<Naked>>>(entity: Entity | Partial<Entity>, data: Data & IsSubset<EntityData<Naked, Convert>, Data>, options?: AssignOptions<Convert>): MergeSelected<Entity, Naked, keyof Data & string>;
385
+ /**
386
+ * Gets a reference to the entity identified by the given type and alternate key property without actually loading it.
387
+ * The key option specifies which property to use for identity map lookup instead of the primary key.
388
+ */
389
+ getReference<Entity extends object, K extends string & keyof Entity>(entityName: EntityName<Entity>, id: Entity[K], options: Omit<GetReferenceOptions, 'key' | 'wrapped'> & {
390
+ key: K;
391
+ wrapped: true;
392
+ }): Ref<Entity>;
393
+ /**
394
+ * Gets a reference to the entity identified by the given type and alternate key property without actually loading it.
395
+ * The key option specifies which property to use for identity map lookup instead of the primary key.
396
+ */
397
+ getReference<Entity extends object, K extends string & keyof Entity>(entityName: EntityName<Entity>, id: Entity[K], options: Omit<GetReferenceOptions, 'key'> & {
398
+ key: K;
399
+ wrapped?: false;
400
+ }): Entity;
385
401
  /**
386
402
  * Gets a reference to the entity identified by the given type and identifier without actually loading it, if the entity is not yet loaded
387
403
  */
388
- getReference<Entity extends object>(entityName: EntityName<Entity>, id: Primary<Entity>, options: Omit<GetReferenceOptions, 'wrapped'> & {
404
+ getReference<Entity extends object>(entityName: EntityName<Entity>, id: Primary<Entity>, options: Omit<GetReferenceOptions, 'wrapped' | 'key'> & {
389
405
  wrapped: true;
390
406
  }): Ref<Entity>;
391
407
  /**
@@ -395,7 +411,7 @@ export declare class EntityManager<Driver extends IDatabaseDriver = IDatabaseDri
395
411
  /**
396
412
  * Gets a reference to the entity identified by the given type and identifier without actually loading it, if the entity is not yet loaded
397
413
  */
398
- getReference<Entity extends object>(entityName: EntityName<Entity>, id: Primary<Entity>, options: Omit<GetReferenceOptions, 'wrapped'> & {
414
+ getReference<Entity extends object>(entityName: EntityName<Entity>, id: Primary<Entity>, options: Omit<GetReferenceOptions, 'wrapped' | 'key'> & {
399
415
  wrapped: false;
400
416
  }): Entity;
401
417
  /**
@@ -247,4 +247,9 @@ export interface GetReferenceOptions {
247
247
  wrapped?: boolean;
248
248
  convertCustomTypes?: boolean;
249
249
  schema?: string;
250
+ /**
251
+ * Property name to use for identity map lookup instead of the primary key.
252
+ * This is useful for creating references by unique non-PK properties.
253
+ */
254
+ key?: string;
250
255
  }
@@ -16,6 +16,11 @@ export interface FactoryOptions {
16
16
  schema?: string;
17
17
  parentSchema?: string;
18
18
  normalizeAccessors?: boolean;
19
+ /**
20
+ * Property name to use for identity map lookup instead of the primary key.
21
+ * This is useful for creating references by unique non-PK properties.
22
+ */
23
+ key?: string;
19
24
  }
20
25
  export declare class EntityFactory {
21
26
  private readonly em;
@@ -29,7 +34,7 @@ export declare class EntityFactory {
29
34
  constructor(em: EntityManager);
30
35
  create<T extends object, P extends string = string>(entityName: EntityName<T>, data: EntityData<T>, options?: FactoryOptions): New<T, P>;
31
36
  mergeData<T extends object>(meta: EntityMetadata<T>, entity: T, data: EntityData<T>, options?: FactoryOptions): void;
32
- createReference<T extends object>(entityName: EntityName<T>, id: Primary<T> | Primary<T>[] | Record<string, Primary<T>>, options?: Pick<FactoryOptions, 'merge' | 'convertCustomTypes' | 'schema'>): T;
37
+ createReference<T extends object>(entityName: EntityName<T>, id: Primary<T> | Primary<T>[] | Record<string, Primary<T>>, options?: Pick<FactoryOptions, 'merge' | 'convertCustomTypes' | 'schema' | 'key'>): T;
33
38
  createEmbeddable<T extends object>(entityName: EntityName<T>, data: EntityData<T>, options?: Pick<FactoryOptions, 'newEntity' | 'convertCustomTypes'>): T;
34
39
  getComparator(): EntityComparator;
35
40
  private createEntity;
@@ -164,6 +164,18 @@ export class EntityFactory {
164
164
  options.convertCustomTypes ??= true;
165
165
  const meta = this.metadata.get(entityName);
166
166
  const schema = this.driver.getSchemaName(meta, options);
167
+ // Handle alternate key lookup
168
+ if (options.key) {
169
+ const value = '' + (Array.isArray(id) ? id[0] : Utils.isPlainObject(id) ? id[options.key] : id);
170
+ const exists = this.unitOfWork.getByKey(entityName, options.key, value, schema, options.convertCustomTypes);
171
+ if (exists) {
172
+ return exists;
173
+ }
174
+ // Create entity stub - storeByKey will set the alternate key property and store in identity map
175
+ const entity = this.create(entityName, {}, { ...options, initialized: false });
176
+ this.unitOfWork.storeByKey(entity, options.key, value, schema, options.convertCustomTypes);
177
+ return entity;
178
+ }
167
179
  if (meta.simplePK) {
168
180
  const exists = this.unitOfWork.getById(entityName, id, schema);
169
181
  if (exists) {
@@ -15,7 +15,7 @@ export type EntityLoaderOptions<Entity, Fields extends string = PopulatePath.ALL
15
15
  convertCustomTypes?: boolean;
16
16
  ignoreLazyScalarProperties?: boolean;
17
17
  filters?: FilterOptions;
18
- strategy?: LoadStrategy;
18
+ strategy?: LoadStrategy | `${LoadStrategy}`;
19
19
  lockMode?: Exclude<LockMode, LockMode.OPTIMISTIC>;
20
20
  schema?: string;
21
21
  connectionType?: ConnectionType;
@@ -211,7 +211,8 @@ export class EntityLoader {
211
211
  async findChildren(entities, prop, populate, options, ref) {
212
212
  const children = Utils.unique(this.getChildReferences(entities, prop, options, ref));
213
213
  const meta = prop.targetMeta;
214
- let fk = Utils.getPrimaryKeyHash(meta.primaryKeys);
214
+ // When targetKey is set, use it for FK lookup instead of the PK
215
+ let fk = prop.targetKey ?? Utils.getPrimaryKeyHash(meta.primaryKeys);
215
216
  let schema = options.schema;
216
217
  const partial = !Utils.isEmpty(prop.where) || !Utils.isEmpty(options.where);
217
218
  if (prop.kind === ReferenceKind.ONE_TO_MANY || (prop.kind === ReferenceKind.MANY_TO_MANY && !prop.owner)) {
@@ -228,7 +229,8 @@ export class EntityLoader {
228
229
  if (!schema && [ReferenceKind.ONE_TO_ONE, ReferenceKind.MANY_TO_ONE].includes(prop.kind)) {
229
230
  schema = children.find(e => e.__helper.__schema)?.__helper.__schema;
230
231
  }
231
- const ids = Utils.unique(children.map(e => e.__helper.getPrimaryKey()));
232
+ // When targetKey is set, get the targetKey value instead of PK
233
+ const ids = Utils.unique(children.map(e => prop.targetKey ? e[prop.targetKey] : e.__helper.getPrimaryKey()));
232
234
  let where = this.mergePrimaryCondition(ids, fk, options, meta, this.metadata, this.driver.getPlatform());
233
235
  const fields = this.buildFields(options.fields, prop, ref);
234
236
  /* eslint-disable prefer-const */
@@ -255,19 +257,44 @@ export class EntityLoader {
255
257
  // @ts-ignore not a public option, will be propagated to the populate call
256
258
  visited: options.visited,
257
259
  });
260
+ // For targetKey relations, wire up loaded entities to parent references
261
+ // This is needed because the references were created under alternate key,
262
+ // but loaded entities are stored under PK, so they don't automatically merge
263
+ if (prop.targetKey && [ReferenceKind.ONE_TO_ONE, ReferenceKind.MANY_TO_ONE].includes(prop.kind)) {
264
+ const itemsByKey = new Map();
265
+ for (const item of items) {
266
+ itemsByKey.set('' + item[prop.targetKey], item);
267
+ }
268
+ for (const entity of entities) {
269
+ const ref = entity[prop.name];
270
+ /* v8 ignore next */
271
+ if (!ref) {
272
+ continue;
273
+ }
274
+ const keyValue = '' + (Reference.isReference(ref) ? ref.unwrap()[prop.targetKey] : ref[prop.targetKey]);
275
+ const loadedItem = itemsByKey.get(keyValue);
276
+ if (loadedItem) {
277
+ entity[prop.name] = (Reference.isReference(ref) ? Reference.create(loadedItem) : loadedItem);
278
+ }
279
+ }
280
+ }
258
281
  if ([ReferenceKind.ONE_TO_ONE, ReferenceKind.MANY_TO_ONE].includes(prop.kind) && items.length !== children.length) {
259
282
  const nullVal = this.em.config.get('forceUndefined') ? undefined : null;
260
283
  const itemsMap = new Set();
261
284
  const childrenMap = new Set();
285
+ // Use targetKey value if set, otherwise use serialized PK
286
+ const getKey = (e) => prop.targetKey ? '' + e[prop.targetKey] : helper(e).getSerializedPrimaryKey();
262
287
  for (const item of items) {
263
- itemsMap.add(helper(item).getSerializedPrimaryKey());
288
+ /* v8 ignore next */
289
+ itemsMap.add(getKey(item));
264
290
  }
265
291
  for (const child of children) {
266
- childrenMap.add(helper(child).getSerializedPrimaryKey());
292
+ childrenMap.add(getKey(child));
267
293
  }
268
294
  for (const entity of entities) {
269
- const key = helper(entity[prop.name] ?? {})?.getSerializedPrimaryKey();
270
- if (childrenMap.has(key) && !itemsMap.has(key)) {
295
+ const ref = entity[prop.name] ?? {};
296
+ const key = helper(ref) ? getKey(ref) : undefined;
297
+ if (key && childrenMap.has(key) && !itemsMap.has(key)) {
271
298
  entity[prop.name] = nullVal;
272
299
  helper(entity).__originalEntityData[prop.name] = null;
273
300
  }
@@ -111,10 +111,26 @@ export declare class EntityRepository<Entity extends object> {
111
111
  map(result: EntityDictionary<Entity>, options?: {
112
112
  schema?: string;
113
113
  }): Entity;
114
+ /**
115
+ * Gets a reference to the entity identified by the given type and alternate key property without actually loading it.
116
+ * The key option specifies which property to use for identity map lookup instead of the primary key.
117
+ */
118
+ getReference<K extends string & keyof Entity>(id: Entity[K], options: Omit<GetReferenceOptions, 'key' | 'wrapped'> & {
119
+ key: K;
120
+ wrapped: true;
121
+ }): Ref<Entity>;
122
+ /**
123
+ * Gets a reference to the entity identified by the given type and alternate key property without actually loading it.
124
+ * The key option specifies which property to use for identity map lookup instead of the primary key.
125
+ */
126
+ getReference<K extends string & keyof Entity>(id: Entity[K], options: Omit<GetReferenceOptions, 'key'> & {
127
+ key: K;
128
+ wrapped?: false;
129
+ }): Entity;
114
130
  /**
115
131
  * Gets a reference to the entity identified by the given type and identifier without actually loading it, if the entity is not yet loaded
116
132
  */
117
- getReference(id: Primary<Entity>, options: Omit<GetReferenceOptions, 'wrapped'> & {
133
+ getReference(id: Primary<Entity>, options: Omit<GetReferenceOptions, 'wrapped' | 'key'> & {
118
134
  wrapped: true;
119
135
  }): Ref<Entity>;
120
136
  /**
@@ -124,7 +140,7 @@ export declare class EntityRepository<Entity extends object> {
124
140
  /**
125
141
  * Gets a reference to the entity identified by the given type and identifier without actually loading it, if the entity is not yet loaded
126
142
  */
127
- getReference(id: Primary<Entity>, options: Omit<GetReferenceOptions, 'wrapped'> & {
143
+ getReference(id: Primary<Entity>, options: Omit<GetReferenceOptions, 'wrapped' | 'key'> & {
128
144
  wrapped: false;
129
145
  }): Entity;
130
146
  /**
@@ -339,6 +339,8 @@ export declare class UniversalPropertyOptionsBuilder<Value, Options, IncludeKeys
339
339
  referenceColumnName(referenceColumnName: string): Pick<UniversalPropertyOptionsBuilder<Value, Options, IncludeKeys>, IncludeKeys>;
340
340
  /** Override the default database column name on the target entity (see {@doclink naming-strategy | Naming Strategy}). This option is suitable for composite keys, where one property is represented by multiple columns. */
341
341
  referencedColumnNames(...referencedColumnNames: string[]): Pick<UniversalPropertyOptionsBuilder<Value, Options, IncludeKeys>, IncludeKeys>;
342
+ /** Specify the property name on the target entity that this FK references instead of the primary key. */
343
+ targetKey(targetKey: string): Pick<UniversalPropertyOptionsBuilder<Value, Options, IncludeKeys>, IncludeKeys>;
342
344
  /** What to do when the target entity gets deleted. */
343
345
  deleteRule(deleteRule: 'cascade' | 'no action' | 'set null' | 'set default' | AnyString): Pick<UniversalPropertyOptionsBuilder<Value, Options, IncludeKeys>, IncludeKeys>;
344
346
  /** What to do when the reference to the target entity gets updated. */
@@ -409,6 +409,10 @@ export class UniversalPropertyOptionsBuilder {
409
409
  referencedColumnNames(...referencedColumnNames) {
410
410
  return this.assignOptions({ referencedColumnNames });
411
411
  }
412
+ /** Specify the property name on the target entity that this FK references instead of the primary key. */
413
+ targetKey(targetKey) {
414
+ return this.assignOptions({ targetKey });
415
+ }
412
416
  /** What to do when the target entity gets deleted. */
413
417
  deleteRule(deleteRule) {
414
418
  return this.assignOptions({ deleteRule });
package/errors.d.ts CHANGED
@@ -62,6 +62,9 @@ export declare class MetadataError<T extends AnyEntity = AnyEntity> extends Vali
62
62
  static nonPersistentCompositeProp(meta: EntityMetadata, prop: EntityProperty): MetadataError<Partial<any>>;
63
63
  static propertyTargetsEntityType(meta: EntityMetadata, prop: EntityProperty, target: EntityMetadata): MetadataError<Partial<any>>;
64
64
  static fromMissingOption(meta: EntityMetadata, prop: EntityProperty, option: string): MetadataError<Partial<any>>;
65
+ static targetKeyOnManyToMany(meta: EntityMetadata, prop: EntityProperty): MetadataError<Partial<any>>;
66
+ static targetKeyNotUnique(meta: EntityMetadata, prop: EntityProperty): MetadataError<Partial<any>>;
67
+ static targetKeyNotFound(meta: EntityMetadata, prop: EntityProperty): MetadataError<Partial<any>>;
65
68
  static dangerousPropertyName(meta: EntityMetadata, prop: EntityProperty): MetadataError<Partial<any>>;
66
69
  private static fromMessage;
67
70
  }
package/errors.js CHANGED
@@ -213,6 +213,15 @@ export class MetadataError extends ValidationError {
213
213
  static fromMissingOption(meta, prop, option) {
214
214
  return this.fromMessage(meta, prop, `is missing '${option}' option`);
215
215
  }
216
+ static targetKeyOnManyToMany(meta, prop) {
217
+ return this.fromMessage(meta, prop, `uses 'targetKey' option which is not supported for ManyToMany relations`);
218
+ }
219
+ static targetKeyNotUnique(meta, prop) {
220
+ return this.fromMessage(meta, prop, `has 'targetKey' set to '${prop.targetKey}', but ${prop.type}.${prop.targetKey} is not marked as unique. The target property must have a unique constraint.`);
221
+ }
222
+ static targetKeyNotFound(meta, prop) {
223
+ return this.fromMessage(meta, prop, `has 'targetKey' set to '${prop.targetKey}', but ${prop.type}.${prop.targetKey} does not exist`);
224
+ }
216
225
  static dangerousPropertyName(meta, prop) {
217
226
  return this.fromMessage(meta, prop, `uses a dangerous property name '${prop.name}' which could lead to prototype pollution. Please use a different property name.`);
218
227
  }
@@ -137,11 +137,13 @@ export class ObjectHydrator extends Hydrator {
137
137
  ret.push(` if (isPrimaryKey(data${dataKey}, true)) {`);
138
138
  const targetKey = this.safeKey(`${prop.targetMeta.tableName}_${this.tmpIndex++}`);
139
139
  context.set(targetKey, prop.targetMeta.class);
140
+ // When targetKey is set, pass the key option to createReference so it uses the alternate key
141
+ const keyOption = prop.targetKey ? `, key: '${prop.targetKey}'` : '';
140
142
  if (prop.ref) {
141
- ret.push(` entity${entityKey} = Reference.create(factory.createReference(${targetKey}, data${dataKey}, { merge: true, convertCustomTypes, normalizeAccessors, schema }));`);
143
+ ret.push(` entity${entityKey} = Reference.create(factory.createReference(${targetKey}, data${dataKey}, { merge: true, convertCustomTypes, normalizeAccessors, schema${keyOption} }));`);
142
144
  }
143
145
  else {
144
- ret.push(` entity${entityKey} = factory.createReference(${targetKey}, data${dataKey}, { merge: true, convertCustomTypes, normalizeAccessors, schema });`);
146
+ ret.push(` entity${entityKey} = factory.createReference(${targetKey}, data${dataKey}, { merge: true, convertCustomTypes, normalizeAccessors, schema${keyOption} });`);
145
147
  }
146
148
  ret.push(` } else if (data${dataKey} && typeof data${dataKey} === 'object') {`);
147
149
  if (prop.ref) {
@@ -438,7 +438,15 @@ export class MetadataDiscovery {
438
438
  }
439
439
  initManyToOneFields(prop) {
440
440
  const meta2 = prop.targetMeta;
441
- const fieldNames = Utils.flatten(meta2.primaryKeys.map(primaryKey => meta2.properties[primaryKey].fieldNames));
441
+ let fieldNames;
442
+ // If targetKey is specified, use that property's field names instead of PKs
443
+ if (prop.targetKey) {
444
+ const targetProp = meta2.properties[prop.targetKey];
445
+ fieldNames = targetProp.fieldNames;
446
+ }
447
+ else {
448
+ fieldNames = Utils.flatten(meta2.primaryKeys.map(primaryKey => meta2.properties[primaryKey].fieldNames));
449
+ }
442
450
  Utils.defaultValue(prop, 'referencedTableName', meta2.tableName);
443
451
  if (!prop.joinColumns) {
444
452
  prop.joinColumns = fieldNames.map(fieldName => this.namingStrategy.joinKeyColumnName(prop.name, fieldName, fieldNames.length > 1));
@@ -1238,7 +1246,8 @@ export class MetadataDiscovery {
1238
1246
  }
1239
1247
  // when the target is a polymorphic embedded entity, `prop.target` is an array of classes, we need to get the metadata by the type name instead
1240
1248
  const meta2 = this.metadata.find(prop.target) ?? this.metadata.getByClassName(prop.type);
1241
- prop.referencedPKs = meta2.primaryKeys;
1249
+ // If targetKey is specified, use that property instead of PKs for referencedPKs
1250
+ prop.referencedPKs = prop.targetKey ? [prop.targetKey] : meta2.primaryKeys;
1242
1251
  prop.targetMeta = meta2;
1243
1252
  if (!prop.formula && prop.persist === false && [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) && !prop.embedded) {
1244
1253
  prop.formula = table => `${table}.${this.platform.quoteIdentifier(prop.fieldNames[0])}`;
@@ -1246,10 +1255,14 @@ export class MetadataDiscovery {
1246
1255
  }
1247
1256
  initColumnType(prop) {
1248
1257
  this.initUnsigned(prop);
1249
- prop.targetMeta?.getPrimaryProps().map(pk => {
1250
- prop.length ??= pk.length;
1251
- prop.precision ??= pk.precision;
1252
- prop.scale ??= pk.scale;
1258
+ // Get the target properties for FK relations - use targetKey property if specified, otherwise PKs
1259
+ const targetProps = prop.targetMeta
1260
+ ? (prop.targetKey ? [prop.targetMeta.properties[prop.targetKey]] : prop.targetMeta.getPrimaryProps())
1261
+ : [];
1262
+ targetProps.map(targetProp => {
1263
+ prop.length ??= targetProp.length;
1264
+ prop.precision ??= targetProp.precision;
1265
+ prop.scale ??= targetProp.scale;
1253
1266
  });
1254
1267
  if (prop.kind === ReferenceKind.SCALAR && (prop.type == null || prop.type === 'object') && prop.columnTypes?.[0]) {
1255
1268
  delete prop.type;
@@ -1282,17 +1295,21 @@ export class MetadataDiscovery {
1282
1295
  }
1283
1296
  const targetMeta = prop.targetMeta;
1284
1297
  prop.columnTypes = [];
1285
- for (const pk of targetMeta.getPrimaryProps()) {
1286
- this.initCustomType(targetMeta, pk);
1287
- this.initColumnType(pk);
1288
- const mappedType = this.getMappedType(pk);
1289
- let columnTypes = pk.columnTypes;
1290
- if (pk.autoincrement) {
1291
- columnTypes = [mappedType.getColumnType({ ...pk, autoincrement: false }, this.platform)];
1298
+ // Use targetKey property if specified, otherwise use primary key properties
1299
+ const referencedProps = prop.targetKey
1300
+ ? [targetMeta.properties[prop.targetKey]]
1301
+ : targetMeta.getPrimaryProps();
1302
+ for (const referencedProp of referencedProps) {
1303
+ this.initCustomType(targetMeta, referencedProp);
1304
+ this.initColumnType(referencedProp);
1305
+ const mappedType = this.getMappedType(referencedProp);
1306
+ let columnTypes = referencedProp.columnTypes;
1307
+ if (referencedProp.autoincrement) {
1308
+ columnTypes = [mappedType.getColumnType({ ...referencedProp, autoincrement: false }, this.platform)];
1292
1309
  }
1293
1310
  prop.columnTypes.push(...columnTypes);
1294
- if (!targetMeta.compositePK) {
1295
- prop.customType = pk.customType;
1311
+ if (!targetMeta.compositePK || prop.targetKey) {
1312
+ prop.customType = referencedProp.customType;
1296
1313
  }
1297
1314
  }
1298
1315
  }
@@ -8,6 +8,7 @@ export declare class MetadataValidator {
8
8
  validateEntityDefinition<T>(metadata: MetadataStorage, name: EntityName<T>, options: MetadataDiscoveryOptions): void;
9
9
  validateDiscovered(discovered: EntityMetadata[], options: MetadataDiscoveryOptions): void;
10
10
  private validateReference;
11
+ private validateTargetKey;
11
12
  private validateBidirectional;
12
13
  private validateOwningSide;
13
14
  private validateInverseSide;
@@ -110,6 +110,25 @@ export class MetadataValidator {
110
110
  if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) && prop.persist === false && targetMeta.compositePK && options.checkNonPersistentCompositeProps) {
111
111
  throw MetadataError.nonPersistentCompositeProp(meta, prop);
112
112
  }
113
+ this.validateTargetKey(meta, prop, targetMeta);
114
+ }
115
+ validateTargetKey(meta, prop, targetMeta) {
116
+ if (!prop.targetKey) {
117
+ return;
118
+ }
119
+ // targetKey is not supported for ManyToMany relations
120
+ if (prop.kind === ReferenceKind.MANY_TO_MANY) {
121
+ throw MetadataError.targetKeyOnManyToMany(meta, prop);
122
+ }
123
+ // targetKey must point to an existing property
124
+ const targetProp = targetMeta.properties[prop.targetKey];
125
+ if (!targetProp) {
126
+ throw MetadataError.targetKeyNotFound(meta, prop);
127
+ }
128
+ // targetKey must point to a unique property
129
+ if (!targetProp.unique && !targetMeta.uniques?.some(u => u.properties?.includes(prop.targetKey))) {
130
+ throw MetadataError.targetKeyNotUnique(meta, prop);
131
+ }
113
132
  }
114
133
  validateBidirectional(meta, prop) {
115
134
  if (prop.inversedBy) {
@@ -342,6 +342,8 @@ export interface ManyToOneOptions<Owner, Target> extends ReferenceOptions<Owner,
342
342
  referenceColumnName?: string;
343
343
  /** Override the default database column name on the target entity (see {@doclink naming-strategy | Naming Strategy}). This option is suitable for composite keys, where one property is represented by multiple columns. */
344
344
  referencedColumnNames?: string[];
345
+ /** Specify the property name on the target entity that this FK references instead of the primary key. */
346
+ targetKey?: string & keyof Target;
345
347
  /** What to do when the target entity gets deleted. */
346
348
  deleteRule?: 'cascade' | 'no action' | 'set null' | 'set default' | AnyString;
347
349
  /** What to do when the reference to the target entity gets updated. */
@@ -388,6 +390,8 @@ export interface OneToOneOptions<Owner, Target> extends Partial<Omit<OneToManyOp
388
390
  mapToPk?: boolean;
389
391
  /** When a part of a composite column is shared in other properties, use this option to specify what columns are considered as owned by this property. This is useful when your composite property is nullable, but parts of it are not. */
390
392
  ownColumns?: string[];
393
+ /** Specify the property name on the target entity that this FK references instead of the primary key. */
394
+ targetKey?: string & keyof Target;
391
395
  /** What to do when the target entity gets deleted. */
392
396
  deleteRule?: 'cascade' | 'no action' | 'set null' | 'set default' | AnyString;
393
397
  /** What to do when the reference to the target entity gets updated. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mikro-orm/core",
3
3
  "type": "module",
4
- "version": "7.0.0-dev.156",
4
+ "version": "7.0.0-dev.157",
5
5
  "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.",
6
6
  "exports": {
7
7
  "./package.json": "./package.json",
package/typings.d.ts CHANGED
@@ -422,6 +422,7 @@ export interface EntityProperty<Owner = any, Target = any> {
422
422
  referencedColumnNames: string[];
423
423
  referencedTableName: string;
424
424
  referencedPKs: EntityKey<Owner>[];
425
+ targetKey?: string;
425
426
  serializer?: (value: any, options?: SerializeOptions<any>) => any;
426
427
  serializedName?: string;
427
428
  comment?: string;
@@ -132,7 +132,16 @@ export class ChangeSetComputer {
132
132
  const targets = Utils.unwrapProperty(changeSet.entity, changeSet.meta, prop);
133
133
  targets.forEach(([target, idx]) => {
134
134
  if (!target.__helper.hasPrimaryKey()) {
135
- Utils.setPayloadProperty(changeSet.payload, changeSet.meta, prop, target.__helper.__identifier, idx);
135
+ // When targetKey is set, use that property value instead of the PK identifier
136
+ let value = prop.targetKey ? target[prop.targetKey] : target.__helper.__identifier;
137
+ /* v8 ignore next */
138
+ if (prop.targetKey && prop.targetMeta) {
139
+ const targetProp = prop.targetMeta.properties[prop.targetKey];
140
+ if (targetProp?.customType) {
141
+ value = targetProp.customType.convertToDatabaseValue(value, this.platform, { mode: 'serialization' });
142
+ }
143
+ }
144
+ Utils.setPayloadProperty(changeSet.payload, changeSet.meta, prop, value, idx);
136
145
  }
137
146
  });
138
147
  }
@@ -145,7 +154,9 @@ export class ChangeSetComputer {
145
154
  this.collectionUpdates.add(target);
146
155
  }
147
156
  if (prop.owner && !this.platform.usesPivotTable()) {
148
- changeSet.payload[prop.name] = target.getItems(false).map((item) => item.__helper.__identifier ?? item.__helper.getPrimaryKey());
157
+ changeSet.payload[prop.name] = target.getItems(false).map((item) => {
158
+ return item.__helper.__identifier ?? item.__helper.getPrimaryKey();
159
+ });
149
160
  }
150
161
  }
151
162
  }
@@ -3,7 +3,14 @@ export declare class IdentityMap {
3
3
  private readonly defaultSchema?;
4
4
  constructor(defaultSchema?: string | undefined);
5
5
  private readonly registry;
6
+ /** Tracks alternate key hashes for each entity so we can clean them up on delete */
7
+ private readonly alternateKeys;
6
8
  store<T>(item: T): void;
9
+ /**
10
+ * Stores an entity under an alternate key (non-PK property).
11
+ * This allows looking up entities by unique properties that are not the primary key.
12
+ */
13
+ storeByKey<T>(item: T, key: string, value: string, schema?: string): void;
7
14
  delete<T>(item: T): void;
8
15
  getByHash<T>(meta: EntityMetadata<T>, hash: string): T | undefined;
9
16
  getStore<T>(meta: EntityMetadata<T>): Map<string, T>;
@@ -16,4 +23,9 @@ export declare class IdentityMap {
16
23
  */
17
24
  get<T>(hash: string): T | undefined;
18
25
  private getPkHash;
26
+ /**
27
+ * Creates a hash for an alternate key lookup.
28
+ * Format: `[key]value` or `schema:[key]value`
29
+ */
30
+ getKeyHash(key: string, value: string, schema?: string): string;
19
31
  }
@@ -4,11 +4,38 @@ export class IdentityMap {
4
4
  this.defaultSchema = defaultSchema;
5
5
  }
6
6
  registry = new Map();
7
+ /** Tracks alternate key hashes for each entity so we can clean them up on delete */
8
+ alternateKeys = new WeakMap();
7
9
  store(item) {
8
10
  this.getStore(item.__meta.root).set(this.getPkHash(item), item);
9
11
  }
12
+ /**
13
+ * Stores an entity under an alternate key (non-PK property).
14
+ * This allows looking up entities by unique properties that are not the primary key.
15
+ */
16
+ storeByKey(item, key, value, schema) {
17
+ const hash = this.getKeyHash(key, value, schema);
18
+ this.getStore(item.__meta.root).set(hash, item);
19
+ // Track this alternate key so we can clean it up when the entity is deleted
20
+ let keys = this.alternateKeys.get(item);
21
+ if (!keys) {
22
+ keys = new Set();
23
+ this.alternateKeys.set(item, keys);
24
+ }
25
+ keys.add(hash);
26
+ }
10
27
  delete(item) {
11
- this.getStore(item.__meta.root).delete(this.getPkHash(item));
28
+ const meta = item.__meta.root;
29
+ const store = this.getStore(meta);
30
+ store.delete(this.getPkHash(item));
31
+ // Also delete any alternate key entries for this entity
32
+ const altKeys = this.alternateKeys.get(item);
33
+ if (altKeys) {
34
+ for (const hash of altKeys) {
35
+ store.delete(hash);
36
+ }
37
+ this.alternateKeys.delete(item);
38
+ }
12
39
  }
13
40
  getByHash(meta, hash) {
14
41
  const store = this.getStore(meta);
@@ -69,4 +96,15 @@ export class IdentityMap {
69
96
  }
70
97
  return hash;
71
98
  }
99
+ /**
100
+ * Creates a hash for an alternate key lookup.
101
+ * Format: `[key]value` or `schema:[key]value`
102
+ */
103
+ getKeyHash(key, value, schema) {
104
+ const hash = `[${key}]${value}`;
105
+ if (schema) {
106
+ return schema + ':' + hash;
107
+ }
108
+ return hash;
109
+ }
72
110
  }
@@ -46,6 +46,19 @@ export declare class UnitOfWork {
46
46
  * Returns entity from the identity map. For composite keys, you need to pass an array of PKs in the same order as they are defined in `meta.primaryKeys`.
47
47
  */
48
48
  getById<T extends object>(entityName: EntityName<T>, id: Primary<T> | Primary<T>[], schema?: string, convertCustomTypes?: boolean): T | undefined;
49
+ /**
50
+ * Returns entity from the identity map by an alternate key (non-PK property).
51
+ * @param convertCustomTypes - If true, the value is in database format and will be converted to JS format for lookup.
52
+ * If false (default), the value is assumed to be in JS format already.
53
+ */
54
+ getByKey<T extends object>(entityName: EntityName<T>, key: string, value: unknown, schema?: string, convertCustomTypes?: boolean): T | undefined;
55
+ /**
56
+ * Stores an entity in the identity map under an alternate key (non-PK property).
57
+ * Also sets the property value on the entity.
58
+ * @param convertCustomTypes - If true, the value is in database format and will be converted to JS format.
59
+ * If false (default), the value is assumed to be in JS format already.
60
+ */
61
+ storeByKey<T extends object>(entity: T, key: string, value: unknown, schema?: string, convertCustomTypes?: boolean): void;
49
62
  tryGetById<T extends object>(entityName: EntityName<T>, where: FilterQuery<T>, schema?: string, strict?: boolean): T | null;
50
63
  /**
51
64
  * Returns map of all managed entities.
@@ -169,6 +169,40 @@ export class UnitOfWork {
169
169
  }
170
170
  return this.identityMap.getByHash(meta, hash);
171
171
  }
172
+ /**
173
+ * Returns entity from the identity map by an alternate key (non-PK property).
174
+ * @param convertCustomTypes - If true, the value is in database format and will be converted to JS format for lookup.
175
+ * If false (default), the value is assumed to be in JS format already.
176
+ */
177
+ getByKey(entityName, key, value, schema, convertCustomTypes) {
178
+ const meta = this.metadata.find(entityName).root;
179
+ schema ??= meta.schema ?? this.em.config.getSchema();
180
+ const prop = meta.properties[key];
181
+ // Convert from DB format to JS format if needed
182
+ if (convertCustomTypes && prop?.customType) {
183
+ value = prop.customType.convertToJSValue(value, this.platform, { mode: 'hydration' });
184
+ }
185
+ const hash = this.identityMap.getKeyHash(key, '' + value, schema);
186
+ return this.identityMap.getByHash(meta, hash);
187
+ }
188
+ /**
189
+ * Stores an entity in the identity map under an alternate key (non-PK property).
190
+ * Also sets the property value on the entity.
191
+ * @param convertCustomTypes - If true, the value is in database format and will be converted to JS format.
192
+ * If false (default), the value is assumed to be in JS format already.
193
+ */
194
+ storeByKey(entity, key, value, schema, convertCustomTypes) {
195
+ const meta = entity.__meta.root;
196
+ schema ??= meta.schema ?? this.em.config.getSchema();
197
+ const prop = meta.properties[key];
198
+ // Convert from DB format to JS format if needed
199
+ if (convertCustomTypes && prop?.customType) {
200
+ value = prop.customType.convertToJSValue(value, this.platform, { mode: 'hydration' });
201
+ }
202
+ // Set the property on the entity
203
+ entity[key] = value;
204
+ this.identityMap.storeByKey(entity, key, '' + value, schema);
205
+ }
172
206
  tryGetById(entityName, where, schema, strict = true) {
173
207
  const pk = Utils.extractPK(where, this.metadata.find(entityName), strict);
174
208
  if (!pk) {
@@ -510,6 +510,23 @@ export class EntityComparator {
510
510
  ret += ` ret${dataKey} = entity${entityKey};\n`;
511
511
  }
512
512
  }
513
+ else if (prop.targetKey) {
514
+ // When targetKey is set, extract that property value instead of the PK
515
+ const targetProp = prop.targetMeta?.properties[prop.targetKey];
516
+ ret += ` if (entity${entityKey} === null) {\n`;
517
+ ret += ` ret${dataKey} = null;\n`;
518
+ ret += ` } else if (typeof entity${entityKey} !== 'undefined') {\n`;
519
+ ret += ` const val${level} = entity${entityKey}${unwrap};\n`;
520
+ if (targetProp?.customType) {
521
+ // If targetKey property has a custom type, convert to database value
522
+ const convertorKey = this.registerCustomType(targetProp, context);
523
+ ret += ` ret${dataKey} = convertToDatabaseValue_${convertorKey}(val${level}?.${prop.targetKey});\n`;
524
+ }
525
+ else {
526
+ ret += ` ret${dataKey} = val${level}?.${prop.targetKey};\n`;
527
+ }
528
+ ret += ` }\n`;
529
+ }
513
530
  else {
514
531
  const toArray = (val) => {
515
532
  if (Utils.isPlainObject(val)) {
package/utils/Utils.js CHANGED
@@ -123,7 +123,7 @@ export function parseJsonSafe(value) {
123
123
  }
124
124
  export class Utils {
125
125
  static PK_SEPARATOR = '~~~';
126
- static #ORM_VERSION = '7.0.0-dev.156';
126
+ static #ORM_VERSION = '7.0.0-dev.157';
127
127
  /**
128
128
  * Checks if the argument is instance of `Object`. Returns false for arrays.
129
129
  */