@mikro-orm/core 7.3.0-dev.0 → 7.3.0-dev.10

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) {
@@ -190,7 +194,7 @@ export class EntityFactory {
190
194
  const initialized = options.initialized || helper(entity).__initialized;
191
195
  this.hydrate(entity, meta, diff2, initialized ? { ...options, initialized } : options);
192
196
  // we need to update the entity data only with keys that were not present before
193
- const nullVal = this.#config.get('forceUndefined') ? undefined : null;
197
+ const nullVal = (meta.forceUndefined ?? this.#config.get('forceUndefined')) ? undefined : null;
194
198
  Utils.keys(diff2).forEach(key => {
195
199
  const prop = meta.properties[key];
196
200
  if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) &&
@@ -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);
@@ -336,7 +348,7 @@ export class EntityFactory {
336
348
  prop.default == null &&
337
349
  !prop.defaultRaw &&
338
350
  entity[prop.name] === undefined) {
339
- entity[prop.name] = (this.#config.get('forceUndefined') ? undefined : null);
351
+ entity[prop.name] = ((meta.forceUndefined ?? this.#config.get('forceUndefined')) ? undefined : null);
340
352
  }
341
353
  if (prop.kind === ReferenceKind.EMBEDDED && entity[prop.name]) {
342
354
  const items = prop.array ? entity[prop.name] : [entity[prop.name]];
@@ -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;
@@ -458,7 +459,8 @@ export class EntityLoader {
458
459
  if ([ReferenceKind.ONE_TO_ONE, ReferenceKind.MANY_TO_ONE].includes(prop.kind) &&
459
460
  items.length !== children.length &&
460
461
  Utils.isEmpty(options.where)) {
461
- const nullVal = this.#em.config.get('forceUndefined') ? undefined : null;
462
+ const ownerMeta = this.#metadata.find(prop.entity());
463
+ const nullVal = (ownerMeta.forceUndefined ?? this.#em.config.get('forceUndefined')) ? undefined : null;
462
464
  const itemsMap = new Set();
463
465
  const childrenMap = new Set();
464
466
  // `e` may be an unresolved reference here, so read the targetKey value directly instead of
@@ -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,
@@ -631,7 +631,7 @@ type PartialWhere<TProperties, TBase> = string | FilterQuery<{
631
631
  [K in AllKeys<TProperties, TBase> & string]?: unknown;
632
632
  }>;
633
633
  /** Metadata descriptor for `defineEntity()`, combining entity options with property definitions. */
634
- export interface EntityMetadataWithProperties<TName extends string, TTableName extends string, TProperties extends Record<string, any>, TPK extends (keyof TProperties)[] | undefined = undefined, TBase = never, TRepository = never, TForceObject extends boolean = false, TDiscriminatorColumn extends string | undefined = undefined, TDiscriminatorValue extends string | number | undefined = undefined, TBaseDiscriminatorColumn extends string | undefined = undefined, TEmbeddable extends boolean = false> extends Omit<Partial<EntityMetadata<InferEntityFromProperties<TProperties, TPK, TBase, TRepository>>>, 'properties' | 'extends' | 'primaryKeys' | 'hooks' | 'discriminator' | 'discriminatorColumn' | 'discriminatorValue' | 'versionProperty' | 'concurrencyCheckKeys' | 'serializedPrimaryKey' | 'indexes' | 'uniques' | 'triggers' | 'repository' | 'filters' | 'orderBy'> {
634
+ export interface EntityMetadataWithProperties<TName extends string, TTableName extends string, TProperties extends Record<string, any>, TPK extends (keyof TProperties)[] | undefined = undefined, TBase = never, TRepository = never, TForceObject extends boolean = false, TDiscriminatorColumn extends string | undefined = undefined, TDiscriminatorValue extends string | number | undefined = undefined, TBaseDiscriminatorColumn extends string | undefined = undefined, TEmbeddable extends boolean = false, TForceUndefined extends boolean = false> extends Omit<Partial<EntityMetadata<InferEntityFromProperties<TProperties, TPK, TBase, TRepository>>>, 'properties' | 'extends' | 'primaryKeys' | 'hooks' | 'discriminator' | 'discriminatorColumn' | 'discriminatorValue' | 'versionProperty' | 'concurrencyCheckKeys' | 'serializedPrimaryKey' | 'indexes' | 'uniques' | 'triggers' | 'repository' | 'filters' | 'orderBy'> {
635
635
  name: TName;
636
636
  tableName?: TTableName;
637
637
  extends?: {
@@ -656,6 +656,8 @@ export interface EntityMetadataWithProperties<TName extends string, TTableName e
656
656
  };
657
657
  }>;
658
658
  forceObject?: TForceObject;
659
+ /** Hydrate `null` as `undefined` for this entity and infer nullable properties as `T | undefined`. */
660
+ forceUndefined?: TForceUndefined;
659
661
  embeddable?: TEmbeddable;
660
662
  inheritance?: 'tpt';
661
663
  orderBy?: {
@@ -707,7 +709,7 @@ export interface EntityMetadataWithProperties<TName extends string, TTableName e
707
709
  }[];
708
710
  }
709
711
  /** Defines an entity schema using property builders, with full type inference from the property definitions. */
710
- export declare function defineEntity<const TName extends string, const TTableName extends string, const TProperties extends Record<string, any>, const TPK extends (keyof TProperties)[] | undefined = undefined, const TBase = never, const TRepository = never, const TForceObject extends boolean = false, const TDiscriminatorColumn extends string | undefined = undefined, const TDiscriminatorValue extends string | number | undefined = undefined, const TBaseDiscriminatorColumn extends string | undefined = undefined, const TEmbeddable extends boolean = false>(meta: EntityMetadataWithProperties<TName, TTableName, TProperties, TPK, TBase, TRepository, TForceObject, TDiscriminatorColumn, TDiscriminatorValue, TBaseDiscriminatorColumn, TEmbeddable>): EntitySchemaWithMeta<TName, TTableName, InferEntityFromProperties<TProperties, TPK, TBase, TRepository, TForceObject, TBaseDiscriminatorColumn, TDiscriminatorValue, TEmbeddable>, TBase, TProperties, EntityCtor<InferEntityFromProperties<TProperties, TPK, TBase, TRepository, TForceObject, TBaseDiscriminatorColumn, TDiscriminatorValue, TEmbeddable>>, TDiscriminatorColumn>;
712
+ export declare function defineEntity<const TName extends string, const TTableName extends string, const TProperties extends Record<string, any>, const TPK extends (keyof TProperties)[] | undefined = undefined, const TBase = never, const TRepository = never, const TForceObject extends boolean = false, const TDiscriminatorColumn extends string | undefined = undefined, const TDiscriminatorValue extends string | number | undefined = undefined, const TBaseDiscriminatorColumn extends string | undefined = undefined, const TEmbeddable extends boolean = false, const TForceUndefined extends boolean = false>(meta: EntityMetadataWithProperties<TName, TTableName, TProperties, TPK, TBase, TRepository, TForceObject, TDiscriminatorColumn, TDiscriminatorValue, TBaseDiscriminatorColumn, TEmbeddable, TForceUndefined>): EntitySchemaWithMeta<TName, TTableName, MaybeForceUndefined<InferEntityFromProperties<TProperties, TPK, TBase, TRepository, TForceObject, TBaseDiscriminatorColumn, TDiscriminatorValue, TEmbeddable, TForceUndefined>, EffectiveForceUndefined<TForceUndefined, TBase>>, TBase, TProperties, EntityCtor<MaybeForceUndefined<InferEntityFromProperties<TProperties, TPK, TBase, TRepository, TForceObject, TBaseDiscriminatorColumn, TDiscriminatorValue, TEmbeddable, TForceUndefined>, EffectiveForceUndefined<TForceUndefined, TBase>>>, TDiscriminatorColumn>;
711
713
  export declare namespace defineEntity {
712
714
  export { propertyBuilders as properties };
713
715
  }
@@ -744,8 +746,17 @@ type InferColumnType<T extends string> = T extends 'int' | 'int4' | 'integer' |
744
746
  type BaseEntityMethodKeys = 'toObject' | 'toPOJO' | 'serialize' | 'assign' | 'populate' | 'init' | 'toReference';
745
747
  interface BaseEntityMethods<in out Entity extends object> extends Pick<IWrappedEntity<Entity>, BaseEntityMethodKeys> {
746
748
  }
749
+ type MaybeForceUndefined<Entity, ForceUndefined> = [ForceUndefined] extends [true] ? {
750
+ [K in keyof Entity]: SwapNull<Entity[K]>;
751
+ } : Entity;
752
+ type SwapNull<Value> = [Value] extends [ScalarReference<infer Inner>] ? [Extract<keyof Value, symbol>] extends [never] ? ScalarReference<SwapNull<Inner>> : ScalarReference<SwapNull<Inner>> & Pick<Value, Extract<keyof Value, symbol>> : null extends Value ? Exclude<Value, null> | undefined : Value;
753
+ type EffectiveForceUndefined<Flag, Base> = [Flag] extends [true] ? true : IsNever<Base> extends true ? false : [Base] extends [{
754
+ [Config]?: infer C;
755
+ }] ? [NonNullable<C>] extends [{
756
+ forceUndefined: true;
757
+ }] ? true : false : false;
747
758
  /** Infers the entity type from a `defineEntity()` properties map, resolving builders, base classes, and primary keys. */
748
- export type InferEntityFromProperties<Properties extends Record<string, any>, PK extends (keyof Properties)[] | undefined = undefined, Base = never, Repository = never, ForceObject extends boolean = false, BaseDiscriminatorColumn extends string | undefined = undefined, DiscriminatorValue extends string | number | undefined = undefined, Embeddable extends boolean = false> = (IsNever<Base> extends true ? {} : Base extends {
759
+ export type InferEntityFromProperties<Properties extends Record<string, any>, PK extends (keyof Properties)[] | undefined = undefined, Base = never, Repository = never, ForceObject extends boolean = false, BaseDiscriminatorColumn extends string | undefined = undefined, DiscriminatorValue extends string | number | undefined = undefined, Embeddable extends boolean = false, ForceUndefined extends boolean = false> = (IsNever<Base> extends true ? {} : Base extends {
749
760
  toObject(...args: any[]): any;
750
761
  } ? BaseEntityMethods<{
751
762
  -readonly [K in keyof Properties]: InferBuilderValue<MaybeReturnType<Properties[K]>>;
@@ -763,6 +774,10 @@ export type InferEntityFromProperties<Properties extends Record<string, any>, PK
763
774
  [Config]?: DefineConfig<{
764
775
  forceObject: true;
765
776
  }>;
777
+ } : {}) & (ForceUndefined extends true ? {
778
+ [Config]?: DefineConfig<{
779
+ forceUndefined: true;
780
+ }>;
766
781
  } : {}) & {
767
782
  [IndexHints]?: [Omit<ExtractBaseProperties<Base>, keyof Properties> & Properties];
768
783
  };
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
  }
@@ -50,6 +50,8 @@ export class ObjectHydrator extends Hydrator {
50
50
  const lines = [];
51
51
  const context = new Map();
52
52
  const props = this.getProperties(meta, type);
53
+ const forceUndefined = meta.forceUndefined ?? this.config.get('forceUndefined');
54
+ const nullVal = forceUndefined ? 'undefined' : 'null';
53
55
  context.set('isPrimaryKey', Utils.isPrimaryKey);
54
56
  context.set('isEntity', EntityHelper.isEntity);
55
57
  context.set('isScalarReference', ScalarReference.isScalarReference);
@@ -76,7 +78,6 @@ export class ObjectHydrator extends Hydrator {
76
78
  .join('_');
77
79
  const ret = [];
78
80
  const idx = this.#tmpIndex++;
79
- const nullVal = this.config.get('forceUndefined') ? 'undefined' : 'null';
80
81
  if (prop.getter && !prop.setter && prop.persist === false) {
81
82
  return [];
82
83
  }
@@ -142,7 +143,6 @@ export class ObjectHydrator extends Hydrator {
142
143
  const hydrateToOne = (prop, dataKey, entityKey) => {
143
144
  const ret = [];
144
145
  const method = type === 'reference' ? 'createReference' : 'create';
145
- const nullVal = this.config.get('forceUndefined') ? 'undefined' : 'null';
146
146
  ret.push(` if (data${dataKey} === null) {\n entity${entityKey} = ${nullVal};`);
147
147
  ret.push(` } else if (typeof data${dataKey} !== 'undefined') {`);
148
148
  // For polymorphic: instanceof check; for regular: isPrimaryKey() check
@@ -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;
@@ -341,8 +341,6 @@ export class ObjectHydrator extends Hydrator {
341
341
  ...hydrateProperty(childProp, prop.object, [...path, childProp.embedded[1]], childDataKey).map(l => ' ' + l));
342
342
  });
343
343
  }
344
- /* v8 ignore next */
345
- const nullVal = this.config.get('forceUndefined') ? 'undefined' : 'null';
346
344
  if (prop.object) {
347
345
  ret.push(` } else if (data${dataKey} === null) {`);
348
346
  }
@@ -365,8 +363,7 @@ export class ObjectHydrator extends Hydrator {
365
363
  ret.push(...hydrateEmbedded(prop, [...path, `[idx_${idx}]`], `${dataKey}[idx_${idx}]`).map(l => ' ' + l));
366
364
  ret.push(` });`);
367
365
  ret.push(` } else if (data${dataKey} === null) {`);
368
- /* v8 ignore next */
369
- ret.push(` entity${entityKey} = ${this.config.get('forceUndefined') ? 'undefined' : 'null'};`);
366
+ ret.push(` entity${entityKey} = ${nullVal};`);
370
367
  ret.push(` }`);
371
368
  return ret;
372
369
  };
@@ -396,7 +393,7 @@ export class ObjectHydrator extends Hydrator {
396
393
  // ReferenceKind.SCALAR
397
394
  ret.push(...hydrateScalar(prop, path, dataKey));
398
395
  }
399
- if (this.config.get('forceUndefined')) {
396
+ if (forceUndefined) {
400
397
  ret.push(` if (data${dataKey} === null) entity${entityKey} = undefined;`);
401
398
  }
402
399
  return ret;
@@ -407,7 +404,7 @@ export class ObjectHydrator extends Hydrator {
407
404
  const code = `// compiled hydrator for entity ${meta.className} (${type + normalizeAccessors ? ' normalized' : ''})\n` +
408
405
  `return function(entity, data, factory, newEntity, convertCustomTypes, schema, parentSchema, normalizeAccessors) {\n` +
409
406
  `${lines.join('\n')}\n}`;
410
- const fnKey = `hydrator-${meta.uniqueName}-${type}-${normalizeAccessors}`;
407
+ const fnKey = `hydrator-${meta.compiledName}-${type}-${normalizeAccessors}`;
411
408
  const hydrator = Utils.createFunction(context, code, this.config.get('compiledFunctions'), fnKey);
412
409
  this.#hydrators[key].set(meta.class, hydrator);
413
410
  return hydrator;
@@ -547,7 +547,7 @@ export class MetadataDiscovery {
547
547
  const fieldNames1 = prop.targetMeta.getPrimaryProps().flatMap(pk => pk.fieldNames);
548
548
  const idColumns = fieldNames1.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.discriminator, fieldName, fieldNames1.length > 1));
549
549
  prop.fieldNames ??= [prop.discriminatorColumn, ...idColumns];
550
- prop.joinColumns ??= idColumns;
550
+ prop.joinColumns ??= prop.fieldNames.slice(1);
551
551
  prop.referencedColumnNames ??= fieldNames1;
552
552
  prop.referencedTableName ??= prop.targetMeta.tableName;
553
553
  return;
@@ -564,7 +564,10 @@ export class MetadataDiscovery {
564
564
  }
565
565
  Utils.defaultValue(prop, 'referencedTableName', meta2.tableName);
566
566
  if (!prop.joinColumns) {
567
- prop.joinColumns = fieldNames.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.name, fieldName, fieldNames.length > 1));
567
+ // must match the real FK columns, which ignore `targetKey` naming for simple PK targets
568
+ prop.joinColumns = prop.targetKey
569
+ ? prop.fieldNames
570
+ : fieldNames.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.name, fieldName, fieldNames.length > 1));
568
571
  }
569
572
  if (!prop.referencedColumnNames) {
570
573
  prop.referencedColumnNames = fieldNames;
@@ -670,6 +673,12 @@ export class MetadataDiscovery {
670
673
  if (prop2.kind !== ReferenceKind.SCALAR && prop2.type === meta.className) {
671
674
  meta.referencingProperties.push({ meta: meta2, prop: prop2 });
672
675
  }
676
+ if (prop2.targetKey &&
677
+ (prop2.type === meta.className || prop2.polymorphTargets?.some(t => t.className === meta.className)) &&
678
+ !(meta.primaryKeys.length === 1 && meta.primaryKeys[0] === prop2.targetKey) &&
679
+ !meta.root.targetKeys?.includes(prop2.targetKey)) {
680
+ (meta.root.targetKeys ??= []).push(prop2.targetKey);
681
+ }
673
682
  }
674
683
  }
675
684
  }
@@ -1062,6 +1071,7 @@ export class MetadataDiscovery {
1062
1071
  });
1063
1072
  ownProps.forEach(prop => (meta.properties[prop.name] = prop));
1064
1073
  meta.filters = { ...base.filters, ...meta.filters };
1074
+ meta.forceUndefined ??= base.forceUndefined;
1065
1075
  if (!meta.discriminatorValue) {
1066
1076
  Object.values(base.properties)
1067
1077
  .filter(prop => !old.includes(prop.name))
@@ -1174,12 +1184,24 @@ export class MetadataDiscovery {
1174
1184
  if (!prop.discriminator && !prop.discriminatorColumn && !prop.discriminatorMap && !Array.isArray(prop.target)) {
1175
1185
  return;
1176
1186
  }
1187
+ const isToOne = [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind);
1188
+ const isUnionTargetMN = prop.kind === ReferenceKind.MANY_TO_MANY && Array.isArray(prop.target);
1177
1189
  prop.polymorphic = true;
1178
1190
  prop.discriminator ??= prop.name;
1191
+ // explicit `fieldNames` use the `[discriminatorColumn, ...fkIdColumns]` layout
1192
+ if (isToOne && prop.fieldNames && prop.fieldNames.length > 1) {
1193
+ prop.discriminatorColumn ??= prop.fieldNames[0];
1194
+ // `EntitySchema` mirrors `fieldNames` into `joinColumns`, let `initManyToOneFields` derive them after embedded prefixing
1195
+ if (Utils.equals(prop.joinColumns, prop.fieldNames)) {
1196
+ delete prop.joinColumns;
1197
+ }
1198
+ }
1179
1199
  prop.discriminatorColumn ??= this.#namingStrategy.discriminatorColumnName(prop.discriminator);
1180
1200
  prop.createForeignKeyConstraint = false;
1181
- const isToOne = [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind);
1182
- const isUnionTargetMN = prop.kind === ReferenceKind.MANY_TO_MANY && Array.isArray(prop.target);
1201
+ // a single field name can only be the FK column, e.g. mirrored from `joinColumn`
1202
+ if (isToOne && prop.fieldNames?.length === 1) {
1203
+ prop.fieldNames = [prop.discriminatorColumn, ...prop.fieldNames];
1204
+ }
1183
1205
  if (isToOne || isUnionTargetMN) {
1184
1206
  const types = prop.type.split(/ ?\| ?/);
1185
1207
  prop.polymorphTargets = discovered.filter(m => types.includes(m.className) && !m.embeddable);
@@ -1439,7 +1461,8 @@ export class MetadataDiscovery {
1439
1461
  }
1440
1462
  }
1441
1463
  rootProp.stiFieldNameMap[meta.discriminatorValue] = prop.fieldNames[0];
1442
- rootProp.stiFieldNames.push(...prop.fieldNames);
1464
+ // subtypes that only differ in type share the column
1465
+ rootProp.stiFieldNames = Utils.unique([...rootProp.stiFieldNames, ...prop.fieldNames]);
1443
1466
  newProp.nullable = true;
1444
1467
  newProp.name = name;
1445
1468
  newProp.hydrate = false;
@@ -32,6 +32,13 @@ export declare class MetadataStorage {
32
32
  getById<T>(id: number): EntityMetadata<T>;
33
33
  /** Returns metadata by class name, optionally throwing if not found. */
34
34
  getByClassName<T = any, V extends boolean = true>(className: string, validate?: V): V extends true ? EntityMetadata<T> : EntityMetadata<T> | undefined;
35
+ /**
36
+ * Returns the first registered metadata with the given table name, in registration order (entities sharing
37
+ * a table, like STI children, resolve to the one registered first). The index is built on first use, as table
38
+ * names are only final once discovery has finished.
39
+ * @internal
40
+ */
41
+ getByTableName<T = any>(tableName: string): EntityMetadata<T> | undefined;
35
42
  /** Returns metadata by unique name, optionally throwing if not found. */
36
43
  getByUniqueName<T = any, V extends boolean = true>(uniqueName: string, validate?: V): V extends true ? EntityMetadata<T> : EntityMetadata<T> | undefined;
37
44
  private validate;
@@ -18,6 +18,8 @@ export class MetadataStorage {
18
18
  #classNameMap;
19
19
  #uniqueNameMap;
20
20
  #ambiguousNames = new Set();
21
+ /** Built on first use by `getByTableName()`, dropped whenever an entity is registered or removed. */
22
+ #tableNameMap;
21
23
  constructor(metadata = {}) {
22
24
  this.#idMap = {};
23
25
  this.#uniqueNameMap = {};
@@ -102,6 +104,7 @@ export class MetadataStorage {
102
104
  /** Registers metadata for the given entity. */
103
105
  set(entityName, meta) {
104
106
  this.#metadataMap.set(entityName, meta);
107
+ this.#tableNameMap = undefined;
105
108
  this.#idMap[meta._id] = meta;
106
109
  this.#uniqueNameMap[meta.uniqueName] = meta;
107
110
  const className = Utils.className(entityName);
@@ -110,6 +113,12 @@ export class MetadataStorage {
110
113
  if (existing && existing !== meta && existing.class !== meta.class) {
111
114
  this.#ambiguousNames.add(className);
112
115
  }
116
+ // colliding names are numbered in storage order, which follows the discovery config, not module evaluation
117
+ const sameName = this.#ambiguousNames.has(className)
118
+ ? [...new Set(this.#metadataMap.values())].filter(m => m.className === meta.className)
119
+ : [meta];
120
+ const ordinal = sameName.indexOf(meta);
121
+ meta.compiledName = ordinal > 0 ? `${meta.className}#${ordinal}` : meta.className;
113
122
  this.#classNameMap[className] = meta;
114
123
  return meta;
115
124
  }
@@ -118,6 +127,7 @@ export class MetadataStorage {
118
127
  const meta = this.find(entityName);
119
128
  if (meta) {
120
129
  this.#metadataMap.delete(meta.class);
130
+ this.#tableNameMap = undefined;
121
131
  delete this.#idMap[meta._id];
122
132
  delete this.#uniqueNameMap[meta.uniqueName];
123
133
  delete this.#classNameMap[meta.className];
@@ -145,6 +155,23 @@ export class MetadataStorage {
145
155
  getByClassName(className, validate = true) {
146
156
  return this.validate(this.#classNameMap[className], className, validate);
147
157
  }
158
+ /**
159
+ * Returns the first registered metadata with the given table name, in registration order (entities sharing
160
+ * a table, like STI children, resolve to the one registered first). The index is built on first use, as table
161
+ * names are only final once discovery has finished.
162
+ * @internal
163
+ */
164
+ getByTableName(tableName) {
165
+ if (!this.#tableNameMap) {
166
+ this.#tableNameMap = new Map();
167
+ for (const meta of this.#metadataMap.values()) {
168
+ if (!this.#tableNameMap.has(meta.tableName)) {
169
+ this.#tableNameMap.set(meta.tableName, meta);
170
+ }
171
+ }
172
+ }
173
+ return this.#tableNameMap.get(tableName);
174
+ }
148
175
  /** Returns metadata by unique name, optionally throwing if not found. */
149
176
  getByUniqueName(uniqueName, validate = true) {
150
177
  return this.validate(this.#uniqueNameMap[uniqueName], uniqueName, validate);
@@ -85,6 +85,8 @@ export type EntityOptions<T, E = T extends EntityClass<infer P> ? P : T> = {
85
85
  abstract?: boolean;
86
86
  /** Disables change tracking - such entities are ignored during flush. */
87
87
  readonly?: boolean;
88
+ /** Hydrate `null` values as `undefined` for this entity, overriding the global `forceUndefined` option. */
89
+ forceUndefined?: boolean;
88
90
  /** Marks entity as {@doclink virtual-entities | virtual}. This is set automatically when you use `expression` option (unless `view` is set). */
89
91
  virtual?: boolean;
90
92
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/core",
3
- "version": "7.3.0-dev.0",
3
+ "version": "7.3.0-dev.10",
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
@@ -246,6 +246,8 @@ export type CleanTypeConfig<T> = Compute<Pick<T, Extract<keyof T, keyof TypeConf
246
246
  /** Configuration options that can be set on an entity via the `[Config]` symbol. */
247
247
  export interface TypeConfig {
248
248
  forceObject?: boolean;
249
+ /** Type-level marker of the `forceUndefined` entity option: `defineEntity` infers nullable properties as `T | undefined` instead of `T | null | undefined`. */
250
+ forceUndefined?: boolean;
249
251
  }
250
252
  declare const __selectedType: unique symbol;
251
253
  declare const __loadedType: unique symbol;
@@ -1173,6 +1175,7 @@ export interface EntityMetadata<Entity = any, Class extends EntityCtor<Entity> =
1173
1175
  meta: EntityMetadata<Entity>;
1174
1176
  prop: EntityProperty<Entity>;
1175
1177
  }[];
1178
+ targetKeys?: EntityKey<Entity>[];
1176
1179
  comparableProps: EntityProperty<Entity>[];
1177
1180
  trackingProps: EntityProperty<Entity>[];
1178
1181
  hydrateProps: EntityProperty<Entity>[];
@@ -1223,6 +1226,8 @@ export interface EntityMetadata<Entity = any, Class extends EntityCtor<Entity> =
1223
1226
  selfReferencing?: boolean;
1224
1227
  hasUniqueProps?: boolean;
1225
1228
  readonly?: boolean;
1229
+ /** Per-entity override of the global `forceUndefined` option. */
1230
+ forceUndefined?: boolean;
1226
1231
  polymorphs?: EntityMetadata[];
1227
1232
  root: EntityMetadata<Entity>;
1228
1233
  definedProperties: Dictionary;
@@ -1254,6 +1259,8 @@ export interface EntityMetadata<Entity = any, Class extends EntityCtor<Entity> =
1254
1259
  orderBy?: QueryOrderMap<Entity> | QueryOrderMap<Entity>[];
1255
1260
  /** @internal can be used for computed numeric cache keys */
1256
1261
  readonly _id: number;
1262
+ /** @internal name that does not depend on module evaluation order (unlike `_id`), used to key `compiledFunctions` */
1263
+ compiledName?: string;
1257
1264
  }
1258
1265
  /** Options for `ISchemaGenerator.create()`. */
1259
1266
  export interface CreateSchemaOptions {
@@ -7,6 +7,7 @@ import { Reference } from '../entity/Reference.js';
7
7
  import { PolymorphicRef } from '../entity/PolymorphicRef.js';
8
8
  import { ReferenceKind } from '../enums.js';
9
9
  import { isRaw } from '../utils/RawQueryFragment.js';
10
+ import { ValidationError } from '../errors.js';
10
11
  /** @internal Computes change sets by comparing entity state against original snapshots. */
11
12
  export class ChangeSetComputer {
12
13
  #comparator;
@@ -48,6 +49,10 @@ export class ChangeSetComputer {
48
49
  if (Utils.equals(data, wrapped.__originalEntityData)) {
49
50
  return null;
50
51
  }
52
+ // a reference known only by its `targetKey` has no PK, so the update would have no condition
53
+ if (!wrapped.hasPrimaryKey()) {
54
+ throw ValidationError.referenceWithoutPK(entity);
55
+ }
51
56
  }
52
57
  const changeSet = new ChangeSet(entity, type, this.computePayload(entity), meta);
53
58
  changeSet.originalEntity = wrapped.__originalEntityData;
@@ -162,21 +167,30 @@ export class ChangeSetComputer {
162
167
  const target = Reference.unwrapReference(rawTarget);
163
168
  const needsProcessing = target != null && (prop.targetKey != null || !target.__helper.hasPrimaryKey());
164
169
  if (needsProcessing) {
170
+ // a reference known only by its `targetKey` has no PK, and is never inserted to generate one
171
+ if (!prop.targetKey && !target.__helper.__initialized) {
172
+ throw ValidationError.referenceWithoutPK(target);
173
+ }
165
174
  let value = prop.targetKey ? target[prop.targetKey] : target.__helper.__identifier;
166
- /* v8 ignore next */
167
- if (prop.targetKey && prop.targetMeta) {
168
- const targetProp = prop.targetMeta.properties[prop.targetKey];
175
+ // use the actual target's meta, a polymorphic relation has several
176
+ if (prop.targetKey) {
177
+ const targetProp = target.__meta.properties[prop.targetKey];
169
178
  if (targetProp?.customType) {
170
179
  value = targetProp.customType.convertToDatabaseValue(value, this.#platform, { mode: 'serialization' });
171
180
  }
172
181
  }
173
182
  if (prop.polymorphic) {
174
183
  const discriminator = QueryHelper.findDiscriminatorValue(prop.discriminatorMap, target.constructor);
175
- Utils.setPayloadProperty(changeSet.payload, changeSet.meta, prop, new PolymorphicRef(discriminator, value), idx);
184
+ value = new PolymorphicRef(discriminator, value);
176
185
  }
177
- else {
178
- Utils.setPayloadProperty(changeSet.payload, changeSet.meta, prop, value, idx);
186
+ // an unchanged `targetKey` FK is not part of the diff
187
+ if (prop.targetKey &&
188
+ changeSet.type === ChangeSetType.UPDATE &&
189
+ !(prop.name in changeSet.payload) &&
190
+ Utils.equals(changeSet.originalEntity?.[prop.name], value)) {
191
+ return;
179
192
  }
193
+ Utils.setPayloadProperty(changeSet.payload, changeSet.meta, prop, value, idx);
180
194
  }
181
195
  });
182
196
  }
@@ -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 = [];
@@ -79,7 +79,7 @@ export class UnitOfWork {
79
79
  * @internal
80
80
  */
81
81
  normalizeEntityData(meta, data) {
82
- const forceUndefined = this.#em.config.get('forceUndefined');
82
+ const forceUndefined = meta.forceUndefined ?? this.#em.config.get('forceUndefined');
83
83
  for (const key of Utils.keys(data)) {
84
84
  const prop = meta.properties[key];
85
85
  if (!prop) {
@@ -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) &&
@@ -95,7 +95,7 @@ export class EntityComparator {
95
95
  lines.push(` return entity${this.wrap(pk)};`);
96
96
  }
97
97
  const code = `// compiled pk getter for entity ${meta.className}\n` + `return function(entity) {\n${lines.join('\n')}\n}`;
98
- const fnKey = `pkGetter-${meta.uniqueName}`;
98
+ const fnKey = `pkGetter-${meta.compiledName}`;
99
99
  const pkSerializer = Utils.createFunction(context, code, this.#config?.get('compiledFunctions'), fnKey);
100
100
  this.#pkGetters.set(meta, pkSerializer);
101
101
  return pkSerializer;
@@ -147,7 +147,7 @@ export class EntityComparator {
147
147
  }
148
148
  const code = `// compiled pk getter (with converted custom types) for entity ${meta.className}\n` +
149
149
  `return function(entity) {\n${lines.join('\n')}\n}`;
150
- const fnKey = `pkGetterConverted-${meta.uniqueName}`;
150
+ const fnKey = `pkGetterConverted-${meta.compiledName}`;
151
151
  const pkSerializer = Utils.createFunction(context, code, this.#config?.get('compiledFunctions'), fnKey);
152
152
  this.#pkGettersConverted.set(meta, pkSerializer);
153
153
  return pkSerializer;
@@ -206,7 +206,7 @@ export class EntityComparator {
206
206
  }
207
207
  }
208
208
  const code = `// compiled pk serializer for entity ${meta.className}\n` + `return function(entity) {\n${lines.join('\n')}\n}`;
209
- const fnKey = `pkSerializer-${meta.uniqueName}`;
209
+ const fnKey = `pkSerializer-${meta.compiledName}`;
210
210
  const pkSerializer = Utils.createFunction(context, code, this.#config?.get('compiledFunctions'), fnKey);
211
211
  this.#pkSerializers.set(meta, pkSerializer);
212
212
  return pkSerializer;
@@ -236,7 +236,7 @@ export class EntityComparator {
236
236
  })
237
237
  .forEach(prop => lines.push(this.getPropertySnapshot(meta, prop, context, this.wrap(prop.name), this.wrap(prop.name), [prop.name])));
238
238
  const code = `return function(entity) {\n const ret = {};\n${lines.join('\n')}\n return ret;\n}`;
239
- const fnKey = `snapshotGenerator-${meta.uniqueName}`;
239
+ const fnKey = `snapshotGenerator-${meta.compiledName}`;
240
240
  const snapshotGenerator = Utils.createFunction(context, code, this.#config?.get('compiledFunctions'), fnKey);
241
241
  this.#snapshotGenerators.set(meta, snapshotGenerator);
242
242
  return snapshotGenerator;
@@ -350,14 +350,27 @@ export class EntityComparator {
350
350
  if (prop.embedded && (meta.embeddable || meta.properties[prop.embedded[0]].object)) {
351
351
  continue;
352
352
  }
353
+ let defined = `typeof ${this.propName(prop.fieldNames[0])} !== 'undefined'`;
354
+ // STI subtypes with conflicting columns share the property, so another subtype's null column must not override it
355
+ if (prop.renamedFrom) {
356
+ lines.push(`${padding} ${this.propName(prop.fieldNames[0], 'mapped')} = true;`);
357
+ defined = `${this.propName(prop.fieldNames[0])} != null`;
358
+ // a column shared with a differently typed subtype is left for each subtype's hydrator to convert
359
+ if (meta.props.some(p => p !== prop && p.name === prop.name && p.fieldNames?.[0] === prop.fieldNames[0])) {
360
+ lines.push(`${padding} if (${defined}) {`);
361
+ lines.push(`${padding} ret${this.wrap(prop.name)} = ${this.propName(prop.fieldNames[0])};`);
362
+ lines.push(`${padding} }`);
363
+ continue;
364
+ }
365
+ }
353
366
  if (prop.runtimeType === 'boolean') {
354
- lines.push(`${padding} if (typeof ${this.propName(prop.fieldNames[0])} !== 'undefined') {`);
367
+ lines.push(`${padding} if (${defined}) {`);
355
368
  lines.push(`${padding} ret${this.wrap(prop.name)} = ${this.propName(prop.fieldNames[0])} == null ? ${this.propName(prop.fieldNames[0])} : !!${this.propName(prop.fieldNames[0])};`);
356
369
  lines.push(`${padding} ${this.propName(prop.fieldNames[0], 'mapped')} = true;`);
357
370
  lines.push(`${padding} }`);
358
371
  }
359
372
  else if (prop.runtimeType === 'Date' && !this.#platform.isNumericProperty(prop)) {
360
- lines.push(`${padding} if (typeof ${this.propName(prop.fieldNames[0])} !== 'undefined') {`);
373
+ lines.push(`${padding} if (${defined}) {`);
361
374
  context.set('parseDate', (value) => this.#platform.parseDate(value));
362
375
  parseDate('ret' + this.wrap(prop.name), this.propName(prop.fieldNames[0]), padding);
363
376
  lines.push(`${padding} ${this.propName(prop.fieldNames[0], 'mapped')} = true;`);
@@ -372,13 +385,13 @@ export class EntityComparator {
372
385
  }
373
386
  return item == null ? item : this.getResultMapper(prop.targetMeta)(item);
374
387
  });
375
- lines.push(`${padding} if (typeof ${this.propName(prop.fieldNames[0])} !== 'undefined') {`);
388
+ lines.push(`${padding} if (${defined}) {`);
376
389
  lines.push(`${padding} ret${this.wrap(prop.name)} = ${this.propName(prop.fieldNames[0])} == null ? ${this.propName(prop.fieldNames[0])} : mapEmbeddedResult_${idx}(${this.propName(prop.fieldNames[0])});`);
377
390
  lines.push(`${padding} ${this.propName(prop.fieldNames[0], 'mapped')} = true;`);
378
391
  lines.push(`${padding} }`);
379
392
  }
380
393
  else if (prop.kind !== ReferenceKind.EMBEDDED) {
381
- lines.push(`${padding} if (typeof ${this.propName(prop.fieldNames[0])} !== 'undefined') {`);
394
+ lines.push(`${padding} if (${defined}) {`);
382
395
  lines.push(`${padding} ret${this.wrap(prop.name)} = ${this.propName(prop.fieldNames[0])};`);
383
396
  lines.push(`${padding} ${this.propName(prop.fieldNames[0], 'mapped')} = true;`);
384
397
  lines.push(`${padding} }`);
@@ -420,7 +433,7 @@ export class EntityComparator {
420
433
  lines.push(` for (let k in result) { if (Object.hasOwn(result, k) && !mapped[k] && ret[k] === undefined${knownKeysGuard}) ret[k] = result[k]; }`);
421
434
  const code = `// compiled mapper for entity ${meta.className}\n` +
422
435
  `return function(result) {\n const ret = {};\n${lines.join('\n')}\n return ret;\n}`;
423
- const fnKey = `resultMapper-${meta.uniqueName}`;
436
+ const fnKey = `resultMapper-${meta.compiledName}`;
424
437
  const resultMapper = Utils.createFunction(context, code, this.#config?.get('compiledFunctions'), fnKey);
425
438
  this.#mappers.set(meta, resultMapper);
426
439
  return resultMapper;
@@ -608,9 +621,24 @@ export class EntityComparator {
608
621
  ret += ` if (discriminator !== undefined) break;\n`;
609
622
  ret += ` __cls${level} = Object.getPrototypeOf(__cls${level});\n`;
610
623
  ret += ` }\n`;
611
- ret += ` const pk = val${level}?.__helper?.__identifier && !val${level}?.__helper?.hasPrimaryKey()\n`;
612
- ret += ` ? val${level}.__helper.__identifier\n`;
613
- ret += ` : toArray(val${level}?.__helper?.getPrimaryKey(true));\n`;
624
+ if (prop.targetKey) {
625
+ // the FK holds the `targetKey` value in the format of the actual target's column
626
+ const targetKey = prop.targetKey;
627
+ const targetKeyValueKey = `targetKeyValue_${this.safeKey(prop.name)}`;
628
+ context.set(targetKeyValueKey, (val) => {
629
+ const customType = val.__meta.properties[targetKey]?.customType;
630
+ const value = val[targetKey];
631
+ return customType
632
+ ? customType.convertToDatabaseValue(value, this.#platform, { mode: 'serialization' })
633
+ : value;
634
+ });
635
+ ret += ` const pk = ${targetKeyValueKey}(val${level});\n`;
636
+ }
637
+ else {
638
+ ret += ` const pk = val${level}?.__helper?.__identifier && !val${level}?.__helper?.hasPrimaryKey()\n`;
639
+ ret += ` ? val${level}.__helper.__identifier\n`;
640
+ ret += ` : toArray(val${level}?.__helper?.getPrimaryKey(true));\n`;
641
+ }
614
642
  ret += ` ret${dataKey} = new PolymorphicRef(discriminator, pk);\n`;
615
643
  ret += ` }\n`;
616
644
  }
@@ -624,8 +652,8 @@ export class EntityComparator {
624
652
  ret += ` } else if (typeof entity${entityKey} !== 'undefined') {\n`;
625
653
  ret += ` const val${level} = entity${entityKey}${unwrap};\n`;
626
654
  if (targetProp?.customType) {
627
- // If targetKey property has a custom type, convert to database value
628
- const convertorKey = this.registerCustomType(targetProp, context);
655
+ // keyed by the relation name to avoid clashing with a same-named owner prop, the relation itself lacks the custom type with an explicit `columnType`
656
+ const convertorKey = this.registerCustomType({ ...prop, customType: targetProp.customType }, context);
629
657
  ret += ` ret${dataKey} = convertToDatabaseValue_${convertorKey}(val${level}${targetAccess});\n`;
630
658
  }
631
659
  else {
@@ -688,7 +716,7 @@ export class EntityComparator {
688
716
  lines.push(`}`);
689
717
  const code = `// compiled comparator for entity ${meta.className}\n` +
690
718
  `return function(last, current, options) {\n const diff = {};\n${lines.join('\n')}\n return diff;\n}`;
691
- const fnKey = `comparator-${meta.uniqueName}`;
719
+ const fnKey = `comparator-${meta.compiledName}`;
692
720
  const comparator = Utils.createFunction(context, code, this.#config?.get('compiledFunctions'), fnKey);
693
721
  this.#comparators.set(meta, comparator);
694
722
  return comparator;
@@ -35,9 +35,35 @@ 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>;
47
+ /** @internal Splits a polymorphic target key like `imageable[Article]` into the property and target class names. */
48
+ static splitPolymorphicKey(key: string): [string, string?];
49
+ /** @internal Resolves the targets of a polymorphic target key like `imageable[Article]` or `imageable[Product,Article]`. */
50
+ static findTargetMetas(prop: EntityProperty | null | undefined, key: string): EntityMetadata[];
51
+ /** @internal Resolves the target entity of a condition key, including polymorphic target keys like `imageable[Article]`. */
52
+ static findTargetMeta(prop: EntityProperty | null | undefined, key: string): EntityMetadata | undefined;
53
+ /**
54
+ * Routes conditions on polymorphic to-one relations to the targets that define all the queried properties,
55
+ * e.g. `{ imageable: { title } }` becomes `{ 'imageable[Article]': { title } }`, or an `$or` of such keys
56
+ * when more targets match. The first target alone keeps the regular relation join.
57
+ */
58
+ private static splitPolymorphicConditions;
59
+ /**
60
+ * Routes ordering by properties of polymorphic to-one relations to the targets that define them, e.g.
61
+ * `{ imageable: { name: 'asc' } }` becomes `{ 'imageable[Product,Article]': { name: 'asc' } }`, ordered by
62
+ * the first non-null value. Nested relations and formulas are ordered via the first target defining them.
63
+ */
64
+ private static splitPolymorphicOrderBy;
65
+ /** Property names used in a condition, including those nested in group operators. */
66
+ private static collectConditionKeys;
41
67
  static getActiveFilters<T>(meta: EntityMetadata<T>, options: FilterOptions | undefined, filters: Dictionary<FilterDef>): FilterDef[];
42
68
  /** @internal Sentinel wrapping for arguments accessed while statically resolving an `rls` filter condition. */
43
69
  static readonly RLS_SENTINEL_PREFIX = "__mikro_rls_arg__";
@@ -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
  }
@@ -158,17 +155,21 @@ export class QueryHelper {
158
155
  })));
159
156
  }
160
157
  Object.keys(where).forEach(k => {
161
- const prop = meta.properties[k];
162
- const meta2 = metadata.find(prop?.targetMeta?.class) || meta;
158
+ const prop = meta.properties[QueryHelper.splitPolymorphicKey(k)[0]];
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).
166
166
  // The criteria layer would otherwise emit a malformed predicate such as
167
167
  // `(joinCol1, joinCol2) = scalar`. Limited to owning relations — `joinColumns` on the
168
168
  // inverse side (1:m) describes the owning entity's FK columns, not the LHS tuple.
169
+ // Polymorphic relations are skipped too, a scalar would be compared to the discriminator column.
169
170
  if (prop?.owner &&
170
171
  [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) &&
171
- prop.joinColumns.length !== meta2.primaryKeys.length) {
172
+ (prop.polymorphic || prop.joinColumns.length !== meta2.primaryKeys.length)) {
172
173
  return;
173
174
  }
174
175
  where[k] = Utils.getPrimaryKeyValues(where[k], meta2, true);
@@ -176,6 +177,37 @@ export class QueryHelper {
176
177
  });
177
178
  return false;
178
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
+ }
179
211
  /** Copies the plain object and array structure of the condition, keeping the leaf values (e.g. entities) by reference. */
180
212
  static cloneWhere(where) {
181
213
  if (Array.isArray(where)) {
@@ -230,6 +262,11 @@ export class QueryHelper {
230
262
  if (!Utils.isPlainObject(where)) {
231
263
  return where;
232
264
  }
265
+ if (meta) {
266
+ where = (options.type === 'orderBy'
267
+ ? QueryHelper.splitPolymorphicOrderBy(meta, where)
268
+ : QueryHelper.splitPolymorphicConditions(meta, where));
269
+ }
233
270
  return Utils.getObjectQueryKeys(where).reduce((o, key) => {
234
271
  let value = where[key];
235
272
  const customExpression = Raw.isKnownFragmentSymbol(key);
@@ -277,10 +314,11 @@ export class QueryHelper {
277
314
  return o;
278
315
  }
279
316
  if (Utils.isPlainObject(value)) {
317
+ const targetMeta = customExpression ? undefined : QueryHelper.findTargetMeta(prop, key);
280
318
  o[key] = QueryHelper.processWhere({
281
319
  ...options,
282
320
  where: value,
283
- entityName: prop?.targetMeta?.class ?? entityName,
321
+ entityName: targetMeta?.class ?? entityName,
284
322
  root: false,
285
323
  });
286
324
  }
@@ -290,6 +328,95 @@ export class QueryHelper {
290
328
  return o;
291
329
  }, {});
292
330
  }
331
+ /** @internal Splits a polymorphic target key like `imageable[Article]` into the property and target class names. */
332
+ static splitPolymorphicKey(key) {
333
+ const match = /^(\w+)\[([\w,]+)]$/.exec(key);
334
+ return match ? [match[1], match[2]] : [key];
335
+ }
336
+ /** @internal Resolves the targets of a polymorphic target key like `imageable[Article]` or `imageable[Product,Article]`. */
337
+ static findTargetMetas(prop, key) {
338
+ const [, targetNames] = QueryHelper.splitPolymorphicKey(key);
339
+ const targets = targetNames?.split(',').map(name => prop?.polymorphTargets?.find(t => t.className === name));
340
+ return targets?.every(Boolean) ? targets : [];
341
+ }
342
+ /** @internal Resolves the target entity of a condition key, including polymorphic target keys like `imageable[Article]`. */
343
+ static findTargetMeta(prop, key) {
344
+ return QueryHelper.splitPolymorphicKey(key)[1] ? QueryHelper.findTargetMetas(prop, key)[0] : prop?.targetMeta;
345
+ }
346
+ /**
347
+ * Routes conditions on polymorphic to-one relations to the targets that define all the queried properties,
348
+ * e.g. `{ imageable: { title } }` becomes `{ 'imageable[Article]': { title } }`, or an `$or` of such keys
349
+ * when more targets match. The first target alone keeps the regular relation join.
350
+ */
351
+ static splitPolymorphicConditions(meta, payload) {
352
+ let ret = payload;
353
+ for (const key of Object.keys(payload)) {
354
+ const prop = meta.properties[key];
355
+ const value = payload[key];
356
+ if (!prop?.polymorphTargets ||
357
+ ![ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) ||
358
+ !Utils.isPlainObject(value)) {
359
+ continue;
360
+ }
361
+ const keys = this.collectConditionKeys(value);
362
+ const targets = prop.polymorphTargets.filter(target => keys.every(k => k in target.properties));
363
+ const primaryKeys = keys.every(k => targets.every(target => target.primaryKeys.includes(k)));
364
+ // PK conditions compare the FK columns without checking the type, so only when every target defines them
365
+ if (keys.length === 0 ||
366
+ targets.length === 0 ||
367
+ (primaryKeys && targets.length === prop.polymorphTargets.length) ||
368
+ (!primaryKeys && targets.length === 1 && targets[0] === prop.targetMeta)) {
369
+ continue;
370
+ }
371
+ const branches = targets.map(target => ({ [`${key}[${target.className}]`]: value }));
372
+ ret = { ...ret };
373
+ delete ret[key];
374
+ if (branches.length === 1) {
375
+ Object.assign(ret, branches[0]);
376
+ }
377
+ else {
378
+ ret.$and = [...(ret.$and ?? []), { $or: branches }];
379
+ }
380
+ }
381
+ return ret;
382
+ }
383
+ /**
384
+ * Routes ordering by properties of polymorphic to-one relations to the targets that define them, e.g.
385
+ * `{ imageable: { name: 'asc' } }` becomes `{ 'imageable[Product,Article]': { name: 'asc' } }`, ordered by
386
+ * the first non-null value. Nested relations and formulas are ordered via the first target defining them.
387
+ */
388
+ static splitPolymorphicOrderBy(meta, orderBy) {
389
+ const ret = {};
390
+ for (const key of Utils.getObjectQueryKeys(orderBy)) {
391
+ const prop = meta.properties[key];
392
+ const value = orderBy[key];
393
+ if (!prop?.polymorphTargets ||
394
+ ![ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind) ||
395
+ !Utils.isPlainObject(value)) {
396
+ ret[key] = value;
397
+ continue;
398
+ }
399
+ for (const field of Utils.getObjectQueryKeys(value)) {
400
+ const found = prop.polymorphTargets.filter(target => field in target.properties);
401
+ const sharedPK = found.length === prop.polymorphTargets.length && found.every(target => target.primaryKeys.includes(field));
402
+ const single = Utils.isPlainObject(value[field]) || found.some(target => target.properties[field].formula);
403
+ const targets = found.length === 0 ? [prop.targetMeta] : single ? [found[0]] : found;
404
+ // shared PKs are ordered by the FK column without a join
405
+ const targetKey = sharedPK ? key : `${key}[${targets.map(target => target.className).join(',')}]`;
406
+ ret[targetKey] = { ...ret[targetKey], [field]: value[field] };
407
+ }
408
+ }
409
+ return ret;
410
+ }
411
+ /** Property names used in a condition, including those nested in group operators. */
412
+ static collectConditionKeys(payload) {
413
+ return Object.keys(payload).flatMap(k => {
414
+ if (k in GroupOperator || k === '$not') {
415
+ return Utils.asArray(payload[k]).flatMap(item => Utils.isPlainObject(item) ? this.collectConditionKeys(item) : []);
416
+ }
417
+ return Utils.isOperator(k) ? [] : [k];
418
+ });
419
+ }
293
420
  static getActiveFilters(meta, options, filters) {
294
421
  if (options === false) {
295
422
  return [];
@@ -450,7 +577,7 @@ export class QueryHelper {
450
577
  }
451
578
  static findProperty(fieldName, options) {
452
579
  const parts = fieldName.split('.');
453
- const propName = parts.pop();
580
+ const propName = QueryHelper.splitPolymorphicKey(parts.pop())[0];
454
581
  const alias = parts.length > 0 ? parts.join('.') : undefined;
455
582
  const entityName = alias ? options.aliasMap?.[alias] : options.entityName;
456
583
  const meta = entityName ? options.metadata.find(entityName) : undefined;
@@ -494,6 +621,9 @@ export class QueryHelper {
494
621
  if (expanded !== w) {
495
622
  where[k] = expanded;
496
623
  }
624
+ else if (Array.isArray(w)) {
625
+ where[k] = w.map(expandItem);
626
+ }
497
627
  else if (Utils.isPlainObject(w)) {
498
628
  for (const op of Object.keys(w)) {
499
629
  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.0';
156
+ static #ORM_VERSION = '7.3.0-dev.10';
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}`;