@mikro-orm/core 7.3.0-dev.1 → 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) {
@@ -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;
@@ -404,7 +404,7 @@ export class ObjectHydrator extends Hydrator {
404
404
  const code = `// compiled hydrator for entity ${meta.className} (${type + normalizeAccessors ? ' normalized' : ''})\n` +
405
405
  `return function(entity, data, factory, newEntity, convertCustomTypes, schema, parentSchema, normalizeAccessors) {\n` +
406
406
  `${lines.join('\n')}\n}`;
407
- const fnKey = `hydrator-${meta.uniqueName}-${type}-${normalizeAccessors}`;
407
+ const fnKey = `hydrator-${meta.compiledName}-${type}-${normalizeAccessors}`;
408
408
  const hydrator = Utils.createFunction(context, code, this.config.get('compiledFunctions'), fnKey);
409
409
  this.#hydrators[key].set(meta.class, hydrator);
410
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
  }
@@ -1175,12 +1184,24 @@ export class MetadataDiscovery {
1175
1184
  if (!prop.discriminator && !prop.discriminatorColumn && !prop.discriminatorMap && !Array.isArray(prop.target)) {
1176
1185
  return;
1177
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);
1178
1189
  prop.polymorphic = true;
1179
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
+ }
1180
1199
  prop.discriminatorColumn ??= this.#namingStrategy.discriminatorColumnName(prop.discriminator);
1181
1200
  prop.createForeignKeyConstraint = false;
1182
- const isToOne = [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind);
1183
- 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
+ }
1184
1205
  if (isToOne || isUnionTargetMN) {
1185
1206
  const types = prop.type.split(/ ?\| ?/);
1186
1207
  prop.polymorphTargets = discovered.filter(m => types.includes(m.className) && !m.embeddable);
@@ -1440,7 +1461,8 @@ export class MetadataDiscovery {
1440
1461
  }
1441
1462
  }
1442
1463
  rootProp.stiFieldNameMap[meta.discriminatorValue] = prop.fieldNames[0];
1443
- rootProp.stiFieldNames.push(...prop.fieldNames);
1464
+ // subtypes that only differ in type share the column
1465
+ rootProp.stiFieldNames = Utils.unique([...rootProp.stiFieldNames, ...prop.fieldNames]);
1444
1466
  newProp.nullable = true;
1445
1467
  newProp.name = name;
1446
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/core",
3
- "version": "7.3.0-dev.1",
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
@@ -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>[];
@@ -1258,6 +1259,8 @@ export interface EntityMetadata<Entity = any, Class extends EntityCtor<Entity> =
1258
1259
  orderBy?: QueryOrderMap<Entity> | QueryOrderMap<Entity>[];
1259
1260
  /** @internal can be used for computed numeric cache keys */
1260
1261
  readonly _id: number;
1262
+ /** @internal name that does not depend on module evaluation order (unlike `_id`), used to key `compiledFunctions` */
1263
+ compiledName?: string;
1261
1264
  }
1262
1265
  /** Options for `ISchemaGenerator.create()`. */
1263
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 = [];
@@ -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.1';
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}`;