@mikro-orm/core 7.3.0-dev.3 → 7.3.0-dev.4

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.
package/EntityManager.js CHANGED
@@ -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) {
@@ -51,6 +51,7 @@ export declare class EntityFactory {
51
51
  assignDefaultValues<T extends object>(entity: T, meta: EntityMetadata<T>, onCreateOnly?: boolean): void;
52
52
  private hydrate;
53
53
  private findEntity;
54
+ private findEntityByPrimaryKey;
54
55
  private processDiscriminatorColumn;
55
56
  /**
56
57
  * denormalize PK to value required by driver (e.g. ObjectId)
@@ -55,6 +55,10 @@ export class EntityFactory {
55
55
  Utils.dropUndefinedProperties(data);
56
56
  this.mergeData(meta2, exists, data, options);
57
57
  wrapped.__processing = false;
58
+ // index a merged `targetKey` value, `getByKey` ignores the old one as stale
59
+ if (meta2.root.targetKeys) {
60
+ this.unitOfWork.getIdentityMap().store(exists);
61
+ }
58
62
  wrapped.__initialized ||= !!options.initialized;
59
63
  if (wrapped.isInitialized()) {
60
64
  if (!wasInitialized) {
@@ -232,9 +236,9 @@ export class EntityFactory {
232
236
  options.convertCustomTypes ??= true;
233
237
  const meta = this.#metadata.get(entityName);
234
238
  const schema = this.#driver.getSchemaName(meta, options);
235
- // Handle alternate key lookup
236
- if (options.key) {
237
- const value = '' + (Array.isArray(id) ? id[0] : Utils.isPlainObject(id) ? id[options.key] : id);
239
+ // Handle alternate key lookup, a `key` pointing at the single PK is a plain PK reference
240
+ if (options.key && !(meta.primaryKeys.length === 1 && meta.primaryKeys[0] === options.key)) {
241
+ const value = Array.isArray(id) ? id[0] : Utils.isPlainObject(id) ? id[options.key] : id;
238
242
  const exists = this.unitOfWork.getByKey(entityName, options.key, value, schema, options.convertCustomTypes);
239
243
  if (exists) {
240
244
  return exists;
@@ -242,6 +246,8 @@ export class EntityFactory {
242
246
  // Create entity stub - storeByKey will set the alternate key property and store in identity map
243
247
  const entity = this.create(entityName, {}, { ...options, initialized: false });
244
248
  this.unitOfWork.storeByKey(entity, options.key, value, schema, options.convertCustomTypes);
249
+ // snapshot the stub like a PK reference, so flush treats it as existing instead of inserting it
250
+ helper(entity).__originalEntityData = this.#comparator.prepareEntity(entity);
245
251
  return entity;
246
252
  }
247
253
  if (meta.simplePK) {
@@ -310,6 +316,12 @@ export class EntityFactory {
310
316
  if (options.merge && !options.newEntity) {
311
317
  this.#hydrator.hydrateReference(entity, meta, data, this, options.convertCustomTypes, options.schema, options.parentSchema);
312
318
  this.unitOfWork.register(entity);
319
+ // relations hydrated together with this entity (joined strategy) resolve it via `targetKey` before it is hydrated
320
+ for (const key of meta.root.targetKeys ?? []) {
321
+ if (data[key] != null) {
322
+ this.unitOfWork.storeByKey(entity, key, data[key], schema, options.convertCustomTypes);
323
+ }
324
+ }
313
325
  }
314
326
  if (options.initialized) {
315
327
  EntityHelper.ensurePropagation(entity);
@@ -379,6 +391,23 @@ export class EntityFactory {
379
391
  }
380
392
  findEntity(data, meta, options) {
381
393
  const schema = this.#driver.getSchemaName(meta, options);
394
+ const exists = this.findEntityByPrimaryKey(data, meta, schema, options);
395
+ // a new entity must not adopt a reference stub, that one is never inserted
396
+ if (exists || Array.isArray(data) || options.newEntity) {
397
+ return exists;
398
+ }
399
+ // a reference created via `targetKey` is only known by that key until the entity is loaded
400
+ for (const key of meta.root.targetKeys ?? []) {
401
+ if (data[key] != null) {
402
+ const ref = this.unitOfWork.getByKey(meta.class, key, data[key], schema, options.convertCustomTypes);
403
+ if (ref && !helper(ref).__initialized) {
404
+ return ref;
405
+ }
406
+ }
407
+ }
408
+ return undefined;
409
+ }
410
+ findEntityByPrimaryKey(data, meta, schema, options) {
382
411
  if (meta.simplePK) {
383
412
  return this.unitOfWork.getById(meta.class, data[meta.primaryKeys[0]], schema);
384
413
  }
@@ -197,9 +197,9 @@ export class EntityLoader {
197
197
  this.initializeCollections(filtered, prop, field, items, customOrder, isPartial, hasLimit);
198
198
  return items;
199
199
  }
200
- async populateScalar(meta, filtered, options) {
201
- const pk = Utils.getPrimaryKeyHash(meta.primaryKeys);
202
- const ids = Utils.unique(filtered.map(e => Utils.getPrimaryKeyValues(e, meta, true)));
200
+ async populateScalar(meta, filtered, options, key) {
201
+ const pk = (key ?? Utils.getPrimaryKeyHash(meta.primaryKeys));
202
+ const ids = Utils.unique(filtered.map(e => (key ? e[key] : Utils.getPrimaryKeyValues(e, meta, true))));
203
203
  const where = this.mergePrimaryCondition(ids, pk, options, meta, this.#metadata, this.#driver.getPlatform());
204
204
  const { filters, convertCustomTypes, lockMode, strategy, populateWhere, connectionType, logging, fields } = options;
205
205
  await this.#em.find(meta.class, where, {
@@ -221,7 +221,8 @@ export class EntityLoader {
221
221
  const needsFkLoad = [];
222
222
  for (const entity of entities) {
223
223
  const refValue = entity[prop.name];
224
- if (refValue && helper(refValue).hasPrimaryKey()) {
224
+ // a `targetKey` reference is identified by its key, it might not have the PK yet
225
+ if (refValue && helper(refValue).getTargetKeyValue(prop.targetKey) != null) {
225
226
  if ((ref && !options.refresh) || // :ref hint - already have reference
226
227
  (!ref && helper(refValue).__initialized && !options.refresh) // already loaded
227
228
  ) {
@@ -246,7 +247,7 @@ export class EntityLoader {
246
247
  if (!ref) {
247
248
  for (const entity of needsFkLoad) {
248
249
  const refValue = entity[prop.name];
249
- if (refValue && helper(refValue).hasPrimaryKey()) {
250
+ if (refValue && helper(refValue).getTargetKeyValue(prop.targetKey) != null) {
250
251
  toPopulate.push(entity);
251
252
  }
252
253
  }
@@ -268,7 +269,7 @@ export class EntityLoader {
268
269
  const allItems = [];
269
270
  await Promise.all([...groups].map(async ([discriminator, children]) => {
270
271
  const targetMeta = this.#metadata.find(prop.discriminatorMap[discriminator]);
271
- await this.populateScalar(targetMeta, children, options);
272
+ await this.populateScalar(targetMeta, children, options, prop.targetKey);
272
273
  allItems.push(...children);
273
274
  }));
274
275
  return allItems;
@@ -83,7 +83,12 @@ export class WrappedEntity {
83
83
  if (!this.__em) {
84
84
  throw ValidationError.entityNotManaged(this.entity);
85
85
  }
86
- return this.__em.findOne(this.entity.constructor, this.entity, {
86
+ // a reference known only by its `targetKey` has no PK, so look it up by the key instead
87
+ const key = this.hasPrimaryKey()
88
+ ? undefined
89
+ : this.__meta.root.targetKeys?.find(k => this.entity[k] != null);
90
+ const where = key ? { [key]: this.entity[key] } : this.entity;
91
+ return this.__em.findOne(this.entity.constructor, where, {
87
92
  ...options,
88
93
  refresh: true,
89
94
  schema: this.__schema,
package/errors.d.ts CHANGED
@@ -10,6 +10,7 @@ export declare class ValidationError<T extends AnyEntity = AnyEntity> extends Er
10
10
  static fromWrongPropertyType(entity: AnyEntity, property: string, expectedType: string, givenType: string, givenValue: string): ValidationError;
11
11
  static fromWrongRepositoryType(entityName: string, repoType: string, method: string): ValidationError;
12
12
  static fromMergeWithoutPK(meta: EntityMetadata): ValidationError;
13
+ static referenceWithoutPK(entity: AnyEntity): ValidationError;
13
14
  static transactionRequired(): ValidationError;
14
15
  static entityNotManaged(entity: AnyEntity): ValidationError;
15
16
  static notEntity(owner: AnyEntity, prop: EntityProperty, data: any): ValidationError;
package/errors.js CHANGED
@@ -28,6 +28,9 @@ export class ValidationError extends Error {
28
28
  static fromMergeWithoutPK(meta) {
29
29
  return new ValidationError(`You cannot merge entity '${meta.className}' without identifier!`);
30
30
  }
31
+ static referenceWithoutPK(entity) {
32
+ return new ValidationError(`Entity '${entity.constructor.name}' is only known by an alternate key and has no primary key, load it first`, entity);
33
+ }
31
34
  static transactionRequired() {
32
35
  return new ValidationError('An open transaction is required for this operation');
33
36
  }
@@ -207,7 +207,7 @@ export class ObjectHydrator extends Hydrator {
207
207
  if (prop.customType?.ensureComparable(meta, prop)) {
208
208
  registerCustomType(prop, this.safeKey(prop.name), 'convertToDatabaseValue', context);
209
209
  ret.push(` if (data${dataKey} != null && typeof data${dataKey} !== 'object' && convertCustomTypes) {`);
210
- ret.push(` data${dataKey} = convertToDatabaseValue_${this.safeKey(prop.name)}(entity${entityKey}.__helper.getPrimaryKey());`);
210
+ ret.push(` data${dataKey} = convertToDatabaseValue_${this.safeKey(prop.name)}(entity${entityKey}.__helper.getTargetKeyValue(${prop.targetKey ? this.quote(prop.targetKey) : ''}));`);
211
211
  ret.push(` }`);
212
212
  }
213
213
  return ret;
@@ -564,7 +564,10 @@ export class MetadataDiscovery {
564
564
  }
565
565
  Utils.defaultValue(prop, 'referencedTableName', meta2.tableName);
566
566
  if (!prop.joinColumns) {
567
- prop.joinColumns = fieldNames.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.name, fieldName, fieldNames.length > 1));
567
+ // must match the real FK columns, which ignore `targetKey` naming for simple PK targets
568
+ prop.joinColumns = prop.targetKey
569
+ ? prop.fieldNames
570
+ : fieldNames.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.name, fieldName, fieldNames.length > 1));
568
571
  }
569
572
  if (!prop.referencedColumnNames) {
570
573
  prop.referencedColumnNames = fieldNames;
@@ -670,6 +673,12 @@ export class MetadataDiscovery {
670
673
  if (prop2.kind !== ReferenceKind.SCALAR && prop2.type === meta.className) {
671
674
  meta.referencingProperties.push({ meta: meta2, prop: prop2 });
672
675
  }
676
+ if (prop2.targetKey &&
677
+ (prop2.type === meta.className || prop2.polymorphTargets?.some(t => t.className === meta.className)) &&
678
+ !(meta.primaryKeys.length === 1 && meta.primaryKeys[0] === prop2.targetKey) &&
679
+ !meta.root.targetKeys?.includes(prop2.targetKey)) {
680
+ (meta.root.targetKeys ??= []).push(prop2.targetKey);
681
+ }
673
682
  }
674
683
  }
675
684
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/core",
3
- "version": "7.3.0-dev.3",
3
+ "version": "7.3.0-dev.4",
4
4
  "description": "TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, PostgreSQL and SQLite databases as well as usage with vanilla JavaScript.",
5
5
  "keywords": [
6
6
  "data-mapper",
package/typings.d.ts CHANGED
@@ -1175,6 +1175,7 @@ export interface EntityMetadata<Entity = any, Class extends EntityCtor<Entity> =
1175
1175
  meta: EntityMetadata<Entity>;
1176
1176
  prop: EntityProperty<Entity>;
1177
1177
  }[];
1178
+ targetKeys?: EntityKey<Entity>[];
1178
1179
  comparableProps: EntityProperty<Entity>[];
1179
1180
  trackingProps: EntityProperty<Entity>[];
1180
1181
  hydrateProps: EntityProperty<Entity>[];
@@ -7,6 +7,7 @@ import { Reference } from '../entity/Reference.js';
7
7
  import { PolymorphicRef } from '../entity/PolymorphicRef.js';
8
8
  import { ReferenceKind } from '../enums.js';
9
9
  import { isRaw } from '../utils/RawQueryFragment.js';
10
+ import { ValidationError } from '../errors.js';
10
11
  /** @internal Computes change sets by comparing entity state against original snapshots. */
11
12
  export class ChangeSetComputer {
12
13
  #comparator;
@@ -48,6 +49,10 @@ export class ChangeSetComputer {
48
49
  if (Utils.equals(data, wrapped.__originalEntityData)) {
49
50
  return null;
50
51
  }
52
+ // a reference known only by its `targetKey` has no PK, so the update would have no condition
53
+ if (!wrapped.hasPrimaryKey()) {
54
+ throw ValidationError.referenceWithoutPK(entity);
55
+ }
51
56
  }
52
57
  const changeSet = new ChangeSet(entity, type, this.computePayload(entity), meta);
53
58
  changeSet.originalEntity = wrapped.__originalEntityData;
@@ -162,21 +167,30 @@ export class ChangeSetComputer {
162
167
  const target = Reference.unwrapReference(rawTarget);
163
168
  const needsProcessing = target != null && (prop.targetKey != null || !target.__helper.hasPrimaryKey());
164
169
  if (needsProcessing) {
170
+ // a reference known only by its `targetKey` has no PK, and is never inserted to generate one
171
+ if (!prop.targetKey && !target.__helper.__initialized) {
172
+ throw ValidationError.referenceWithoutPK(target);
173
+ }
165
174
  let value = prop.targetKey ? target[prop.targetKey] : target.__helper.__identifier;
166
- /* v8 ignore next */
167
- if (prop.targetKey && prop.targetMeta) {
168
- const targetProp = prop.targetMeta.properties[prop.targetKey];
175
+ // use the actual target's meta, a polymorphic relation has several
176
+ if (prop.targetKey) {
177
+ const targetProp = target.__meta.properties[prop.targetKey];
169
178
  if (targetProp?.customType) {
170
179
  value = targetProp.customType.convertToDatabaseValue(value, this.#platform, { mode: 'serialization' });
171
180
  }
172
181
  }
173
182
  if (prop.polymorphic) {
174
183
  const discriminator = QueryHelper.findDiscriminatorValue(prop.discriminatorMap, target.constructor);
175
- Utils.setPayloadProperty(changeSet.payload, changeSet.meta, prop, new PolymorphicRef(discriminator, value), idx);
184
+ value = new PolymorphicRef(discriminator, value);
176
185
  }
177
- else {
178
- Utils.setPayloadProperty(changeSet.payload, changeSet.meta, prop, value, idx);
186
+ // an unchanged `targetKey` FK is not part of the diff
187
+ if (prop.targetKey &&
188
+ changeSet.type === ChangeSetType.UPDATE &&
189
+ !(prop.name in changeSet.payload) &&
190
+ Utils.equals(changeSet.originalEntity?.[prop.name], value)) {
191
+ return;
179
192
  }
193
+ Utils.setPayloadProperty(changeSet.payload, changeSet.meta, prop, value, idx);
180
194
  }
181
195
  });
182
196
  }
@@ -1,9 +1,9 @@
1
1
  import type { AnyEntity, EntityMetadata } from '../typings.js';
2
- /** @internal Stores managed entity instances keyed by their primary key hash, ensuring each row is loaded once. */
2
+ /** @internal Stores managed entity instances keyed by their primary key hash (and `targetKey` values), ensuring each row is loaded once. */
3
3
  export declare class IdentityMap {
4
4
  #private;
5
5
  constructor(defaultSchema?: string);
6
- /** Stores an entity in the identity map under its primary key hash. */
6
+ /** Stores an entity in the identity map under its primary key hash and the `targetKey` values referencing it. */
7
7
  store<T>(item: T): void;
8
8
  /**
9
9
  * Stores an entity under an alternate key (non-PK property).
@@ -25,6 +25,7 @@ export declare class IdentityMap {
25
25
  /** Returns all entities currently in the identity map. */
26
26
  values(): AnyEntity[];
27
27
  [Symbol.iterator](): IterableIterator<AnyEntity>;
28
+ private getEntities;
28
29
  /** Returns all hash keys currently in the identity map. */
29
30
  keys(): string[];
30
31
  /**
@@ -1,15 +1,29 @@
1
- /** @internal Stores managed entity instances keyed by their primary key hash, ensuring each row is loaded once. */
1
+ /** @internal Stores managed entity instances keyed by their primary key hash (and `targetKey` values), ensuring each row is loaded once. */
2
2
  export class IdentityMap {
3
3
  #defaultSchema;
4
4
  #registry = new Map();
5
5
  /** Tracks alternate key hashes for each entity so we can clean them up on delete */
6
6
  #alternateKeys = new WeakMap();
7
+ #storesWithAlternateKeys = new WeakSet();
7
8
  constructor(defaultSchema) {
8
9
  this.#defaultSchema = defaultSchema;
9
10
  }
10
- /** Stores an entity in the identity map under its primary key hash. */
11
+ /** Stores an entity in the identity map under its primary key hash and the `targetKey` values referencing it. */
11
12
  store(item) {
12
- this.getStore(item.__meta.root).set(this.getPkHash(item), item);
13
+ const meta = item.__meta.root;
14
+ const wrapped = item.__helper;
15
+ // PK-less references (known only by a `targetKey`) would share one slot that outlives their later removal
16
+ if (!meta.targetKeys || wrapped.hasPrimaryKey()) {
17
+ this.getStore(meta).set(this.getPkHash(item), item);
18
+ }
19
+ // references resolved via `targetKey` look the entity up by that key instead of the PK
20
+ for (const key of meta.targetKeys ?? []) {
21
+ const value = item[key];
22
+ if (value != null) {
23
+ const schema = wrapped.__schema ?? meta.schema ?? this.#defaultSchema;
24
+ this.storeByKey(item, key, '' + value, schema);
25
+ }
26
+ }
13
27
  }
14
28
  /**
15
29
  * Stores an entity under an alternate key (non-PK property).
@@ -17,7 +31,9 @@ export class IdentityMap {
17
31
  */
18
32
  storeByKey(item, key, value, schema) {
19
33
  const hash = this.getKeyHash(key, value, schema);
20
- this.getStore(item.__meta.root).set(hash, item);
34
+ const store = this.getStore(item.__meta.root);
35
+ store.set(hash, item);
36
+ this.#storesWithAlternateKeys.add(store);
21
37
  // Track this alternate key so we can clean it up when the entity is deleted
22
38
  let keys = this.#alternateKeys.get(item);
23
39
  if (!keys) {
@@ -35,7 +51,10 @@ export class IdentityMap {
35
51
  const altKeys = this.#alternateKeys.get(item);
36
52
  if (altKeys) {
37
53
  for (const hash of altKeys) {
38
- store.delete(hash);
54
+ // the hash might be owned by another entity by now, e.g. after swapping unique values
55
+ if (store.get(hash) === item) {
56
+ store.delete(hash);
57
+ }
39
58
  }
40
59
  this.#alternateKeys.delete(item);
41
60
  }
@@ -69,7 +88,7 @@ export class IdentityMap {
69
88
  values() {
70
89
  const ret = [];
71
90
  for (const store of this.#registry.values()) {
72
- for (const item of store.values()) {
91
+ for (const item of this.getEntities(store)) {
73
92
  ret.push(item);
74
93
  }
75
94
  }
@@ -77,11 +96,15 @@ export class IdentityMap {
77
96
  }
78
97
  *[Symbol.iterator]() {
79
98
  for (const store of this.#registry.values()) {
80
- for (const item of store.values()) {
99
+ for (const item of this.getEntities(store)) {
81
100
  yield item;
82
101
  }
83
102
  }
84
103
  }
104
+ getEntities(store) {
105
+ // an entity indexed by alternate keys occupies several slots of its store
106
+ return this.#storesWithAlternateKeys.has(store) ? new Set(store.values()) : store.values();
107
+ }
85
108
  /** Returns all hash keys currently in the identity map. */
86
109
  keys() {
87
110
  const ret = [];
@@ -89,7 +89,10 @@ export class UnitOfWork {
89
89
  Utils.isPlainObject(data[prop.name])) {
90
90
  // Skip polymorphic relations - they use PolymorphicRef wrapper
91
91
  if (!prop.polymorphic) {
92
- data[prop.name] = Utils.getPrimaryKeyValues(data[prop.name], prop.targetMeta, true);
92
+ // with `targetKey`, the FK holds that value instead of the PK
93
+ data[prop.name] = prop.targetKey
94
+ ? data[prop.name][prop.targetKey]
95
+ : Utils.getPrimaryKeyValues(data[prop.name], prop.targetMeta, true);
93
96
  }
94
97
  }
95
98
  else if (prop.kind === ReferenceKind.EMBEDDED && !prop.object && Utils.isPlainObject(data[prop.name])) {
@@ -205,7 +208,18 @@ export class UnitOfWork {
205
208
  value = prop.customType.convertToJSValue(value, this.#platform, { mode: 'hydration' });
206
209
  }
207
210
  const hash = this.#identityMap.getKeyHash(key, '' + value, schema);
208
- return this.#identityMap.getByHash(meta, hash);
211
+ const entity = this.#identityMap.getByHash(meta, hash);
212
+ // the entry is stale when the key changed via refresh or merge, unless only the unflushed value differs
213
+ if (entity && '' + entity[key] !== '' + value) {
214
+ // the snapshot holds the database value
215
+ const dbValue = prop?.customType
216
+ ? prop.customType.convertToDatabaseValue(value, this.#platform, { mode: 'serialization' })
217
+ : value;
218
+ if ('' + helper(entity).__originalEntityData?.[key] !== '' + dbValue) {
219
+ return undefined;
220
+ }
221
+ }
222
+ return entity;
209
223
  }
210
224
  /**
211
225
  * Stores an entity in the identity map under an alternate key (non-PK property).
@@ -368,6 +382,10 @@ export class UnitOfWork {
368
382
  }
369
383
  /** Marks an entity for removal, cascading to related entities. */
370
384
  remove(entity, visited, options = {}) {
385
+ // a reference known only by its `targetKey` has no PK, so the delete would match nothing
386
+ if (!helper(entity).__initialized && !helper(entity).hasPrimaryKey()) {
387
+ throw ValidationError.referenceWithoutPK(entity);
388
+ }
371
389
  // allow removing not managed entities if they are not part of the persist stack
372
390
  if (helper(entity).__managed || !this.#persistStack.has(entity)) {
373
391
  this.#removeStack.add(entity);
@@ -1118,6 +1136,11 @@ export class UnitOfWork {
1118
1136
  for (const changeSet of changeSets) {
1119
1137
  const wrapped = helper(changeSet.entity);
1120
1138
  wrapped.__originalEntityData = this.#comparator.prepareEntity(changeSet.entity);
1139
+ // re-index so the entity is found by its new `targetKey` values instead of the old ones
1140
+ if (changeSet.meta.root.targetKeys?.some(key => key in changeSet.payload)) {
1141
+ this.#identityMap.delete(changeSet.entity);
1142
+ this.#identityMap.store(changeSet.entity);
1143
+ }
1121
1144
  if (!wrapped.__initialized) {
1122
1145
  for (const prop of changeSet.meta.relations) {
1123
1146
  if ([ReferenceKind.MANY_TO_MANY, ReferenceKind.ONE_TO_MANY].includes(prop.kind) &&
@@ -608,9 +608,24 @@ export class EntityComparator {
608
608
  ret += ` if (discriminator !== undefined) break;\n`;
609
609
  ret += ` __cls${level} = Object.getPrototypeOf(__cls${level});\n`;
610
610
  ret += ` }\n`;
611
- ret += ` const pk = val${level}?.__helper?.__identifier && !val${level}?.__helper?.hasPrimaryKey()\n`;
612
- ret += ` ? val${level}.__helper.__identifier\n`;
613
- ret += ` : toArray(val${level}?.__helper?.getPrimaryKey(true));\n`;
611
+ if (prop.targetKey) {
612
+ // the FK holds the `targetKey` value in the format of the actual target's column
613
+ const targetKey = prop.targetKey;
614
+ const targetKeyValueKey = `targetKeyValue_${this.safeKey(prop.name)}`;
615
+ context.set(targetKeyValueKey, (val) => {
616
+ const customType = val.__meta.properties[targetKey]?.customType;
617
+ const value = val[targetKey];
618
+ return customType
619
+ ? customType.convertToDatabaseValue(value, this.#platform, { mode: 'serialization' })
620
+ : value;
621
+ });
622
+ ret += ` const pk = ${targetKeyValueKey}(val${level});\n`;
623
+ }
624
+ else {
625
+ ret += ` const pk = val${level}?.__helper?.__identifier && !val${level}?.__helper?.hasPrimaryKey()\n`;
626
+ ret += ` ? val${level}.__helper.__identifier\n`;
627
+ ret += ` : toArray(val${level}?.__helper?.getPrimaryKey(true));\n`;
628
+ }
614
629
  ret += ` ret${dataKey} = new PolymorphicRef(discriminator, pk);\n`;
615
630
  ret += ` }\n`;
616
631
  }
@@ -624,8 +639,8 @@ export class EntityComparator {
624
639
  ret += ` } else if (typeof entity${entityKey} !== 'undefined') {\n`;
625
640
  ret += ` const val${level} = entity${entityKey}${unwrap};\n`;
626
641
  if (targetProp?.customType) {
627
- // If targetKey property has a custom type, convert to database value
628
- const convertorKey = this.registerCustomType(targetProp, context);
642
+ // keyed by the relation name to avoid clashing with a same-named owner prop, the relation itself lacks the custom type with an explicit `columnType`
643
+ const convertorKey = this.registerCustomType({ ...prop, customType: targetProp.customType }, context);
629
644
  ret += ` ret${dataKey} = convertToDatabaseValue_${convertorKey}(val${level}${targetAccess});\n`;
630
645
  }
631
646
  else {
@@ -35,6 +35,12 @@ export declare class QueryHelper {
35
35
  */
36
36
  static liftGroupOperators<T extends object>(where: Dictionary, meta: EntityMetadata<T>, metadata: MetadataStorage, key?: string): string | undefined;
37
37
  static inlinePrimaryKeyObjects<T extends object>(where: Dictionary, meta: EntityMetadata<T>, metadata: MetadataStorage, key?: string): boolean;
38
+ /**
39
+ * A bare value on a polymorphic relation would be compared to the discriminator column, so PK values are turned
40
+ * into a PK condition instead, e.g. `1` becomes `{ id: 1 }` and `[{ id: 1 }, { id: 2 }]` becomes `{ id: { $in: [1, 2] } }`.
41
+ * Null checks and `[type, id]` tuples keep their meaning.
42
+ */
43
+ private static inlinePolymorphicPrimaryKeys;
38
44
  /** Copies the plain object and array structure of the condition, keeping the leaf values (e.g. entities) by reference. */
39
45
  private static cloneWhere;
40
46
  static processWhere<T extends object>(options: ProcessWhereOptions<T>): FilterQuery<T>;
@@ -4,7 +4,7 @@ import { ARRAY_OPERATORS, GroupOperator, JSON_KEY_OPERATORS, ReferenceKind } fro
4
4
  import { JsonType } from '../types/JsonType.js';
5
5
  import { helper } from '../entity/wrap.js';
6
6
  import { isRaw, Raw } from './RawQueryFragment.js';
7
- import { MetadataError } from '../errors.js';
7
+ import { MetadataError, ValidationError } from '../errors.js';
8
8
  /** @internal */
9
9
  export class QueryHelper {
10
10
  static SUPPORTED_OPERATORS = ['>', '<', '<=', '>=', '!', '!='];
@@ -102,10 +102,7 @@ export class QueryHelper {
102
102
  for (const k of keys) {
103
103
  const value = where[k];
104
104
  const prop = meta.properties[k];
105
- // Polymorphic relations use multiple columns (discriminator + FK), so they cannot
106
- // participate in the standard single-column FK expansion. Query by discriminator
107
- // column directly instead, e.g. { likeableType: 'post', likeableId: 1 }.
108
- if (!prop || ![ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) || prop.polymorphic) {
105
+ if (!prop || ![ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind)) {
109
106
  continue;
110
107
  }
111
108
  const op = this.liftGroupOperators(value, prop.targetMeta, metadata, k);
@@ -130,7 +127,7 @@ export class QueryHelper {
130
127
  if (Array.isArray(where)) {
131
128
  where.forEach((item, i) => {
132
129
  if (this.inlinePrimaryKeyObjects(item, meta, metadata, key)) {
133
- where[i] = Utils.getPrimaryKeyValues(item, meta, false);
130
+ where[i] = Utils.getPrimaryKeyValues(item, meta, true);
134
131
  }
135
132
  });
136
133
  }
@@ -160,6 +157,9 @@ export class QueryHelper {
160
157
  Object.keys(where).forEach(k => {
161
158
  const prop = meta.properties[QueryHelper.splitPolymorphicKey(k)[0]];
162
159
  const meta2 = metadata.find(QueryHelper.findTargetMeta(prop, k)?.class) || meta;
160
+ if (prop?.polymorphic) {
161
+ where[k] = this.inlinePolymorphicPrimaryKeys(where[k], meta2, prop);
162
+ }
163
163
  if (this.inlinePrimaryKeyObjects(where[k], meta2, metadata, k)) {
164
164
  // Skip the PK collapse when an owning M:1/1:1 relation's FK column count does not match
165
165
  // the target's PK column count (e.g. FK references target PK + an extra unique column).
@@ -177,6 +177,37 @@ export class QueryHelper {
177
177
  });
178
178
  return false;
179
179
  }
180
+ /**
181
+ * A bare value on a polymorphic relation would be compared to the discriminator column, so PK values are turned
182
+ * into a PK condition instead, e.g. `1` becomes `{ id: 1 }` and `[{ id: 1 }, { id: 2 }]` becomes `{ id: { $in: [1, 2] } }`.
183
+ * Null checks and `[type, id]` tuples keep their meaning.
184
+ */
185
+ static inlinePolymorphicPrimaryKeys(value, meta, prop) {
186
+ const pk = meta.primaryKeys[0];
187
+ const isScalar = (v) => v != null && !Array.isArray(v) && Utils.isPrimaryKey(v);
188
+ const isPrimaryKeyObject = (v) => Utils.isPlainObject(v) && Utils.getObjectKeysSize(v) === 1 && pk in v;
189
+ // lists of plain values are how `[type, id]` tuples get normalized (`{ $in: [type, id] }`), so only PK objects are inlined
190
+ const isList = (v) => Array.isArray(v) && v.length > 0 && v.every(isPrimaryKeyObject);
191
+ let cond;
192
+ if (isScalar(value)) {
193
+ cond = value;
194
+ }
195
+ else if (isList(value)) {
196
+ cond = { $in: value.map(v => v[pk]) };
197
+ }
198
+ else if (Utils.isPlainObject(value) &&
199
+ Utils.hasObjectKeys(value) &&
200
+ Object.entries(value).every(([op, v]) => Utils.isOperator(op) && !(op in GroupOperator) && op !== '$not' && (isScalar(v) || isList(v)))) {
201
+ cond = Object.fromEntries(Object.entries(value).map(([op, v]) => [op, isList(v) ? v.map(i => i[pk]) : v]));
202
+ }
203
+ else {
204
+ return value;
205
+ }
206
+ if (meta.compositePK) {
207
+ throw new ValidationError(`Polymorphic relation ${prop.name} targets entities with a composite primary key, use PK objects or entity references in the condition instead.`);
208
+ }
209
+ return { [pk]: cond };
210
+ }
180
211
  /** Copies the plain object and array structure of the condition, keeping the leaf values (e.g. entities) by reference. */
181
212
  static cloneWhere(where) {
182
213
  if (Array.isArray(where)) {
@@ -555,6 +586,9 @@ export class QueryHelper {
555
586
  if (expanded !== w) {
556
587
  where[k] = expanded;
557
588
  }
589
+ else if (Array.isArray(w)) {
590
+ where[k] = w.map(expandItem);
591
+ }
558
592
  else if (Utils.isPlainObject(w)) {
559
593
  for (const op of Object.keys(w)) {
560
594
  if (!Utils.isOperator(op, false)) {
@@ -189,6 +189,10 @@ export class TransactionManager {
189
189
  if (wrapped.__originalEntityData) {
190
190
  parentWrapped.__originalEntityData = originalEntityData;
191
191
  }
192
+ // index a merged `targetKey` value, `getByKey` ignores the old one as stale
193
+ if (meta.root.targetKeys) {
194
+ parentUoW.getIdentityMap().store(parentEntity);
195
+ }
192
196
  }
193
197
  else {
194
198
  parentUoW.merge(entity, new Set([entity]));
package/utils/Utils.js CHANGED
@@ -153,7 +153,7 @@ export function parseJsonSafe(value) {
153
153
  /** Collection of general-purpose utility methods used throughout the ORM. */
154
154
  export class Utils {
155
155
  static PK_SEPARATOR = '~~~';
156
- static #ORM_VERSION = '7.3.0-dev.3';
156
+ static #ORM_VERSION = '7.3.0-dev.4';
157
157
  /** Default session variable name backing an RLS filter argument (`current_setting('mikro.<filter>.<arg>')`). */
158
158
  static getRlsSettingName(filterName, argName) {
159
159
  return `mikro.${filterName}.${argName}`;