Description
Since TypeORM 1.0, RelationMetadata.setEntityValue() merges the loaded relation
entity into an already-set relation property instead of assigning it
(introduced in #11267 to prevent eager-loaded relations from overwriting
manually requested nested relations):
// RelationMetadata.setEntityValue
if (ObjectUtils.isObject(entity[propertyName])) {
OrmUtils.mergeDeep(entity[propertyName], value);
} else {
entity[propertyName] = value;
}
However OrmUtils.merge only merges plain objects (isPlainObject
guard, see #7774):
static merge(target, source, memo = new Map()) {
if (OrmUtils.isPlainObject(target) && OrmUtils.isPlainObject(source)) { ... }
...
}
Loaded relation values are always class instances (entity instances), and
when the target property already holds a class instance too, mergeDeep is a
silent no-op: the freshly hydrated relation entity is discarded and the stale
pre-set instance stays on the parent entity.
This breaks any entity whose constructor pre-initializes a relation property,
e.g.:
@Entity()
class Post {
constructor() {
this.author = new Author(); // sensible default for "create" flows
}
@ManyToOne(() => Author)
@JoinColumn({ name: 'authorId' })
author: Author;
}
// typeorm creates entities via the constructor during hydration (unless
// entitySkipConstructor is set), so `author` is already an Author instance...
const post = await repo.findOne({ where: {}, relations: { author: true } });
post.author; // ...and stays the EMPTY Author() from the constructor.
// All hydrated Author columns are silently dropped.
On 0.3.x this worked: setEntityValue assigned unconditionally.
Expected Behavior
relations: { author: true } returns the hydrated Author entity, as in 0.3.x.
Actual Behavior
post.author is the empty instance created in the constructor; every column
of the loaded relation row is silently discarded (the SELECT contains the
columns; only the entity transformation drops them).
Discussion / possible fixes
Note the constraint: the #11265 scenario currently relies on the no-op —
when the same to-one relation is hydrated twice (manual nested join first,
eager join second), the silent no-op is what preserves the deeper first
value. So a naive "assign when target is a class instance" would fix this
issue but re-open #11265 for that ordering.
Possible directions:
-
Distinguish "pre-existing value was set by this hydration run" from
"pre-existing value came from the user/constructor". E.g. the transformer
marks relation properties it has already written (a Symbol on the
entity, or a per-run Set<entity, propertyPath>); first write assigns
(restoring 0.3.x semantics for constructor presets), subsequent writes
merge/no-op (keeping Eager relations with DeleteDateColumn override manually requested relations with nested properties #11265 behavior).
-
Make the merge class-instance aware: when target and value are instances
of the same entity, merge column values (and only overwrite relation
properties that are undefined on the target). Heavier, touches
OrmUtils.
Option 1 seems minimal and semantically precise. Happy to open a PR with a
regression test if you agree with a direction.
Description
Since TypeORM 1.0,
RelationMetadata.setEntityValue()merges the loaded relationentity into an already-set relation property instead of assigning it
(introduced in #11267 to prevent eager-loaded relations from overwriting
manually requested nested relations):
However
OrmUtils.mergeonly merges plain objects (isPlainObjectguard, see #7774):
Loaded relation values are always class instances (entity instances), and
when the target property already holds a class instance too,
mergeDeepis asilent no-op: the freshly hydrated relation entity is discarded and the stale
pre-set instance stays on the parent entity.
This breaks any entity whose constructor pre-initializes a relation property,
e.g.:
On 0.3.x this worked:
setEntityValueassigned unconditionally.Expected Behavior
relations: { author: true }returns the hydrated Author entity, as in 0.3.x.Actual Behavior
post.authoris the empty instance created in the constructor; every columnof the loaded relation row is silently discarded (the SELECT contains the
columns; only the entity transformation drops them).
Discussion / possible fixes
Note the constraint: the #11265 scenario currently relies on the no-op —
when the same to-one relation is hydrated twice (manual nested join first,
eager join second), the silent no-op is what preserves the deeper first
value. So a naive "assign when target is a class instance" would fix this
issue but re-open #11265 for that ordering.
Possible directions:
Distinguish "pre-existing value was set by this hydration run" from
"pre-existing value came from the user/constructor". E.g. the transformer
marks relation properties it has already written (a
Symbolon theentity, or a per-run
Set<entity, propertyPath>); first write assigns(restoring 0.3.x semantics for constructor presets), subsequent writes
merge/no-op (keeping Eager relations with DeleteDateColumn override manually requested relations with nested properties #11265 behavior).
Make the merge class-instance aware: when target and value are instances
of the same entity, merge column values (and only overwrite relation
properties that are
undefinedon the target). Heavier, touchesOrmUtils.Option 1 seems minimal and semantically precise. Happy to open a PR with a
regression test if you agree with a direction.