@mikro-orm/core 7.3.0-dev.2 → 7.3.0-dev.20
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 +131 -32
- package/drivers/DatabaseDriver.d.ts +2 -0
- package/drivers/DatabaseDriver.js +16 -3
- package/entity/EntityAssigner.js +15 -3
- package/entity/EntityFactory.d.ts +8 -0
- package/entity/EntityFactory.js +69 -3
- package/entity/EntityLoader.js +31 -15
- package/entity/WrappedEntity.js +6 -1
- package/entity/defineEntity.d.ts +32 -19
- package/entity/defineEntity.js +9 -0
- package/entity/validators.js +4 -1
- package/errors.d.ts +7 -0
- package/errors.js +15 -0
- package/hydration/ObjectHydrator.js +6 -2
- package/metadata/MetadataDiscovery.d.ts +10 -0
- package/metadata/MetadataDiscovery.js +178 -30
- package/metadata/MetadataStorage.d.ts +8 -0
- package/metadata/MetadataStorage.js +47 -3
- package/metadata/MetadataValidator.js +2 -1
- package/metadata/types.d.ts +12 -0
- package/package.json +1 -1
- package/platforms/Platform.d.ts +6 -0
- package/platforms/Platform.js +26 -0
- package/serialization/EntitySerializer.js +52 -12
- package/types/DecimalType.js +34 -1
- package/types/DoubleType.d.ts +7 -3
- package/types/DoubleType.js +8 -3
- package/typings.d.ts +26 -6
- package/typings.js +5 -2
- package/unit-of-work/ChangeSetComputer.js +22 -7
- package/unit-of-work/ChangeSetPersister.js +24 -5
- package/unit-of-work/CommitOrderCalculator.js +2 -1
- package/unit-of-work/IdentityMap.d.ts +3 -2
- package/unit-of-work/IdentityMap.js +30 -7
- package/unit-of-work/UnitOfWork.js +45 -7
- package/utils/Configuration.d.ts +12 -1
- package/utils/EntityComparator.d.ts +5 -0
- package/utils/EntityComparator.js +87 -19
- package/utils/QueryHelper.d.ts +14 -0
- package/utils/QueryHelper.js +90 -13
- package/utils/RawQueryFragment.d.ts +5 -1
- package/utils/RawQueryFragment.js +27 -19
- package/utils/TransactionManager.js +4 -0
- package/utils/Utils.d.ts +2 -0
- package/utils/Utils.js +24 -8
- package/utils/clone.js +5 -7
- package/utils/env-vars.js +2 -0
- package/utils/fs-utils.js +19 -15
- package/utils/upsert-utils.js +2 -1
package/EntityManager.js
CHANGED
|
@@ -743,10 +743,15 @@ export class EntityManager {
|
|
|
743
743
|
if (Utils.isEmpty(options.orderBy) && !Raw.hasObjectFragments(options.orderBy)) {
|
|
744
744
|
throw new Error('Explicit `orderBy` option required');
|
|
745
745
|
}
|
|
746
|
+
const meta = this.metadata.get(entityName);
|
|
747
|
+
// same merge as em.find, so the cursor and the query see the same orderBy
|
|
748
|
+
if (meta.orderBy) {
|
|
749
|
+
options.orderBy = QueryHelper.mergeOrderBy(options.orderBy, meta.orderBy);
|
|
750
|
+
}
|
|
746
751
|
const [entities, count] = options.includeCount !== false
|
|
747
752
|
? await em.findAndCount(entityName, options.where, options)
|
|
748
753
|
: [await em.find(entityName, options.where, options)];
|
|
749
|
-
return new Cursor(entities, count, options,
|
|
754
|
+
return new Cursor(entities, count, options, meta);
|
|
750
755
|
}
|
|
751
756
|
/**
|
|
752
757
|
* Refreshes the persistent state of an entity from the database, overriding any local changes that have not yet been
|
|
@@ -781,26 +786,46 @@ export class EntityManager {
|
|
|
781
786
|
em.#unitOfWork.unsetIdentity(entity);
|
|
782
787
|
return null;
|
|
783
788
|
}
|
|
789
|
+
// `serialize()` applies `Type.toJSON()` and `serializedName`, take only relations and embeddables from it, scalars from the entity
|
|
790
|
+
const getData = (e, data = helper(e).serialize({
|
|
791
|
+
ignoreSerializers: true,
|
|
792
|
+
includeHidden: true,
|
|
793
|
+
convertCustomTypes: false,
|
|
794
|
+
})) => {
|
|
795
|
+
for (const prop of helper(e).__meta.props) {
|
|
796
|
+
if (prop.kind === ReferenceKind.SCALAR && !prop.ref) {
|
|
797
|
+
if (helper(e).__initialized || prop.primary) {
|
|
798
|
+
data[prop.name] = e[prop.name];
|
|
799
|
+
}
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (prop.serializedName && prop.serializedName in data) {
|
|
803
|
+
data[prop.name] = data[prop.serializedName];
|
|
804
|
+
}
|
|
805
|
+
if (prop.kind === ReferenceKind.EMBEDDED && e[prop.name] != null) {
|
|
806
|
+
Utils.asArray(e[prop.name]).forEach((item, i) => getData(item, prop.array ? data[prop.name][i] : data[prop.name]));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return data;
|
|
810
|
+
};
|
|
811
|
+
const hydrate = (target, source) => {
|
|
812
|
+
em.config
|
|
813
|
+
.getHydrator(this.metadata)
|
|
814
|
+
.hydrate(target, helper(target).__meta, getData(source), em.#entityFactory, 'full', false, false);
|
|
815
|
+
Utils.merge(helper(target).__originalEntityData, this.#comparator.prepareEntity(source));
|
|
816
|
+
};
|
|
784
817
|
let found = false;
|
|
785
818
|
for (const e of fork.#unitOfWork.getIdentityMap()) {
|
|
786
819
|
const ref = em.getReference(e.constructor, helper(e).getPrimaryKey());
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
.
|
|
791
|
-
|
|
820
|
+
hydrate(ref, e);
|
|
821
|
+
// index a refreshed `targetKey` value, `getByKey` ignores the old one as stale
|
|
822
|
+
if (helper(ref).__meta.root.targetKeys) {
|
|
823
|
+
em.#unitOfWork.getIdentityMap().store(ref);
|
|
824
|
+
}
|
|
792
825
|
found ||= ref === entity;
|
|
793
826
|
}
|
|
794
827
|
if (!found) {
|
|
795
|
-
|
|
796
|
-
ignoreSerializers: true,
|
|
797
|
-
includeHidden: true,
|
|
798
|
-
convertCustomTypes: false,
|
|
799
|
-
});
|
|
800
|
-
em.config
|
|
801
|
-
.getHydrator(this.metadata)
|
|
802
|
-
.hydrate(entity, wrapped.__meta, data, em.#entityFactory, 'full', false, false);
|
|
803
|
-
Utils.merge(wrapped.__originalEntityData, this.#comparator.prepareEntity(reloaded));
|
|
828
|
+
hydrate(entity, reloaded);
|
|
804
829
|
}
|
|
805
830
|
return entity;
|
|
806
831
|
}
|
|
@@ -1021,6 +1046,15 @@ export class EntityManager {
|
|
|
1021
1046
|
where[meta.primaryKeys[0]] = ret.insertId;
|
|
1022
1047
|
}
|
|
1023
1048
|
}
|
|
1049
|
+
// a `null` unique value cannot identify the row, and as it never conflicts, the row was inserted
|
|
1050
|
+
if (!Utils.hasObjectKeys(where)) {
|
|
1051
|
+
const insertId = meta.simplePK && ret.insertId != null ? { [meta.primaryKeys[0]]: ret.insertId } : {};
|
|
1052
|
+
Object.assign(where, Utils.getPrimaryKeyCond(entity, meta.primaryKeys) ?? insertId);
|
|
1053
|
+
}
|
|
1054
|
+
// an empty condition would hydrate the entity from an arbitrary row, mongo still gets one for nested conflict fields
|
|
1055
|
+
if (!Utils.hasObjectKeys(where) && platform.usesPivotTable()) {
|
|
1056
|
+
throw new Error(`Cannot find the upserted ${meta.className} row, as neither its primary key nor a unique value is known`);
|
|
1057
|
+
}
|
|
1024
1058
|
const data2 = await em.withSessionContext(options.ctx ?? em.#transactionContext, ctx => this.driver.findOne(meta.class, where, {
|
|
1025
1059
|
fields: returning.concat(...(options.onConflictMergeFields ?? [])),
|
|
1026
1060
|
ctx,
|
|
@@ -1028,6 +1062,9 @@ export class EntityManager {
|
|
|
1028
1062
|
connectionType: 'write',
|
|
1029
1063
|
schema: options.schema,
|
|
1030
1064
|
}));
|
|
1065
|
+
if (!data2) {
|
|
1066
|
+
throw new Error(`Cannot find the upserted ${meta.className} row for condition ${JSON.stringify(where)}`);
|
|
1067
|
+
}
|
|
1031
1068
|
em.getHydrator().hydrate(entity, meta, data2, em.#entityFactory, 'full', false, true);
|
|
1032
1069
|
}
|
|
1033
1070
|
// recompute the data as there might be some values missing (e.g. those with db column defaults)
|
|
@@ -1179,8 +1216,10 @@ export class EntityManager {
|
|
|
1179
1216
|
unique.findIndex(p => data[p] ?? data[p.substring(0, p.indexOf('.'))] != null);
|
|
1180
1217
|
const tmp = getWhereCondition(meta, options.onConflictFields, row, where);
|
|
1181
1218
|
propIndex = tmp.propIndex;
|
|
1219
|
+
// a partial composite PK cannot identify the row
|
|
1220
|
+
const partialPK = meta.compositePK && !Utils.isPlainObject(tmp.where) && meta.primaryKeys.some(pk => row[pk] == null);
|
|
1182
1221
|
where = QueryHelper.processWhere({
|
|
1183
|
-
where: tmp.where,
|
|
1222
|
+
where: partialPK ? {} : tmp.where,
|
|
1184
1223
|
entityName,
|
|
1185
1224
|
metadata: this.metadata,
|
|
1186
1225
|
platform: this.getPlatform(),
|
|
@@ -1195,6 +1234,25 @@ export class EntityManager {
|
|
|
1195
1234
|
allWhere.push(where);
|
|
1196
1235
|
dataIndexes.push(i);
|
|
1197
1236
|
}
|
|
1237
|
+
// mongo inserts the rows with an empty condition as part of the batch and reports their ids
|
|
1238
|
+
const positional = !this.getPlatform().usesPivotTable() ||
|
|
1239
|
+
((this.getPlatform().usesReturningStatement() || this.getPlatform().usesOutputStatement()) &&
|
|
1240
|
+
options.onConflictAction !== 'ignore' &&
|
|
1241
|
+
!options.onConflictWhere);
|
|
1242
|
+
const hasUniqueKey = !!options.onConflictFields || meta.uniques.length > 0 || meta.props.some(p => p.unique && !p.primary);
|
|
1243
|
+
// only an autoincrement PK can be derived from the `insertId` of the batch
|
|
1244
|
+
const autoincrement = meta.getPrimaryProps().some(p => p.autoincrement);
|
|
1245
|
+
// a TPT child table does not return its rows, so they are never mapped by position
|
|
1246
|
+
const peel = !!meta.tptParent || (!positional && (hasUniqueKey || !autoincrement));
|
|
1247
|
+
// a row with a `null` unique value has nothing to be reloaded by, the single row path maps its PK from the insert
|
|
1248
|
+
for (let idx = 0; peel && idx < allWhere.length; idx++) {
|
|
1249
|
+
if (!Utils.hasObjectKeys(allWhere[idx])) {
|
|
1250
|
+
result[dataIndexes[idx]] = await em.upsert(entityName, data[dataIndexes[idx]], options);
|
|
1251
|
+
allData.splice(idx, 1);
|
|
1252
|
+
allWhere.splice(idx, 1);
|
|
1253
|
+
dataIndexes.splice(idx--, 1);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1198
1256
|
if (allData.length === 0) {
|
|
1199
1257
|
return [...new Set(result)];
|
|
1200
1258
|
}
|
|
@@ -1244,7 +1302,11 @@ export class EntityManager {
|
|
|
1244
1302
|
// skip the reload only when RETURNING brought back a row for every input
|
|
1245
1303
|
// (onConflictWhere can suppress writes, leaving some rows out)
|
|
1246
1304
|
// oxfmt-ignore
|
|
1247
|
-
const
|
|
1305
|
+
const getUniqueFields = (where) => options.onConflictFields ?? (Utils.isPlainObject(where) ? Object.keys(where).flatMap(key => Utils.splitPrimaryKeys(key)) : meta.primaryKeys);
|
|
1306
|
+
// a row without a condition has no unique fields to derive
|
|
1307
|
+
const uniqueFields = getUniqueFields(allWhere.find(cond => Utils.hasObjectKeys(cond)) ?? allWhere[0]);
|
|
1308
|
+
// an empty condition identifies no row, it must not turn into a reload condition matching every row
|
|
1309
|
+
const getRowUniqueFields = (where) => Utils.isPlainObject(where) && !Utils.hasObjectKeys(where) ? uniqueFields : getUniqueFields(where);
|
|
1248
1310
|
const platform = this.getPlatform();
|
|
1249
1311
|
const returning = getOnConflictReturningFields(meta, data[dataIndexes[0]], uniqueFields, options, platform);
|
|
1250
1312
|
if (options.onConflictWhere && res.rows?.length !== allData.length) {
|
|
@@ -1261,10 +1323,14 @@ export class EntityManager {
|
|
|
1261
1323
|
Utils.keys(cond).forEach(key => add.add(key));
|
|
1262
1324
|
}
|
|
1263
1325
|
const where = { $or: [] };
|
|
1326
|
+
// a row without a condition was inserted, only the primary key reported for it can identify it
|
|
1327
|
+
const insertedPKs = allData.map((row, idx) => Utils.hasObjectKeys(allWhere[idx]) ? null : Utils.getPrimaryKeyCond(entitiesByData.get(row), meta.primaryKeys));
|
|
1264
1328
|
dataIndexes.forEach((index, idx) => {
|
|
1265
1329
|
const item = data[index];
|
|
1266
|
-
where.$or[idx] = {};
|
|
1267
|
-
|
|
1330
|
+
where.$or[idx] = { ...insertedPKs[idx] };
|
|
1331
|
+
// the inputs of one batch can be identified by different unique keys
|
|
1332
|
+
const fields = insertedPKs[idx] ? [] : getRowUniqueFields(allWhere[idx]);
|
|
1333
|
+
const props = Array.isArray(fields) ? fields : Object.keys(item);
|
|
1268
1334
|
props.forEach(prop => {
|
|
1269
1335
|
where.$or[idx][prop] = item[prop];
|
|
1270
1336
|
});
|
|
@@ -1279,8 +1345,8 @@ export class EntityManager {
|
|
|
1279
1345
|
connectionType: 'write',
|
|
1280
1346
|
schema: options.schema,
|
|
1281
1347
|
}));
|
|
1282
|
-
|
|
1283
|
-
const
|
|
1348
|
+
if (loadPK.size > 0) {
|
|
1349
|
+
const comparableRows = data2.map(row => {
|
|
1284
1350
|
const tmp = {};
|
|
1285
1351
|
add.forEach(k => {
|
|
1286
1352
|
const prop = meta.properties[k];
|
|
@@ -1288,25 +1354,58 @@ export class EntityManager {
|
|
|
1288
1354
|
tmp[k] = prop?.customType ? prop.customType.convertToDatabaseValue(row[k], this.getPlatform()) : row[k];
|
|
1289
1355
|
}
|
|
1290
1356
|
});
|
|
1291
|
-
return
|
|
1357
|
+
return tmp;
|
|
1292
1358
|
});
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1359
|
+
// rows carry the keys of every condition in the batch, compare only this one's
|
|
1360
|
+
const isMatch = (cond, idx) => this.#comparator.matching(entityName, cond, Object.fromEntries(Object.entries(comparableRows[idx]).filter(([k]) => k in cond)));
|
|
1361
|
+
// When every condition key is a comparable prop checked with `!==`, a matching row has the same primitive
|
|
1362
|
+
// values, so rows can be grouped by those values once instead of comparing every row for every entity.
|
|
1363
|
+
// Props outside `comparableProps` are skipped by the comparator, so they never qualify for grouping.
|
|
1364
|
+
const groupableProps = new Set(meta.comparableProps.filter(prop => this.#comparator.isStrictlyCompared(prop)));
|
|
1365
|
+
const groups = new Map();
|
|
1366
|
+
// `null` and `undefined` share a key, which can only make a group wider than what `matching` accepts
|
|
1367
|
+
const groupKey = (data, keys) => keys.map(k => (data[k] == null ? 'null' : `${typeof data[k]}:${data[k]}`)).join('\0');
|
|
1368
|
+
const findRowIndex = (cond) => {
|
|
1369
|
+
const keys = Utils.keys(cond);
|
|
1370
|
+
if (!keys.every(k => groupableProps.has(meta.properties[k]))) {
|
|
1371
|
+
return comparableRows.findIndex((_, idx) => isMatch(cond, idx));
|
|
1372
|
+
}
|
|
1373
|
+
const signature = keys.join('\0');
|
|
1374
|
+
if (!groups.has(signature)) {
|
|
1375
|
+
const rowsByKey = new Map();
|
|
1376
|
+
comparableRows.forEach((tmp, idx) => {
|
|
1377
|
+
const rowKey = groupKey(tmp, keys);
|
|
1378
|
+
const indexes = rowsByKey.get(rowKey) ?? [];
|
|
1379
|
+
indexes.push(idx);
|
|
1380
|
+
rowsByKey.set(rowKey, indexes);
|
|
1381
|
+
});
|
|
1382
|
+
groups.set(signature, rowsByKey);
|
|
1383
|
+
}
|
|
1384
|
+
const group = groups.get(signature);
|
|
1385
|
+
return group.get(groupKey(cond, keys))?.find(idx => isMatch(cond, idx)) ?? -1;
|
|
1386
|
+
};
|
|
1387
|
+
for (const [entity, cond] of loadPK.entries()) {
|
|
1388
|
+
const row = data2[findRowIndex(cond)];
|
|
1389
|
+
/* v8 ignore next */
|
|
1390
|
+
if (!row) {
|
|
1391
|
+
throw new Error(`Cannot find matching entity for condition ${JSON.stringify(cond)}`);
|
|
1392
|
+
}
|
|
1393
|
+
em.getHydrator().hydrate(entity, meta, row, em.#entityFactory, 'full', false, true);
|
|
1296
1394
|
}
|
|
1297
|
-
em.getHydrator().hydrate(entity, meta, row, em.#entityFactory, 'full', false, true);
|
|
1298
1395
|
}
|
|
1299
1396
|
if (loadPK.size !== data2.length && Array.isArray(uniqueFields)) {
|
|
1300
1397
|
for (let i = 0; i < allData.length; i++) {
|
|
1301
1398
|
const data = allData[i];
|
|
1302
|
-
const
|
|
1303
|
-
|
|
1304
|
-
a
|
|
1305
|
-
|
|
1306
|
-
|
|
1399
|
+
const fields = (insertedPKs[i] ? meta.primaryKeys : getRowUniqueFields(allWhere[i]));
|
|
1400
|
+
const cond = insertedPKs[i] ??
|
|
1401
|
+
fields.reduce((a, b) => {
|
|
1402
|
+
// @ts-ignore
|
|
1403
|
+
a[b] = data[b];
|
|
1404
|
+
return a;
|
|
1405
|
+
}, {});
|
|
1307
1406
|
const entity = entitiesByData.get(data);
|
|
1308
1407
|
const row = data2.find(item => {
|
|
1309
|
-
const pk =
|
|
1408
|
+
const pk = fields.reduce((a, b) => {
|
|
1310
1409
|
// @ts-ignore
|
|
1311
1410
|
a[b] = item[b];
|
|
1312
1411
|
return a;
|
|
@@ -81,6 +81,8 @@ export declare abstract class DatabaseDriver<C extends Connection> implements ID
|
|
|
81
81
|
*/
|
|
82
82
|
private mapCursorOffset;
|
|
83
83
|
protected createCursorCondition<T extends object>(definition: (readonly [keyof T & string, QueryOrder])[], offsets: Dictionary[], inverse: boolean, meta: EntityMetadata<T>, fromJson?: boolean): FilterQuery<T>;
|
|
84
|
+
/** @internal Polymorphic embeddable variants can declare same-named properties with different field names. */
|
|
85
|
+
protected variantProps(prop: EntityProperty, data?: Dictionary): Dictionary<EntityProperty>;
|
|
84
86
|
/** @internal */
|
|
85
87
|
mapDataToFieldNames(data: Dictionary, stringifyJsonArrays: boolean, properties?: Record<string, EntityProperty>, convertCustomTypes?: boolean, object?: boolean): Dictionary;
|
|
86
88
|
protected inlineEmbeddables<T extends object>(meta: EntityMetadata<T>, data: T, where?: boolean): void;
|
|
@@ -403,6 +403,19 @@ export class DatabaseDriver {
|
|
|
403
403
|
}
|
|
404
404
|
return { ...atOrPast, ...past };
|
|
405
405
|
}
|
|
406
|
+
/** @internal Polymorphic embeddable variants can declare same-named properties with different field names. */
|
|
407
|
+
variantProps(prop, data) {
|
|
408
|
+
const meta = prop.targetMeta;
|
|
409
|
+
// eslint-disable-next-line eqeqeq
|
|
410
|
+
const variant = meta?.polymorphs?.find(m => m.discriminatorValue == data?.[meta.discriminatorColumn]);
|
|
411
|
+
const props = variant ? { ...prop.embeddedProps } : prop.embeddedProps;
|
|
412
|
+
for (const p of variant ? Object.values(props) : []) {
|
|
413
|
+
if (variant.properties[p.embedded[1]] && Utils.isSameKind(variant.properties[p.embedded[1]], p)) {
|
|
414
|
+
props[p.embedded[1]] = p;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return props;
|
|
418
|
+
}
|
|
406
419
|
/** @internal */
|
|
407
420
|
mapDataToFieldNames(data, stringifyJsonArrays, properties, convertCustomTypes, object) {
|
|
408
421
|
if (!properties || data == null) {
|
|
@@ -417,17 +430,17 @@ export class DatabaseDriver {
|
|
|
417
430
|
if (prop.embeddedProps && !prop.object && !object) {
|
|
418
431
|
const copy = data[k];
|
|
419
432
|
delete data[k];
|
|
420
|
-
Object.assign(data, this.mapDataToFieldNames(copy, stringifyJsonArrays, prop
|
|
433
|
+
Object.assign(data, this.mapDataToFieldNames(copy, stringifyJsonArrays, this.variantProps(prop, copy), convertCustomTypes));
|
|
421
434
|
return;
|
|
422
435
|
}
|
|
423
436
|
if (prop.embeddedProps && (object || prop.object)) {
|
|
424
437
|
const copy = data[k];
|
|
425
438
|
delete data[k];
|
|
426
439
|
if (prop.array) {
|
|
427
|
-
data[prop.fieldNames[0]] = copy?.map((item) => this.mapDataToFieldNames(item, stringifyJsonArrays, prop
|
|
440
|
+
data[prop.fieldNames[0]] = copy?.map((item) => this.mapDataToFieldNames(item, stringifyJsonArrays, this.variantProps(prop, item), convertCustomTypes, true));
|
|
428
441
|
}
|
|
429
442
|
else {
|
|
430
|
-
data[prop.fieldNames[0]] = this.mapDataToFieldNames(copy, stringifyJsonArrays, prop
|
|
443
|
+
data[prop.fieldNames[0]] = this.mapDataToFieldNames(copy, stringifyJsonArrays, this.variantProps(prop, copy), convertCustomTypes, true);
|
|
431
444
|
}
|
|
432
445
|
if (stringifyJsonArrays && prop.array && !object) {
|
|
433
446
|
data[prop.fieldNames[0]] = this.platform.convertJsonToDatabaseValue(data[prop.fieldNames[0]]);
|
package/entity/EntityAssigner.js
CHANGED
|
@@ -234,14 +234,26 @@ export class EntityAssigner {
|
|
|
234
234
|
entity[propName].push(...Object.values(tmp));
|
|
235
235
|
});
|
|
236
236
|
}
|
|
237
|
+
const polymorphs = prop.targetMeta?.polymorphs;
|
|
238
|
+
// eslint-disable-next-line eqeqeq
|
|
239
|
+
const variant = polymorphs?.find(m => m.discriminatorValue == value[prop.targetMeta.discriminatorColumn]);
|
|
237
240
|
const create = () => EntityAssigner.validateEM(em) &&
|
|
238
|
-
em.getEntityFactory().createEmbeddable(prop.targetMeta.class, value, {
|
|
241
|
+
em.getEntityFactory().createEmbeddable((variant ?? prop.targetMeta).class, value, {
|
|
239
242
|
convertCustomTypes: options.convertCustomTypes,
|
|
240
243
|
newEntity: options.mergeEmbeddedProperties ? !('propName' in entity) : true,
|
|
241
244
|
});
|
|
242
|
-
|
|
245
|
+
// a different polymorphic variant cannot be merged into the current one
|
|
246
|
+
const merge = options.mergeEmbeddedProperties && (!variant || entity[propName]?.constructor === variant.class);
|
|
247
|
+
entity[propName] = (merge ? entity[propName] || create() : create());
|
|
248
|
+
const props = polymorphs ? { ...prop.embeddedProps } : prop.embeddedProps;
|
|
249
|
+
// polymorphic variants can declare same-named properties of different kinds or relation targets
|
|
250
|
+
for (const p of polymorphs ? entity[propName].__meta.props : []) {
|
|
251
|
+
if (props[p.name] && props[p.name].targetMeta?.class !== p.targetMeta?.class) {
|
|
252
|
+
props[p.name] = { ...p, nullable: prop.nullable || p.nullable };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
243
255
|
Object.keys(value).forEach(key => {
|
|
244
|
-
EntityAssigner.assignProperty(entity[propName], key,
|
|
256
|
+
EntityAssigner.assignProperty(entity[propName], key, props, value, options);
|
|
245
257
|
});
|
|
246
258
|
}
|
|
247
259
|
static createCollectionItem(item, em, prop, invalid, options) {
|
|
@@ -38,6 +38,13 @@ export declare class EntityFactory {
|
|
|
38
38
|
constructor(em: EntityManager);
|
|
39
39
|
/** Creates a new entity instance or returns an existing one from the identity map, hydrating it with the provided data. */
|
|
40
40
|
create<T extends object, P extends string = string>(entityName: EntityName<T>, data: EntityData<T>, options?: FactoryOptions): New<T, P>;
|
|
41
|
+
/**
|
|
42
|
+
* Whether merging `data` into an initialized entity would change nothing: every key is a scalar the entity already
|
|
43
|
+
* holds by identity, and keys `mergeData` always re-applies (formula, version, generated, discriminator) match the snapshot.
|
|
44
|
+
*/
|
|
45
|
+
private isNoopMerge;
|
|
46
|
+
/** Whether hydrating `value` into `prop` would leave `current` as is, mirroring the hydrator's Date and boolean coercion. */
|
|
47
|
+
private holdsValue;
|
|
41
48
|
/** Merges new data into an existing entity, preserving user-modified properties. */
|
|
42
49
|
mergeData<T extends object>(meta: EntityMetadata<T>, entity: T, data: EntityData<T>, options?: FactoryOptions): void;
|
|
43
50
|
/** Creates or retrieves an uninitialized entity reference by its primary key or alternate key. */
|
|
@@ -51,6 +58,7 @@ export declare class EntityFactory {
|
|
|
51
58
|
assignDefaultValues<T extends object>(entity: T, meta: EntityMetadata<T>, onCreateOnly?: boolean): void;
|
|
52
59
|
private hydrate;
|
|
53
60
|
private findEntity;
|
|
61
|
+
private findEntityByPrimaryKey;
|
|
54
62
|
private processDiscriminatorColumn;
|
|
55
63
|
/**
|
|
56
64
|
* 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) {
|
|
@@ -145,8 +149,45 @@ export class EntityFactory {
|
|
|
145
149
|
wrapped.__processing = false;
|
|
146
150
|
return entity;
|
|
147
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Whether merging `data` into an initialized entity would change nothing: every key is a scalar the entity already
|
|
154
|
+
* holds by identity, and keys `mergeData` always re-applies (formula, version, generated, discriminator) match the snapshot.
|
|
155
|
+
*/
|
|
156
|
+
isNoopMerge(meta, entity, data, options) {
|
|
157
|
+
const wrapped = helper(entity);
|
|
158
|
+
if (!wrapped.__initialized || options.normalizeAccessors) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
for (const key of Utils.keys(data)) {
|
|
162
|
+
const prop = meta.properties[key];
|
|
163
|
+
const value = data[key];
|
|
164
|
+
// custom types are left to the full merge, as their JS and database values can differ
|
|
165
|
+
if (!prop || prop.customType || value === undefined || !this.holdsValue(prop, entity[key], value)) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
const reapplied = prop.formula || prop.version || prop.generated || key === meta.root.discriminatorColumn;
|
|
169
|
+
if (reapplied && (wrapped.__originalEntityData?.[key] !== value || !wrapped.__loadedProperties.has(key))) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
/** Whether hydrating `value` into `prop` would leave `current` as is, mirroring the hydrator's Date and boolean coercion. */
|
|
176
|
+
holdsValue(prop, current, value) {
|
|
177
|
+
if (current instanceof Date && value instanceof Date) {
|
|
178
|
+
return current.getTime() === value.getTime();
|
|
179
|
+
}
|
|
180
|
+
if (prop.runtimeType === 'boolean' && value != null) {
|
|
181
|
+
return current === !!value;
|
|
182
|
+
}
|
|
183
|
+
return current === value;
|
|
184
|
+
}
|
|
148
185
|
/** Merges new data into an existing entity, preserving user-modified properties. */
|
|
149
186
|
mergeData(meta, entity, data, options = {}) {
|
|
187
|
+
// joined rows often repeat exactly what the entity already holds, skip the snapshot and diffing then
|
|
188
|
+
if (this.isNoopMerge(meta, entity, data, options)) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
150
191
|
// merge unchanged properties automatically
|
|
151
192
|
data = QueryHelper.processParams(data);
|
|
152
193
|
const existsData = this.#comparator.prepareEntity(entity);
|
|
@@ -232,9 +273,9 @@ export class EntityFactory {
|
|
|
232
273
|
options.convertCustomTypes ??= true;
|
|
233
274
|
const meta = this.#metadata.get(entityName);
|
|
234
275
|
const schema = this.#driver.getSchemaName(meta, options);
|
|
235
|
-
// Handle alternate key lookup
|
|
236
|
-
if (options.key) {
|
|
237
|
-
const value =
|
|
276
|
+
// Handle alternate key lookup, a `key` pointing at the single PK is a plain PK reference
|
|
277
|
+
if (options.key && !(meta.primaryKeys.length === 1 && meta.primaryKeys[0] === options.key)) {
|
|
278
|
+
const value = Array.isArray(id) ? id[0] : Utils.isPlainObject(id) ? id[options.key] : id;
|
|
238
279
|
const exists = this.unitOfWork.getByKey(entityName, options.key, value, schema, options.convertCustomTypes);
|
|
239
280
|
if (exists) {
|
|
240
281
|
return exists;
|
|
@@ -242,6 +283,8 @@ export class EntityFactory {
|
|
|
242
283
|
// Create entity stub - storeByKey will set the alternate key property and store in identity map
|
|
243
284
|
const entity = this.create(entityName, {}, { ...options, initialized: false });
|
|
244
285
|
this.unitOfWork.storeByKey(entity, options.key, value, schema, options.convertCustomTypes);
|
|
286
|
+
// snapshot the stub like a PK reference, so flush treats it as existing instead of inserting it
|
|
287
|
+
helper(entity).__originalEntityData = this.#comparator.prepareEntity(entity);
|
|
245
288
|
return entity;
|
|
246
289
|
}
|
|
247
290
|
if (meta.simplePK) {
|
|
@@ -310,6 +353,12 @@ export class EntityFactory {
|
|
|
310
353
|
if (options.merge && !options.newEntity) {
|
|
311
354
|
this.#hydrator.hydrateReference(entity, meta, data, this, options.convertCustomTypes, options.schema, options.parentSchema);
|
|
312
355
|
this.unitOfWork.register(entity);
|
|
356
|
+
// relations hydrated together with this entity (joined strategy) resolve it via `targetKey` before it is hydrated
|
|
357
|
+
for (const key of meta.root.targetKeys ?? []) {
|
|
358
|
+
if (data[key] != null) {
|
|
359
|
+
this.unitOfWork.storeByKey(entity, key, data[key], schema, options.convertCustomTypes);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
313
362
|
}
|
|
314
363
|
if (options.initialized) {
|
|
315
364
|
EntityHelper.ensurePropagation(entity);
|
|
@@ -379,6 +428,23 @@ export class EntityFactory {
|
|
|
379
428
|
}
|
|
380
429
|
findEntity(data, meta, options) {
|
|
381
430
|
const schema = this.#driver.getSchemaName(meta, options);
|
|
431
|
+
const exists = this.findEntityByPrimaryKey(data, meta, schema, options);
|
|
432
|
+
// a new entity must not adopt a reference stub, that one is never inserted
|
|
433
|
+
if (exists || Array.isArray(data) || options.newEntity) {
|
|
434
|
+
return exists;
|
|
435
|
+
}
|
|
436
|
+
// a reference created via `targetKey` is only known by that key until the entity is loaded
|
|
437
|
+
for (const key of meta.root.targetKeys ?? []) {
|
|
438
|
+
if (data[key] != null) {
|
|
439
|
+
const ref = this.unitOfWork.getByKey(meta.class, key, data[key], schema, options.convertCustomTypes);
|
|
440
|
+
if (ref && !helper(ref).__initialized) {
|
|
441
|
+
return ref;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
findEntityByPrimaryKey(data, meta, schema, options) {
|
|
382
448
|
if (meta.simplePK) {
|
|
383
449
|
return this.unitOfWork.getById(meta.class, data[meta.primaryKeys[0]], schema);
|
|
384
450
|
}
|
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;
|
|
@@ -311,18 +312,25 @@ export class EntityLoader {
|
|
|
311
312
|
}
|
|
312
313
|
initializeManyToMany(filtered, children, prop, field, customOrder, partial, readonly) {
|
|
313
314
|
if (prop.mappedBy) {
|
|
315
|
+
// group children by owner in one pass
|
|
316
|
+
const map = new Map(filtered.map(entity => [entity, []]));
|
|
317
|
+
for (const child of children) {
|
|
318
|
+
for (const owner of child[prop.mappedBy].getItems(false)) {
|
|
319
|
+
map.get(owner)?.push(child);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
314
322
|
for (const entity of filtered) {
|
|
315
|
-
|
|
316
|
-
entity[field].hydrate(items, true, partial, readonly);
|
|
323
|
+
entity[field].hydrate(map.get(entity), true, partial, readonly);
|
|
317
324
|
}
|
|
318
325
|
}
|
|
319
326
|
else {
|
|
320
327
|
// owning side of M:N without pivot table needs to be reordered
|
|
328
|
+
const childIndex = new Map(children.map((child, idx) => [child, idx]));
|
|
321
329
|
for (const entity of filtered) {
|
|
322
|
-
|
|
323
|
-
const items =
|
|
324
|
-
if (
|
|
325
|
-
items.sort((a, b) =>
|
|
330
|
+
// keep the order of references, or the order of loaded children when a custom order is used
|
|
331
|
+
const items = entity[prop.name].getItems(false).filter(item => childIndex.has(item));
|
|
332
|
+
if (customOrder) {
|
|
333
|
+
items.sort((a, b) => childIndex.get(a) - childIndex.get(b));
|
|
326
334
|
}
|
|
327
335
|
entity[field].hydrate(items, true, partial, readonly);
|
|
328
336
|
}
|
|
@@ -672,12 +680,20 @@ export class EntityLoader {
|
|
|
672
680
|
const subCond = Utils.isPlainObject(where[prop.name]) ? { ...where[prop.name] } : {};
|
|
673
681
|
const meta2 = prop.targetMeta;
|
|
674
682
|
const pk = Utils.getPrimaryKeyHash(meta2.primaryKeys);
|
|
683
|
+
// `$not` wrapping entity conditions stays on the target entity, only operators on the relation itself map to its PK
|
|
684
|
+
const isRelationOperator = (key, value) => {
|
|
685
|
+
if (key === '$not' && Utils.isPlainObject(value)) {
|
|
686
|
+
return Utils.getObjectQueryKeys(value).every(k => Utils.isOperator(k, false));
|
|
687
|
+
}
|
|
688
|
+
return Utils.isOperator(key, false);
|
|
689
|
+
};
|
|
675
690
|
['$and', '$or'].forEach(op => {
|
|
676
691
|
if (where[op]) {
|
|
677
692
|
const child = where[op]
|
|
678
693
|
.map((cond) => cond[prop.name])
|
|
679
694
|
.filter((sub) => sub != null &&
|
|
680
|
-
!(Utils.isPlainObject(sub) &&
|
|
695
|
+
!(Utils.isPlainObject(sub) &&
|
|
696
|
+
Utils.getObjectQueryKeys(sub).every(key => isRelationOperator(key, sub[key]))))
|
|
681
697
|
.map((cond) => {
|
|
682
698
|
if (Utils.isPrimaryKey(cond)) {
|
|
683
699
|
return { [pk]: cond };
|
|
@@ -690,7 +706,7 @@ export class EntityLoader {
|
|
|
690
706
|
}
|
|
691
707
|
}
|
|
692
708
|
});
|
|
693
|
-
const operators = Object.keys(subCond).filter(key =>
|
|
709
|
+
const operators = Object.keys(subCond).filter(key => isRelationOperator(key, subCond[key]));
|
|
694
710
|
if (operators.length > 0) {
|
|
695
711
|
subCond[pk] = Utils.isPlainObject(subCond[pk]) ? { ...subCond[pk] } : (subCond[pk] ?? {});
|
|
696
712
|
operators.forEach(op => {
|
|
@@ -808,7 +824,7 @@ export class EntityLoader {
|
|
|
808
824
|
if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(wrapped.__meta.properties[f].kind)) {
|
|
809
825
|
return entity[f].getItems(false).every((item) => this.isPropertyLoaded(item, r.join('.')));
|
|
810
826
|
}
|
|
811
|
-
return this.isPropertyLoaded(entity[f], r.join('.'));
|
|
827
|
+
return this.isPropertyLoaded(Reference.unwrapReference(entity[f]), r.join('.'));
|
|
812
828
|
}
|
|
813
829
|
filterReferences(entities, field, options, ref, populate) {
|
|
814
830
|
if (ref) {
|
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,
|