@mikro-orm/core 7.3.0-dev.3 → 7.3.0-dev.5
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 +4 -0
- package/entity/EntityFactory.d.ts +1 -0
- package/entity/EntityFactory.js +32 -3
- package/entity/EntityLoader.js +7 -6
- package/entity/WrappedEntity.js +6 -1
- package/errors.d.ts +1 -0
- package/errors.js +3 -0
- package/hydration/ObjectHydrator.js +1 -1
- package/metadata/MetadataDiscovery.js +10 -1
- package/package.json +1 -1
- package/typings.d.ts +1 -0
- package/unit-of-work/ChangeSetComputer.js +20 -6
- package/unit-of-work/IdentityMap.d.ts +3 -2
- package/unit-of-work/IdentityMap.js +30 -7
- package/unit-of-work/UnitOfWork.js +25 -2
- package/utils/EntityComparator.js +20 -5
- package/utils/QueryHelper.d.ts +14 -0
- package/utils/QueryHelper.js +80 -11
- package/utils/TransactionManager.js +4 -0
- package/utils/Utils.js +1 -1
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)
|
package/entity/EntityFactory.js
CHANGED
|
@@ -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 =
|
|
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
|
}
|
package/entity/EntityLoader.js
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
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;
|
package/entity/WrappedEntity.js
CHANGED
|
@@ -83,7 +83,12 @@ export class WrappedEntity {
|
|
|
83
83
|
if (!this.__em) {
|
|
84
84
|
throw ValidationError.entityNotManaged(this.entity);
|
|
85
85
|
}
|
|
86
|
-
|
|
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.
|
|
210
|
+
ret.push(` data${dataKey} = convertToDatabaseValue_${this.safeKey(prop.name)}(entity${entityKey}.__helper.getTargetKeyValue(${prop.targetKey ? this.quote(prop.targetKey) : ''}));`);
|
|
211
211
|
ret.push(` }`);
|
|
212
212
|
}
|
|
213
213
|
return ret;
|
|
@@ -564,7 +564,10 @@ export class MetadataDiscovery {
|
|
|
564
564
|
}
|
|
565
565
|
Utils.defaultValue(prop, 'referencedTableName', meta2.tableName);
|
|
566
566
|
if (!prop.joinColumns) {
|
|
567
|
-
|
|
567
|
+
// must match the real FK columns, which ignore `targetKey` naming for simple PK targets
|
|
568
|
+
prop.joinColumns = prop.targetKey
|
|
569
|
+
? prop.fieldNames
|
|
570
|
+
: fieldNames.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.name, fieldName, fieldNames.length > 1));
|
|
568
571
|
}
|
|
569
572
|
if (!prop.referencedColumnNames) {
|
|
570
573
|
prop.referencedColumnNames = fieldNames;
|
|
@@ -670,6 +673,12 @@ export class MetadataDiscovery {
|
|
|
670
673
|
if (prop2.kind !== ReferenceKind.SCALAR && prop2.type === meta.className) {
|
|
671
674
|
meta.referencingProperties.push({ meta: meta2, prop: prop2 });
|
|
672
675
|
}
|
|
676
|
+
if (prop2.targetKey &&
|
|
677
|
+
(prop2.type === meta.className || prop2.polymorphTargets?.some(t => t.className === meta.className)) &&
|
|
678
|
+
!(meta.primaryKeys.length === 1 && meta.primaryKeys[0] === prop2.targetKey) &&
|
|
679
|
+
!meta.root.targetKeys?.includes(prop2.targetKey)) {
|
|
680
|
+
(meta.root.targetKeys ??= []).push(prop2.targetKey);
|
|
681
|
+
}
|
|
673
682
|
}
|
|
674
683
|
}
|
|
675
684
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mikro-orm/core",
|
|
3
|
-
"version": "7.3.0-dev.
|
|
3
|
+
"version": "7.3.0-dev.5",
|
|
4
4
|
"description": "TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, PostgreSQL and SQLite databases as well as usage with vanilla JavaScript.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"data-mapper",
|
package/typings.d.ts
CHANGED
|
@@ -1175,6 +1175,7 @@ export interface EntityMetadata<Entity = any, Class extends EntityCtor<Entity> =
|
|
|
1175
1175
|
meta: EntityMetadata<Entity>;
|
|
1176
1176
|
prop: EntityProperty<Entity>;
|
|
1177
1177
|
}[];
|
|
1178
|
+
targetKeys?: EntityKey<Entity>[];
|
|
1178
1179
|
comparableProps: EntityProperty<Entity>[];
|
|
1179
1180
|
trackingProps: EntityProperty<Entity>[];
|
|
1180
1181
|
hydrateProps: EntityProperty<Entity>[];
|
|
@@ -7,6 +7,7 @@ import { Reference } from '../entity/Reference.js';
|
|
|
7
7
|
import { PolymorphicRef } from '../entity/PolymorphicRef.js';
|
|
8
8
|
import { ReferenceKind } from '../enums.js';
|
|
9
9
|
import { isRaw } from '../utils/RawQueryFragment.js';
|
|
10
|
+
import { ValidationError } from '../errors.js';
|
|
10
11
|
/** @internal Computes change sets by comparing entity state against original snapshots. */
|
|
11
12
|
export class ChangeSetComputer {
|
|
12
13
|
#comparator;
|
|
@@ -48,6 +49,10 @@ export class ChangeSetComputer {
|
|
|
48
49
|
if (Utils.equals(data, wrapped.__originalEntityData)) {
|
|
49
50
|
return null;
|
|
50
51
|
}
|
|
52
|
+
// a reference known only by its `targetKey` has no PK, so the update would have no condition
|
|
53
|
+
if (!wrapped.hasPrimaryKey()) {
|
|
54
|
+
throw ValidationError.referenceWithoutPK(entity);
|
|
55
|
+
}
|
|
51
56
|
}
|
|
52
57
|
const changeSet = new ChangeSet(entity, type, this.computePayload(entity), meta);
|
|
53
58
|
changeSet.originalEntity = wrapped.__originalEntityData;
|
|
@@ -162,21 +167,30 @@ export class ChangeSetComputer {
|
|
|
162
167
|
const target = Reference.unwrapReference(rawTarget);
|
|
163
168
|
const needsProcessing = target != null && (prop.targetKey != null || !target.__helper.hasPrimaryKey());
|
|
164
169
|
if (needsProcessing) {
|
|
170
|
+
// a reference known only by its `targetKey` has no PK, and is never inserted to generate one
|
|
171
|
+
if (!prop.targetKey && !target.__helper.__initialized) {
|
|
172
|
+
throw ValidationError.referenceWithoutPK(target);
|
|
173
|
+
}
|
|
165
174
|
let value = prop.targetKey ? target[prop.targetKey] : target.__helper.__identifier;
|
|
166
|
-
|
|
167
|
-
if (prop.targetKey
|
|
168
|
-
const targetProp =
|
|
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
|
-
|
|
184
|
+
value = new PolymorphicRef(discriminator, value);
|
|
176
185
|
}
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
211
|
+
const entity = this.#identityMap.getByHash(meta, hash);
|
|
212
|
+
// the entry is stale when the key changed via refresh or merge, unless only the unflushed value differs
|
|
213
|
+
if (entity && '' + entity[key] !== '' + value) {
|
|
214
|
+
// the snapshot holds the database value
|
|
215
|
+
const dbValue = prop?.customType
|
|
216
|
+
? prop.customType.convertToDatabaseValue(value, this.#platform, { mode: 'serialization' })
|
|
217
|
+
: value;
|
|
218
|
+
if ('' + helper(entity).__originalEntityData?.[key] !== '' + dbValue) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return entity;
|
|
209
223
|
}
|
|
210
224
|
/**
|
|
211
225
|
* Stores an entity in the identity map under an alternate key (non-PK property).
|
|
@@ -368,6 +382,10 @@ export class UnitOfWork {
|
|
|
368
382
|
}
|
|
369
383
|
/** Marks an entity for removal, cascading to related entities. */
|
|
370
384
|
remove(entity, visited, options = {}) {
|
|
385
|
+
// a reference known only by its `targetKey` has no PK, so the delete would match nothing
|
|
386
|
+
if (!helper(entity).__initialized && !helper(entity).hasPrimaryKey()) {
|
|
387
|
+
throw ValidationError.referenceWithoutPK(entity);
|
|
388
|
+
}
|
|
371
389
|
// allow removing not managed entities if they are not part of the persist stack
|
|
372
390
|
if (helper(entity).__managed || !this.#persistStack.has(entity)) {
|
|
373
391
|
this.#removeStack.add(entity);
|
|
@@ -1118,6 +1136,11 @@ export class UnitOfWork {
|
|
|
1118
1136
|
for (const changeSet of changeSets) {
|
|
1119
1137
|
const wrapped = helper(changeSet.entity);
|
|
1120
1138
|
wrapped.__originalEntityData = this.#comparator.prepareEntity(changeSet.entity);
|
|
1139
|
+
// re-index so the entity is found by its new `targetKey` values instead of the old ones
|
|
1140
|
+
if (changeSet.meta.root.targetKeys?.some(key => key in changeSet.payload)) {
|
|
1141
|
+
this.#identityMap.delete(changeSet.entity);
|
|
1142
|
+
this.#identityMap.store(changeSet.entity);
|
|
1143
|
+
}
|
|
1121
1144
|
if (!wrapped.__initialized) {
|
|
1122
1145
|
for (const prop of changeSet.meta.relations) {
|
|
1123
1146
|
if ([ReferenceKind.MANY_TO_MANY, ReferenceKind.ONE_TO_MANY].includes(prop.kind) &&
|
|
@@ -608,9 +608,24 @@ export class EntityComparator {
|
|
|
608
608
|
ret += ` if (discriminator !== undefined) break;\n`;
|
|
609
609
|
ret += ` __cls${level} = Object.getPrototypeOf(__cls${level});\n`;
|
|
610
610
|
ret += ` }\n`;
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
611
|
+
if (prop.targetKey) {
|
|
612
|
+
// the FK holds the `targetKey` value in the format of the actual target's column
|
|
613
|
+
const targetKey = prop.targetKey;
|
|
614
|
+
const targetKeyValueKey = `targetKeyValue_${this.safeKey(prop.name)}`;
|
|
615
|
+
context.set(targetKeyValueKey, (val) => {
|
|
616
|
+
const customType = val.__meta.properties[targetKey]?.customType;
|
|
617
|
+
const value = val[targetKey];
|
|
618
|
+
return customType
|
|
619
|
+
? customType.convertToDatabaseValue(value, this.#platform, { mode: 'serialization' })
|
|
620
|
+
: value;
|
|
621
|
+
});
|
|
622
|
+
ret += ` const pk = ${targetKeyValueKey}(val${level});\n`;
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
ret += ` const pk = val${level}?.__helper?.__identifier && !val${level}?.__helper?.hasPrimaryKey()\n`;
|
|
626
|
+
ret += ` ? val${level}.__helper.__identifier\n`;
|
|
627
|
+
ret += ` : toArray(val${level}?.__helper?.getPrimaryKey(true));\n`;
|
|
628
|
+
}
|
|
614
629
|
ret += ` ret${dataKey} = new PolymorphicRef(discriminator, pk);\n`;
|
|
615
630
|
ret += ` }\n`;
|
|
616
631
|
}
|
|
@@ -624,8 +639,8 @@ export class EntityComparator {
|
|
|
624
639
|
ret += ` } else if (typeof entity${entityKey} !== 'undefined') {\n`;
|
|
625
640
|
ret += ` const val${level} = entity${entityKey}${unwrap};\n`;
|
|
626
641
|
if (targetProp?.customType) {
|
|
627
|
-
//
|
|
628
|
-
const convertorKey = this.registerCustomType(targetProp, context);
|
|
642
|
+
// keyed by the relation name to avoid clashing with a same-named owner prop, the relation itself lacks the custom type with an explicit `columnType`
|
|
643
|
+
const convertorKey = this.registerCustomType({ ...prop, customType: targetProp.customType }, context);
|
|
629
644
|
ret += ` ret${dataKey} = convertToDatabaseValue_${convertorKey}(val${level}${targetAccess});\n`;
|
|
630
645
|
}
|
|
631
646
|
else {
|
package/utils/QueryHelper.d.ts
CHANGED
|
@@ -35,11 +35,19 @@ 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>;
|
|
41
47
|
/** @internal Splits a polymorphic target key like `imageable[Article]` into the property and target class names. */
|
|
42
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[];
|
|
43
51
|
/** @internal Resolves the target entity of a condition key, including polymorphic target keys like `imageable[Article]`. */
|
|
44
52
|
static findTargetMeta(prop: EntityProperty | null | undefined, key: string): EntityMetadata | undefined;
|
|
45
53
|
/**
|
|
@@ -48,6 +56,12 @@ export declare class QueryHelper {
|
|
|
48
56
|
* when more targets match. The first target alone keeps the regular relation join.
|
|
49
57
|
*/
|
|
50
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;
|
|
51
65
|
/** Property names used in a condition, including those nested in group operators. */
|
|
52
66
|
private static collectConditionKeys;
|
|
53
67
|
static getActiveFilters<T>(meta: EntityMetadata<T>, options: FilterOptions | undefined, filters: Dictionary<FilterDef>): FilterDef[];
|
package/utils/QueryHelper.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
130
|
+
where[i] = Utils.getPrimaryKeyValues(item, meta, true);
|
|
134
131
|
}
|
|
135
132
|
});
|
|
136
133
|
}
|
|
@@ -160,6 +157,9 @@ export class QueryHelper {
|
|
|
160
157
|
Object.keys(where).forEach(k => {
|
|
161
158
|
const prop = meta.properties[QueryHelper.splitPolymorphicKey(k)[0]];
|
|
162
159
|
const meta2 = metadata.find(QueryHelper.findTargetMeta(prop, k)?.class) || meta;
|
|
160
|
+
if (prop?.polymorphic) {
|
|
161
|
+
where[k] = this.inlinePolymorphicPrimaryKeys(where[k], meta2, prop);
|
|
162
|
+
}
|
|
163
163
|
if (this.inlinePrimaryKeyObjects(where[k], meta2, metadata, k)) {
|
|
164
164
|
// Skip the PK collapse when an owning M:1/1:1 relation's FK column count does not match
|
|
165
165
|
// the target's PK column count (e.g. FK references target PK + an extra unique column).
|
|
@@ -177,6 +177,37 @@ export class QueryHelper {
|
|
|
177
177
|
});
|
|
178
178
|
return false;
|
|
179
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* A bare value on a polymorphic relation would be compared to the discriminator column, so PK values are turned
|
|
182
|
+
* into a PK condition instead, e.g. `1` becomes `{ id: 1 }` and `[{ id: 1 }, { id: 2 }]` becomes `{ id: { $in: [1, 2] } }`.
|
|
183
|
+
* Null checks and `[type, id]` tuples keep their meaning.
|
|
184
|
+
*/
|
|
185
|
+
static inlinePolymorphicPrimaryKeys(value, meta, prop) {
|
|
186
|
+
const pk = meta.primaryKeys[0];
|
|
187
|
+
const isScalar = (v) => v != null && !Array.isArray(v) && Utils.isPrimaryKey(v);
|
|
188
|
+
const isPrimaryKeyObject = (v) => Utils.isPlainObject(v) && Utils.getObjectKeysSize(v) === 1 && pk in v;
|
|
189
|
+
// lists of plain values are how `[type, id]` tuples get normalized (`{ $in: [type, id] }`), so only PK objects are inlined
|
|
190
|
+
const isList = (v) => Array.isArray(v) && v.length > 0 && v.every(isPrimaryKeyObject);
|
|
191
|
+
let cond;
|
|
192
|
+
if (isScalar(value)) {
|
|
193
|
+
cond = value;
|
|
194
|
+
}
|
|
195
|
+
else if (isList(value)) {
|
|
196
|
+
cond = { $in: value.map(v => v[pk]) };
|
|
197
|
+
}
|
|
198
|
+
else if (Utils.isPlainObject(value) &&
|
|
199
|
+
Utils.hasObjectKeys(value) &&
|
|
200
|
+
Object.entries(value).every(([op, v]) => Utils.isOperator(op) && !(op in GroupOperator) && op !== '$not' && (isScalar(v) || isList(v)))) {
|
|
201
|
+
cond = Object.fromEntries(Object.entries(value).map(([op, v]) => [op, isList(v) ? v.map(i => i[pk]) : v]));
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
if (meta.compositePK) {
|
|
207
|
+
throw new ValidationError(`Polymorphic relation ${prop.name} targets entities with a composite primary key, use PK objects or entity references in the condition instead.`);
|
|
208
|
+
}
|
|
209
|
+
return { [pk]: cond };
|
|
210
|
+
}
|
|
180
211
|
/** Copies the plain object and array structure of the condition, keeping the leaf values (e.g. entities) by reference. */
|
|
181
212
|
static cloneWhere(where) {
|
|
182
213
|
if (Array.isArray(where)) {
|
|
@@ -231,8 +262,10 @@ export class QueryHelper {
|
|
|
231
262
|
if (!Utils.isPlainObject(where)) {
|
|
232
263
|
return where;
|
|
233
264
|
}
|
|
234
|
-
if (meta
|
|
235
|
-
where =
|
|
265
|
+
if (meta) {
|
|
266
|
+
where = (options.type === 'orderBy'
|
|
267
|
+
? QueryHelper.splitPolymorphicOrderBy(meta, where)
|
|
268
|
+
: QueryHelper.splitPolymorphicConditions(meta, where));
|
|
236
269
|
}
|
|
237
270
|
return Utils.getObjectQueryKeys(where).reduce((o, key) => {
|
|
238
271
|
let value = where[key];
|
|
@@ -297,13 +330,18 @@ export class QueryHelper {
|
|
|
297
330
|
}
|
|
298
331
|
/** @internal Splits a polymorphic target key like `imageable[Article]` into the property and target class names. */
|
|
299
332
|
static splitPolymorphicKey(key) {
|
|
300
|
-
const match = /^(\w+)\[(\w+)]$/.exec(key);
|
|
333
|
+
const match = /^(\w+)\[([\w,]+)]$/.exec(key);
|
|
301
334
|
return match ? [match[1], match[2]] : [key];
|
|
302
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
|
+
}
|
|
303
342
|
/** @internal Resolves the target entity of a condition key, including polymorphic target keys like `imageable[Article]`. */
|
|
304
343
|
static findTargetMeta(prop, key) {
|
|
305
|
-
|
|
306
|
-
return targetName ? prop?.polymorphTargets?.find(t => t.className === targetName) : prop?.targetMeta;
|
|
344
|
+
return QueryHelper.splitPolymorphicKey(key)[1] ? QueryHelper.findTargetMetas(prop, key)[0] : prop?.targetMeta;
|
|
307
345
|
}
|
|
308
346
|
/**
|
|
309
347
|
* Routes conditions on polymorphic to-one relations to the targets that define all the queried properties,
|
|
@@ -342,6 +380,34 @@ export class QueryHelper {
|
|
|
342
380
|
}
|
|
343
381
|
return ret;
|
|
344
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
|
+
}
|
|
345
411
|
/** Property names used in a condition, including those nested in group operators. */
|
|
346
412
|
static collectConditionKeys(payload) {
|
|
347
413
|
return Object.keys(payload).flatMap(k => {
|
|
@@ -555,6 +621,9 @@ export class QueryHelper {
|
|
|
555
621
|
if (expanded !== w) {
|
|
556
622
|
where[k] = expanded;
|
|
557
623
|
}
|
|
624
|
+
else if (Array.isArray(w)) {
|
|
625
|
+
where[k] = w.map(expandItem);
|
|
626
|
+
}
|
|
558
627
|
else if (Utils.isPlainObject(w)) {
|
|
559
628
|
for (const op of Object.keys(w)) {
|
|
560
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.
|
|
156
|
+
static #ORM_VERSION = '7.3.0-dev.5';
|
|
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}`;
|