@mikro-orm/core 7.2.0-dev.9 → 7.2.0

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.
Files changed (69) hide show
  1. package/EntityManager.d.ts +41 -7
  2. package/EntityManager.js +225 -45
  3. package/MikroORM.js +3 -0
  4. package/README.md +1 -0
  5. package/connections/Connection.d.ts +3 -1
  6. package/drivers/DatabaseDriver.d.ts +14 -5
  7. package/drivers/DatabaseDriver.js +151 -52
  8. package/drivers/IDatabaseDriver.d.ts +1 -0
  9. package/entity/Collection.js +4 -2
  10. package/entity/EntityFactory.js +6 -0
  11. package/entity/EntityLoader.d.ts +7 -1
  12. package/entity/EntityLoader.js +46 -11
  13. package/entity/EntityRepository.d.ts +4 -5
  14. package/entity/EntityRepository.js +7 -2
  15. package/entity/defineEntity.d.ts +48 -14
  16. package/entity/defineEntity.js +32 -1
  17. package/enums.d.ts +5 -1
  18. package/enums.js +2 -0
  19. package/errors.d.ts +36 -0
  20. package/errors.js +90 -0
  21. package/events/EventManager.js +6 -3
  22. package/exceptions.d.ts +5 -0
  23. package/exceptions.js +5 -0
  24. package/hydration/ObjectHydrator.d.ts +2 -0
  25. package/hydration/ObjectHydrator.js +12 -8
  26. package/index.d.ts +1 -1
  27. package/metadata/MetadataDiscovery.d.ts +3 -0
  28. package/metadata/MetadataDiscovery.js +127 -17
  29. package/metadata/MetadataStorage.js +17 -1
  30. package/metadata/types.d.ts +19 -3
  31. package/package.json +1 -1
  32. package/platforms/Platform.d.ts +22 -3
  33. package/platforms/Platform.js +59 -1
  34. package/types/BigIntType.d.ts +1 -0
  35. package/types/BigIntType.js +23 -0
  36. package/types/DateTimeType.d.ts +1 -0
  37. package/types/DateTimeType.js +8 -0
  38. package/types/StringType.d.ts +14 -3
  39. package/types/StringType.js +34 -4
  40. package/types/TextType.d.ts +2 -4
  41. package/types/TextType.js +2 -8
  42. package/types/Type.d.ts +11 -0
  43. package/types/Type.js +4 -4
  44. package/types/index.d.ts +2 -2
  45. package/typings.d.ts +56 -2
  46. package/typings.js +24 -1
  47. package/unit-of-work/ChangeSetPersister.js +17 -13
  48. package/unit-of-work/UnitOfWork.js +11 -4
  49. package/utils/Configuration.d.ts +15 -1
  50. package/utils/Configuration.js +11 -1
  51. package/utils/Cursor.d.ts +2 -0
  52. package/utils/Cursor.js +43 -33
  53. package/utils/DataloaderUtils.js +2 -1
  54. package/utils/EntityComparator.d.ts +2 -0
  55. package/utils/EntityComparator.js +7 -3
  56. package/utils/QueryHelper.d.ts +12 -0
  57. package/utils/QueryHelper.js +75 -4
  58. package/utils/RawQueryFragment.d.ts +6 -0
  59. package/utils/RawQueryFragment.js +15 -6
  60. package/utils/TransactionManager.js +1 -1
  61. package/utils/Utils.d.ts +14 -2
  62. package/utils/Utils.js +31 -4
  63. package/utils/env-vars.js +1 -0
  64. package/utils/index.d.ts +1 -0
  65. package/utils/index.js +1 -0
  66. package/utils/rls-utils.d.ts +35 -0
  67. package/utils/rls-utils.js +97 -0
  68. package/utils/upsert-utils.d.ts +9 -1
  69. package/utils/upsert-utils.js +26 -3
@@ -65,13 +65,22 @@ export declare abstract class DatabaseDriver<C extends Connection> implements ID
65
65
  where: FilterQuery<T>;
66
66
  };
67
67
  /**
68
- * Restores the JS value of a single cursor offset: ISO strings become `Date` instances based on the
69
- * property type (never based on the string shape alone), and custom types are restored via
70
- * `convertToJSValue`. Values compared against a JSON document keep their serialized form instead,
71
- * unless the platform preserves native date types inside JSON documents (mongo).
68
+ * Resolves a leaf `orderBy` direction into the two flags the rewritten `orderBy` and the cursor
69
+ * condition have to agree on, or pagination skips rows at the null boundary. A placement the caller
70
+ * asked for wins where the platform can honor it, anything else follows the platform's own default,
71
+ * which is what the untouched `orderBy` will get.
72
+ */
73
+ private parseCursorDirection;
74
+ /**
75
+ * Restores the JS value of a single cursor offset. String-cursor values (`fromJson`) are decoded
76
+ * JSON: types implementing `fromJSON` own the round trip, other custom types restore via
77
+ * `convertToJSValue`, with `Date` healing for date-like columns based on the property type (never
78
+ * based on the string shape alone). POJO values are already JS values and only date-like strings
79
+ * are healed. Values compared against a JSON document keep their serialized form instead, unless
80
+ * the platform preserves native date types inside JSON documents (mongo).
72
81
  */
73
82
  private mapCursorOffset;
74
- protected createCursorCondition<T extends object>(definition: (readonly [keyof T & string, QueryOrder])[], offsets: Dictionary[], inverse: boolean, meta: EntityMetadata<T>): FilterQuery<T>;
83
+ protected createCursorCondition<T extends object>(definition: (readonly [keyof T & string, QueryOrder])[], offsets: Dictionary[], inverse: boolean, meta: EntityMetadata<T>, fromJson?: boolean): FilterQuery<T>;
75
84
  /** @internal */
76
85
  mapDataToFieldNames(data: Dictionary, stringifyJsonArrays: boolean, properties?: Record<string, EntityProperty>, convertCustomTypes?: boolean, object?: boolean): Dictionary;
77
86
  protected inlineEmbeddables<T extends object>(meta: EntityMetadata<T>, data: T, where?: boolean): void;
@@ -10,7 +10,6 @@ import { helper } from '../entity/wrap.js';
10
10
  import { Reference } from '../entity/Reference.js';
11
11
  import { PolymorphicRef } from '../entity/PolymorphicRef.js';
12
12
  import { JsonType } from '../types/JsonType.js';
13
- import { DateTimeType } from '../types/DateTimeType.js';
14
13
  import { QueryHelper } from '../utils/QueryHelper.js';
15
14
  import { MikroORM } from '../MikroORM.js';
16
15
  /** Abstract base class for all database drivers, implementing common driver logic. */
@@ -146,6 +145,7 @@ export class DatabaseDriver {
146
145
  const createCursor = (val, key, inverse = false) => {
147
146
  const def = Reference.unwrapReference((isCursor(val, key) ? val[key] : val));
148
147
  let offsets;
148
+ let fromJson = false;
149
149
  // entity (and reference) instances are supported as cursors too, their properties are read the same way
150
150
  if (Utils.isPlainObject(def) || Utils.isEntity(def)) {
151
151
  // POJO values are already JS values, extract them ordered per the definition,
@@ -158,11 +158,17 @@ export class DatabaseDriver {
158
158
  });
159
159
  }
160
160
  else {
161
- /* v8 ignore next */
162
- offsets = def ? Cursor.decode(def) : [];
161
+ try {
162
+ /* v8 ignore next */
163
+ offsets = def ? Cursor.decode(def) : [];
164
+ }
165
+ catch (error) {
166
+ throw CursorError.invalidCursor(meta.className, error);
167
+ }
168
+ fromJson = true;
163
169
  }
164
170
  if (definition.length > 0 && definition.length === offsets.length) {
165
- return this.createCursorCondition(definition, offsets, inverse, meta);
171
+ return this.createCursorCondition(definition, offsets, inverse, meta, fromJson);
166
172
  }
167
173
  /* v8 ignore next */
168
174
  return {};
@@ -176,16 +182,24 @@ export class DatabaseDriver {
176
182
  if (limit != null) {
177
183
  options.limit = limit + (overfetch ? 1 : 0);
178
184
  }
179
- const createOrderBy = (prop, direction) => {
185
+ const createOrderBy = (prop, direction, properties = meta.properties) => {
186
+ const propMeta = properties[prop];
180
187
  if (Utils.isPlainObject(direction)) {
188
+ const childProps = propMeta?.kind === ReferenceKind.EMBEDDED ? propMeta.embeddedProps : propMeta?.targetMeta?.properties;
181
189
  const value = Utils.getObjectQueryKeys(direction).reduce((o, key) => {
182
- Object.assign(o, createOrderBy(key, direction[key]));
190
+ Object.assign(o, createOrderBy(key, direction[key], childProps ?? {}));
183
191
  return o;
184
192
  }, {});
185
193
  return { [prop]: value };
186
194
  }
187
- const desc = direction === QueryOrderNumeric.DESC || direction.toString().toLowerCase() === 'desc';
195
+ const { desc, nullsFirst, explicit } = this.parseCursorDirection(direction);
188
196
  const dir = Utils.xor(desc, isLast) ? 'desc' : 'asc';
197
+ // only a requested placement is spelled out, an unqualified direction already lands where the
198
+ // condition expects it; backward pagination reverses the ordering, so the placement flips too
199
+ if (explicit) {
200
+ const nulls = Utils.xor(nullsFirst, isLast) ? 'first' : 'last';
201
+ return { [prop]: `${dir} nulls ${nulls}` };
202
+ }
189
203
  return { [prop]: dir };
190
204
  };
191
205
  // the cursor condition is created at the driver level, after the EM already converted custom types
@@ -203,12 +217,30 @@ export class DatabaseDriver {
203
217
  };
204
218
  }
205
219
  /**
206
- * Restores the JS value of a single cursor offset: ISO strings become `Date` instances based on the
207
- * property type (never based on the string shape alone), and custom types are restored via
208
- * `convertToJSValue`. Values compared against a JSON document keep their serialized form instead,
209
- * unless the platform preserves native date types inside JSON documents (mongo).
220
+ * Resolves a leaf `orderBy` direction into the two flags the rewritten `orderBy` and the cursor
221
+ * condition have to agree on, or pagination skips rows at the null boundary. A placement the caller
222
+ * asked for wins where the platform can honor it, anything else follows the platform's own default,
223
+ * which is what the untouched `orderBy` will get.
210
224
  */
211
- mapCursorOffset(prop, value, insideJson) {
225
+ parseCursorDirection(direction) {
226
+ const dir = ('' + direction).toLowerCase();
227
+ const desc = direction === QueryOrderNumeric.DESC || dir.startsWith('desc');
228
+ const nullsFirst = dir.includes('nulls first');
229
+ const explicit = (nullsFirst || dir.includes('nulls last')) && this.platform.supportsNullsOrdering();
230
+ if (!explicit) {
231
+ return { desc, nullsFirst: this.platform.sortsNullsLowest() ? !desc : desc, explicit };
232
+ }
233
+ return { desc, nullsFirst, explicit };
234
+ }
235
+ /**
236
+ * Restores the JS value of a single cursor offset. String-cursor values (`fromJson`) are decoded
237
+ * JSON: types implementing `fromJSON` own the round trip, other custom types restore via
238
+ * `convertToJSValue`, with `Date` healing for date-like columns based on the property type (never
239
+ * based on the string shape alone). POJO values are already JS values and only date-like strings
240
+ * are healed. Values compared against a JSON document keep their serialized form instead, unless
241
+ * the platform preserves native date types inside JSON documents (mongo).
242
+ */
243
+ mapCursorOffset(prop, value, insideJson, fromJson) {
212
244
  if (Utils.isScalarReference(value)) {
213
245
  value = value.unwrap();
214
246
  }
@@ -219,25 +251,65 @@ export class DatabaseDriver {
219
251
  if (value == null) {
220
252
  return value;
221
253
  }
254
+ // mongo preserves native date types inside JSON documents, restored JS values compare directly
222
255
  if (insideJson && !this.platform.preservesDatesInsideJson()) {
223
- // compared against the JSON document, which holds the serialized form
256
+ // compared against the JSON document, which holds the JSON form of the database value
224
257
  if (value instanceof Date) {
225
258
  return value.toISOString();
226
259
  }
227
- // restore the JS value from the serialized form, `processWhere` then converts it to
228
- // the database form, which is what the JSON document holds for custom typed props
229
- return prop?.customType ? prop.customType.convertToJSValue(value, this.platform) : value;
260
+ if (prop?.customType && fromJson) {
261
+ const restored = prop.customType.fromJSON
262
+ ? prop.customType.fromJSON(value, this.platform)
263
+ : prop.customType.convertToJSValue(value, this.platform);
264
+ // a restored `Date` compares against its ISO string in the document, everything else
265
+ // keeps the JS value and gets its single `convertToDatabaseValue` in `processWhere`
266
+ if (restored instanceof Date) {
267
+ const converted = prop.customType.convertToDatabaseValue(restored, this.platform, {
268
+ fromQuery: true,
269
+ key: prop.name,
270
+ mode: 'query',
271
+ });
272
+ if (converted instanceof Date) {
273
+ return converted.toISOString();
274
+ }
275
+ }
276
+ return restored;
277
+ }
278
+ return value;
279
+ }
280
+ if (prop?.customType) {
281
+ if (fromJson && prop.customType.fromJSON) {
282
+ // the type owns its JSON round trip
283
+ return prop.customType.fromJSON(value, this.platform);
284
+ }
285
+ // A string is either a serialized form (string cursor) or a hand-written one (POJO).
286
+ // Types whose JS value is a `Date` must receive one. The rest get the string first,
287
+ // so any sub-millisecond precision survives.
288
+ if (typeof value === 'string' &&
289
+ (prop.runtimeType === 'Date' || this.platform.getMappedType(prop.columnTypes?.[0] ?? '').runtimeType === 'Date')) {
290
+ if (prop.runtimeType !== 'Date') {
291
+ try {
292
+ return prop.customType.convertToJSValue(value, this.platform);
293
+ }
294
+ catch {
295
+ // the type cannot read the serialized string, fall back to the `Date` form
296
+ }
297
+ }
298
+ return prop.customType.convertToJSValue(new Date(value), this.platform);
299
+ }
300
+ // serialized non-string values still need restoring; POJO values are already JS values
301
+ return fromJson ? prop.customType.convertToJSValue(value, this.platform) : value;
230
302
  }
231
- if (typeof value === 'string' &&
232
- (prop?.runtimeType === 'Date' ||
233
- (prop?.customType && this.platform.getMappedType(prop.columnTypes?.[0] ?? '') instanceof DateTimeType))) {
234
- value = new Date(value);
303
+ if (typeof value === 'string' && prop?.runtimeType === 'Date') {
304
+ return new Date(value);
235
305
  }
236
- return prop?.customType ? prop.customType.convertToJSValue(value, this.platform) : value;
306
+ return value;
237
307
  }
238
- createCursorCondition(definition, offsets, inverse, meta) {
239
- const createCondition = (prop, direction, offset, eq = false, path = prop, properties = meta.properties, insideJson = false) => {
308
+ createCursorCondition(definition, offsets, inverse, meta, fromJson = false) {
309
+ const createCondition = (prop, direction, offset, eq = false, path = prop, properties = meta.properties, insideJson = false, nullable = false) => {
240
310
  const propMeta = properties[prop];
311
+ // nullable relations and embeddables null out their joined columns, and a formula can yield null unannounced
312
+ nullable ||= !!propMeta?.nullable || !!propMeta?.formula;
241
313
  if (Utils.isPlainObject(direction)) {
242
314
  if (offset === undefined) {
243
315
  throw CursorError.missingValue(meta.className, path);
@@ -247,40 +319,57 @@ export class DatabaseDriver {
247
319
  const childProps = propMeta?.kind === ReferenceKind.EMBEDDED ? propMeta.embeddedProps : propMeta?.targetMeta?.properties;
248
320
  insideJson ||=
249
321
  (propMeta?.kind === ReferenceKind.EMBEDDED && !!propMeta.object) || propMeta?.customType instanceof JsonType;
250
- const value = Utils.keys(direction).reduce((o, key) => {
251
- Object.assign(o, createCondition(key, direction[key], offset?.[key], eq, `${path}.${key}`, childProps ?? {}, insideJson));
252
- return o;
253
- }, {});
254
- return { [prop]: value };
255
- }
256
- const isDesc = direction === QueryOrderNumeric.DESC || direction.toString().toLowerCase() === 'desc';
257
- const dirStr = direction.toString().toLowerCase();
258
- let nullsFirst;
259
- if (dirStr.includes('nulls first')) {
260
- nullsFirst = true;
261
- }
262
- else if (dirStr.includes('nulls last')) {
263
- nullsFirst = false;
264
- }
265
- else {
266
- // Default: NULLS LAST for ASC, NULLS FIRST for DESC (matches most databases)
267
- nullsFirst = isDesc;
322
+ const keys = Utils.keys(direction);
323
+ const child = (key, childEq) => createCondition(key, direction[key],
324
+ // a null relation offset means the whole sort key is null, propagate it to the leaves
325
+ offset === null ? null : offset[key], childEq, `${path}.${key}`, childProps ?? {}, insideJson, nullable);
326
+ // the group's own keys are a keyset in their own right, so they decompose the same way the
327
+ // top level does; merging them into one object would compare every key independently instead
328
+ const lex = (index) => {
329
+ const key = keys[index];
330
+ if (index === keys.length - 1) {
331
+ return child(key, eq);
332
+ }
333
+ const atOrPast = child(key, true);
334
+ const tail = lex(index + 1);
335
+ // an unconstrained tail matches anything, leaving only the `at or past` prefix to constrain
336
+ const past = Utils.hasObjectKeys(tail) ? { $or: [child(key, false), tail] } : {};
337
+ if (!Utils.hasObjectKeys(past)) {
338
+ return atOrPast;
339
+ }
340
+ return Utils.hasObjectKeys(atOrPast) ? { $and: [atOrPast, past] } : past;
341
+ };
342
+ const value = keys.length > 0 ? lex(0) : {};
343
+ // an unconstrained group condition must not degrade to `{ [prop]: {} }`
344
+ return Utils.hasObjectKeys(value) ? { [prop]: value } : {};
268
345
  }
346
+ const { desc: isDesc, nullsFirst } = this.parseCursorDirection(direction);
269
347
  const operator = Utils.xor(isDesc, inverse) ? '$lt' : '$gt';
270
348
  // For leaf-level properties, undefined means missing value
271
349
  if (offset === undefined) {
272
350
  throw CursorError.missingValue(meta.className, path);
273
351
  }
274
- offset = this.mapCursorOffset(propMeta, offset, insideJson);
352
+ // string-cursor values are client supplied, so a value the type cannot restore is an
353
+ // invalid cursor, letting callers map `CursorError` to a client error response
354
+ try {
355
+ offset = this.mapCursorOffset(propMeta, offset, insideJson, fromJson);
356
+ }
357
+ catch (error) {
358
+ if (!fromJson || error instanceof CursorError) {
359
+ throw error;
360
+ }
361
+ throw CursorError.invalidCursor(meta.className, error);
362
+ }
275
363
  // Handle null offset (intentional null cursor value)
276
364
  if (offset === null) {
365
+ // hasItemsAfterNull: forward + nullsFirst, or backward + nullsLast
366
+ const hasItemsAfterNull = Utils.xor(nullsFirst, inverse);
277
367
  if (eq) {
278
- // Equal to null
279
- return { [prop]: null };
368
+ // at-or-after a null sort key means every row when non-null rows follow the null block,
369
+ // so the tie-breaker in the `$or` sibling can reach past it
370
+ return hasItemsAfterNull ? {} : { [prop]: null };
280
371
  }
281
372
  // Strict comparison with null cursor value
282
- // hasItemsAfterNull: forward + nullsFirst, or backward + nullsLast
283
- const hasItemsAfterNull = Utils.xor(nullsFirst, inverse);
284
373
  if (hasItemsAfterNull) {
285
374
  return { [prop]: { $ne: null } };
286
375
  }
@@ -288,7 +377,12 @@ export class DatabaseDriver {
288
377
  return { [prop]: [] };
289
378
  }
290
379
  // Non-null offset
291
- return { [prop]: { [operator + (eq ? 'e' : '')]: offset } };
380
+ const condition = { [prop]: { [operator + (eq ? 'e' : '')]: offset } };
381
+ // null sort keys lie past the offset here, and no comparison ever matches them
382
+ if (nullable && !Utils.xor(nullsFirst, inverse)) {
383
+ return { $or: [condition, { [prop]: null }] };
384
+ }
385
+ return condition;
292
386
  };
293
387
  const [order, ...otherOrders] = definition;
294
388
  const [offset, ...otherOffsets] = offsets;
@@ -296,13 +390,18 @@ export class DatabaseDriver {
296
390
  if (!otherOrders.length) {
297
391
  return createCondition(prop, direction, offset);
298
392
  }
299
- return {
300
- ...createCondition(prop, direction, offset, true),
393
+ const atOrPast = createCondition(prop, direction, offset, true);
394
+ const past = {
301
395
  $or: [
302
396
  createCondition(prop, direction, offset),
303
- this.createCursorCondition(otherOrders, otherOffsets, inverse, meta),
397
+ this.createCursorCondition(otherOrders, otherOffsets, inverse, meta, fromJson),
304
398
  ],
305
399
  };
400
+ // the `at or past` prefix is itself a group condition when it compares against nulls
401
+ if ('$or' in atOrPast) {
402
+ return { $and: [atOrPast, past] };
403
+ }
404
+ return { ...atOrPast, ...past };
306
405
  }
307
406
  /** @internal */
308
407
  mapDataToFieldNames(data, stringifyJsonArrays, properties, convertCustomTypes, object) {
@@ -422,8 +521,8 @@ export class DatabaseDriver {
422
521
  const props = prop.embeddedProps;
423
522
  let unknownProp = false;
424
523
  Object.keys(data[prop.name]).forEach(kk => {
425
- // explicitly allow `$exists`, `$eq`, `$ne` and `$elemMatch` operators here as they can't be misused this way
426
- const operator = Object.keys(data[prop.name]).some(f => Utils.isOperator(f) && !['$exists', '$ne', '$eq', '$elemMatch'].includes(f));
524
+ // explicitly allow `$exists`, `$eq`, `$ne`, `$elemMatch` and `$all` operators here as they can't be misused this way
525
+ const operator = Object.keys(data[prop.name]).some(f => Utils.isOperator(f) && !['$exists', '$ne', '$eq', '$elemMatch', '$all'].includes(f));
427
526
  if (operator) {
428
527
  throw ValidationError.cannotUseOperatorsInsideEmbeddables(meta.class, prop.name, data);
429
528
  }
@@ -345,6 +345,7 @@ export interface CountByOptions<T extends object> {
345
345
  filters?: FilterOptions;
346
346
  having?: FilterQuery<T>;
347
347
  schema?: string;
348
+ connectionType?: ConnectionType;
348
349
  flushMode?: FlushMode | `${FlushMode}`;
349
350
  loggerContext?: LogContext;
350
351
  logging?: LoggingOptions;
@@ -117,9 +117,11 @@ export class Collection {
117
117
  opts.orderBy = QueryHelper.mergeOrderBy(opts.orderBy, this.property.orderBy, this.property.targetMeta?.orderBy);
118
118
  options.populate = (await em.preparePopulate(this.property.targetMeta.class, options));
119
119
  const cond = (await em.applyFilters(this.property.targetMeta.class, where, options.filters ?? {}, 'read'));
120
- const map = await em
120
+ // fall back to the ambient transaction context, or `withSessionContext` would wrap the pivot load in a
121
+ // second concurrent transaction (a deadlock with a single-connection pool) instead of joining the open one
122
+ const map = await em.withSessionContext(ctx ?? em.getTransactionContext(), trx => em
121
123
  .getDriver()
122
- .loadFromPivotTable(this.property, [helper(this.owner).__primaryKeys], cond, opts.orderBy, ctx, options);
124
+ .loadFromPivotTable(this.property, [helper(this.owner).__primaryKeys], cond, opts.orderBy, trx, options));
123
125
  items = map[helper(this.owner).getSerializedPrimaryKey()].map((item) => em.merge(this.property.targetMeta.class, item, { convertCustomTypes: true }));
124
126
  await em.populate(items, options.populate, options);
125
127
  }
@@ -77,6 +77,12 @@ export class EntityFactory {
77
77
  }
78
78
  }
79
79
  data = { ...data };
80
+ if (options.newEntity && meta2.root.inheritanceType === 'sti' && meta2.discriminatorValue != null) {
81
+ const prop = meta2.properties[meta2.root.discriminatorColumn];
82
+ if (prop && prop.userDefined !== false) {
83
+ data[prop.name] ??= meta2.discriminatorValue;
84
+ }
85
+ }
80
86
  const entity = exists ?? this.createEntity(data, meta2, options);
81
87
  wrapped = helper(entity);
82
88
  wrapped.__processing = true;
@@ -1,4 +1,4 @@
1
- import type { AnyEntity, AutoPath, ConnectionType, EntityName, EntityProperty, FilterQuery, PopulateHintOptions, PopulateOptions } from '../typings.js';
1
+ import type { AnyEntity, AutoPath, ConnectionType, EntityName, EntityProperty, FilterQuery, ObjectQuery, PopulateHintOptions, PopulateOptions } from '../typings.js';
2
2
  import type { EntityManager } from '../EntityManager.js';
3
3
  import { LoadStrategy, type LockMode, type PopulateHint, PopulatePath, type QueryOrderMap } from '../enums.js';
4
4
  import type { InflightQueryAbortStrategy } from '../connections/Connection.js';
@@ -14,6 +14,8 @@ export interface EntityLoaderOptions<Entity, Fields extends string = never, Excl
14
14
  where?: FilterQuery<Entity>;
15
15
  /** Controls how `where` conditions are applied to populated relations. */
16
16
  populateWhere?: PopulateHint | `${PopulateHint}`;
17
+ /** @see FindOptions.populateFilter */
18
+ populateFilter?: ObjectQuery<Entity>;
17
19
  /** Ordering for populated relations. */
18
20
  orderBy?: QueryOrderMap<Entity> | QueryOrderMap<Entity>[];
19
21
  /** Whether to reload already loaded entities. */
@@ -77,6 +79,10 @@ export declare class EntityLoader {
77
79
  /** @internal */
78
80
  findChildrenFromPivotTable<Entity extends object>(filtered: Entity[], prop: EntityProperty<Entity>, options: Required<EntityLoaderOptions<Entity>>, orderBy?: QueryOrderMap<Entity>[], populate?: PopulateOptions<Entity>, pivotJoin?: boolean): Promise<AnyEntity[][]>;
79
81
  private extractChildCondition;
82
+ /** Extracts the part of `options.populateFilter` that applies to the given relation. */
83
+ private extractChildPopulateFilter;
84
+ /** Splits an extracted populate filter into the conditions on the populated entity and those on its own relations. */
85
+ private splitPopulateFilter;
80
86
  private buildFields;
81
87
  private getChildReferences;
82
88
  private filterCollections;
@@ -1,5 +1,5 @@
1
1
  import { QueryHelper } from '../utils/QueryHelper.js';
2
- import { Utils } from '../utils/Utils.js';
2
+ import { DANGEROUS_PROPERTY_NAMES, Utils } from '../utils/Utils.js';
3
3
  import { ValidationError } from '../errors.js';
4
4
  import { LoadStrategy, PopulatePath, ReferenceKind, } from '../enums.js';
5
5
  import { Reference } from './Reference.js';
@@ -124,10 +124,10 @@ export class EntityLoader {
124
124
  mergeNestedPopulate(populate) {
125
125
  const tmp = populate.reduce((ret, item) => {
126
126
  /* v8 ignore next */
127
- if (item.field === PopulatePath.ALL) {
127
+ if (item.field === PopulatePath.ALL || DANGEROUS_PROPERTY_NAMES.includes(item.field)) {
128
128
  return ret;
129
129
  }
130
- if (!ret[item.field]) {
130
+ if (!Object.hasOwn(ret, item.field)) {
131
131
  ret[item.field] = item;
132
132
  return ret;
133
133
  }
@@ -229,9 +229,10 @@ export class EntityLoader {
229
229
  }
230
230
  toPopulate.push(entity);
231
231
  }
232
- else if (refValue == null && !helper(entity).__loadedProperties.has(prop.name)) {
232
+ else if (refValue == null && !prop.object && !helper(entity).__loadedProperties.has(prop.name)) {
233
233
  // FK columns weren't loaded (partial loading) — need to re-fetch them.
234
234
  // If the property IS in __loadedProperties, the FK was loaded and is genuinely null.
235
+ // Object-embedded virtual props are skipped — they are populated via the embeddable instance.
235
236
  needsFkLoad.push(entity);
236
237
  }
237
238
  }
@@ -333,7 +334,7 @@ export class EntityLoader {
333
334
  // When targetKey is set, use it for FK lookup instead of the PK
334
335
  let fk = prop.targetKey ?? Utils.getPrimaryKeyHash(meta.primaryKeys);
335
336
  let schema = options.schema;
336
- const partial = !Utils.isEmpty(prop.where) || !Utils.isEmpty(options.where);
337
+ const partial = !Utils.isEmpty(prop.where) || !Utils.isEmpty(options.where) || !Utils.isEmpty(options.populateFilter);
337
338
  let polymorphicOwnerProp;
338
339
  const ownerProp = prop.kind === ReferenceKind.ONE_TO_MANY || (prop.kind === ReferenceKind.MANY_TO_MANY && !prop.owner)
339
340
  ? meta.properties[prop.mappedBy]
@@ -393,12 +394,20 @@ export class EntityLoader {
393
394
  if (!Utils.isEmpty(prop.where) || Raw.hasObjectFragments(prop.where)) {
394
395
  where = { $and: [where, prop.where] };
395
396
  }
397
+ const childFilter = options.populateFilter ? await this.extractChildPopulateFilter(options, prop) : undefined;
398
+ // conditions on the populated entity itself have no sink in the child query (the joined strategy puts
399
+ // them on the join), only the nested relation ones can be forwarded as its own `populateFilter`
400
+ const [ownFilter, nestedFilter] = this.splitPopulateFilter(childFilter, meta);
401
+ if (ownFilter) {
402
+ where = { $and: [where, ownFilter] };
403
+ }
396
404
  const orderBy = QueryHelper.mergeOrderBy(options.orderBy, prop.orderBy);
397
405
  const findOptions = {
398
406
  filters,
399
407
  convertCustomTypes,
400
408
  lockMode,
401
409
  populateWhere,
410
+ populateFilter: nestedFilter,
402
411
  logging,
403
412
  orderBy,
404
413
  populate: populate.children ?? populate.all ?? [],
@@ -445,7 +454,10 @@ export class EntityLoader {
445
454
  }
446
455
  }
447
456
  }
448
- if ([ReferenceKind.ONE_TO_ONE, ReferenceKind.MANY_TO_ONE].includes(prop.kind) && items.length !== children.length) {
457
+ // a missing target row means an orphaned reference, unless the query was narrowed by a populate condition
458
+ if ([ReferenceKind.ONE_TO_ONE, ReferenceKind.MANY_TO_ONE].includes(prop.kind) &&
459
+ items.length !== children.length &&
460
+ Utils.isEmpty(options.where)) {
449
461
  const nullVal = this.#em.config.get('forceUndefined') ? undefined : null;
450
462
  const itemsMap = new Set();
451
463
  const childrenMap = new Set();
@@ -571,6 +583,9 @@ export class EntityLoader {
571
583
  filters,
572
584
  ignoreLazyScalarProperties,
573
585
  populateWhere,
586
+ populateFilter: options.populateFilter
587
+ ? (await this.extractChildPopulateFilter(options, prop))
588
+ : undefined,
574
589
  connectionType,
575
590
  logging,
576
591
  schema,
@@ -607,7 +622,7 @@ export class EntityLoader {
607
622
  const fields = this.buildFields(options.fields, prop);
608
623
  // oxfmt-ignore
609
624
  const exclude = Array.isArray(options.exclude) ? Utils.extractChildElements(options.exclude, prop.name) : options.exclude;
610
- const populateFilter = options.populateFilter?.[prop.name];
625
+ const populateFilter = options.populateFilter ? await this.extractChildPopulateFilter(options, prop) : undefined;
611
626
  const options2 = { ...options, fields, exclude, populateFilter };
612
627
  ['limit', 'offset', 'first', 'last', 'before', 'after', 'overfetch'].forEach(prop => delete options2[prop]);
613
628
  options2.populate = populate?.children ?? [];
@@ -620,7 +635,7 @@ export class EntityLoader {
620
635
  if (!Utils.isEmpty(prop.where)) {
621
636
  where = { $and: [where, prop.where] };
622
637
  }
623
- const map = await this.#driver.loadFromPivotTable(prop, ids, where, orderBy, this.#em.getTransactionContext(), options2, pivotJoin);
638
+ const map = await this.#em.withSessionContext(this.#em.getTransactionContext(), ctx => this.#driver.loadFromPivotTable(prop, ids, where, orderBy, ctx, options2, pivotJoin));
624
639
  const children = [];
625
640
  const isUnionTargetMN = QueryHelper.isUnionTargetPolymorphic(prop);
626
641
  for (let i = 0; i < filtered.length; i++) {
@@ -652,7 +667,8 @@ export class EntityLoader {
652
667
  }
653
668
  async extractChildCondition(options, prop, filters = false) {
654
669
  const where = options.where;
655
- const subCond = Utils.isPlainObject(where[prop.name]) ? where[prop.name] : {};
670
+ // shallow copy, the operator normalization below must not mutate the caller's condition
671
+ const subCond = Utils.isPlainObject(where[prop.name]) ? { ...where[prop.name] } : {};
656
672
  const meta2 = prop.targetMeta;
657
673
  const pk = Utils.getPrimaryKeyHash(meta2.primaryKeys);
658
674
  ['$and', '$or'].forEach(op => {
@@ -667,15 +683,16 @@ export class EntityLoader {
667
683
  }
668
684
  return cond;
669
685
  });
670
- if (child.length > 0) {
686
+ // partial extraction from `$or` is unsound — the parent may have matched via a dropped branch
687
+ if (child.length > 0 && (op === '$and' || child.length === where[op].length)) {
671
688
  subCond[op] = child;
672
689
  }
673
690
  }
674
691
  });
675
692
  const operators = Object.keys(subCond).filter(key => Utils.isOperator(key, false));
676
693
  if (operators.length > 0) {
694
+ subCond[pk] = Utils.isPlainObject(subCond[pk]) ? { ...subCond[pk] } : (subCond[pk] ?? {});
677
695
  operators.forEach(op => {
678
- subCond[pk] ??= {};
679
696
  subCond[pk][op] = subCond[op];
680
697
  delete subCond[op];
681
698
  });
@@ -685,6 +702,24 @@ export class EntityLoader {
685
702
  }
686
703
  return subCond;
687
704
  }
705
+ /** Extracts the part of `options.populateFilter` that applies to the given relation. */
706
+ async extractChildPopulateFilter(options, prop) {
707
+ const filter = await this.extractChildCondition({ ...options, where: options.populateFilter }, prop);
708
+ return Utils.isEmpty(filter) ? undefined : filter;
709
+ }
710
+ /** Splits an extracted populate filter into the conditions on the populated entity and those on its own relations. */
711
+ splitPopulateFilter(filter, meta) {
712
+ if (!filter) {
713
+ return [undefined, undefined];
714
+ }
715
+ const own = {};
716
+ const nested = {};
717
+ for (const key of Object.keys(filter)) {
718
+ const target = meta.relations.some(rel => rel.name === key) ? nested : own;
719
+ target[key] = filter[key];
720
+ }
721
+ return [Utils.isEmpty(own) ? undefined : own, Utils.isEmpty(nested) ? undefined : nested];
722
+ }
688
723
  buildFields(fields = [], prop, ref) {
689
724
  if (ref) {
690
725
  fields = prop.targetMeta.primaryKeys.map(targetPkName => `${prop.name}.${targetPkName}`);
@@ -1,5 +1,5 @@
1
1
  import type { PopulatePath } from '../enums.js';
2
- import type { CreateOptions, EntityManager, MergeOptions } from '../EntityManager.js';
2
+ import type { CreateOptions, EntityManager, MapOptions, MergeOptions } from '../EntityManager.js';
3
3
  import type { AssignOptions } from './EntityAssigner.js';
4
4
  import type { Dictionary, EntityData, EntityDictionary, EntityKey, EntityName, FilterQuery, Loaded, Primary, AutoPath, RequiredEntityData, Ref, EntityType, EntityDTO, MergeSelected, FromEntityType, IsSubset, MergeLoaded, ArrayElement, IndexFilterQuery, WithUsingOptions } from '../typings.js';
5
5
  import type { CountByOptions, CountOptions, DeleteOptions, FindAllOptions, FindByCursorOptions, FindOneOptions, FindOneOrFailOptions, FindOptions, GetReferenceOptions, NativeInsertUpdateOptions, StreamOptions, UpdateOptions, UpsertManyOptions, UpsertOptions } from '../drivers/IDatabaseDriver.js';
@@ -115,11 +115,10 @@ export declare class EntityRepository<Entity extends object> {
115
115
  */
116
116
  nativeDelete(where: FilterQuery<Entity>, options?: DeleteOptions<Entity>): Promise<number>;
117
117
  /**
118
- * Maps raw database result to an entity and merges it to this EntityManager.
118
+ * Maps raw database result to an entity and merges it to this EntityManager by default.
119
+ * Use `disableIdentityMap` to return an isolated entity without affecting the current context.
119
120
  */
120
- map(result: EntityDictionary<Entity>, options?: {
121
- schema?: string;
122
- }): Entity;
121
+ map(result: EntityDictionary<Entity>, options?: Omit<MapOptions, 'mapped'>): Entity;
123
122
  /**
124
123
  * Gets a reference to the entity identified by the given type and alternate key property without actually loading it.
125
124
  * The key option specifies which property to use for identity map lookup instead of the primary key.
@@ -131,7 +131,8 @@ export class EntityRepository {
131
131
  return this.getEntityManager().nativeDelete(this.entityName, where, options);
132
132
  }
133
133
  /**
134
- * Maps raw database result to an entity and merges it to this EntityManager.
134
+ * Maps raw database result to an entity and merges it to this EntityManager by default.
135
+ * Use `disableIdentityMap` to return an isolated entity without affecting the current context.
135
136
  */
136
137
  map(result, options) {
137
138
  return this.getEntityManager().map(this.entityName, result, options);
@@ -223,7 +224,11 @@ export class EntityRepository {
223
224
  }
224
225
  const entityName = entities[0].constructor.name;
225
226
  const repoType = Utils.className(this.entityName);
226
- if (entityName && repoType !== entityName) {
227
+ // compare class identity where possible, as minifiers can mangle two classes to the same name
228
+ const entityMeta = entities[0].__meta;
229
+ const repoMeta = this.getEntityManager().getMetadata?.().find(this.entityName);
230
+ const mismatch = entityMeta && repoMeta ? entityMeta.class !== repoMeta.class : entityName && repoType !== entityName;
231
+ if (mismatch) {
227
232
  throw ValidationError.fromWrongRepositoryType(entityName, repoType, method);
228
233
  }
229
234
  }