Loading inverse side of a one-to-one fails if target entity has an association id #7271

Closed
opened 2026-01-22 15:48:40 +01:00 by admin · 2 comments
Owner

Originally created by @mcurland on GitHub (Dec 6, 2023).

Bug Report

An inverse one-to-one is resolved with a separate query in EntityPersister->loadOneToOneEntity, which is called by UnitOfWork immediately after entity construction. If this is not the owning side of the association, a freshly constructed entity is provided as the source with the assumption that sufficient data is available in the entity to resolve the association.

The problem is that this is simply not true for an entity that uses an association as its identifier. In this case, the identifying association has not been initialized, so the entity has exactly zero useful information in it. This throws in loadOneToOneEntity indicating that the join column is not mapped.

The result is that the owning side of the relationship needs to be pulled into the UoW before the inverse side. Obviously, I should be able to query either object without failure

Q A
BC Break no (assuming BC means Backwards Compatibility, which is NO (Not Obvious))
Version 2.17.1 (ongoing)

Summary

Entity identified with an association cannot be loaded if it is the inverse side of a non-identifying one-to-one relationship.

Current behavior

Exception is thrown indicating that the join column cannot be identified.

How to reproduce

Snippets pulled from associated pull request (which does not fail because it includes a fix). Any attempt to directly retrieve an InverseSide (findOneBy, getResult, etc.) that is not already cached will throw.

/**
 * @Entity()
 * @Table(name="one_to_one_inverse_side_assoc_id_load_inverse")
 */
class InverseSide
{
    /**
     * Associative id (owning identifier)
     *
     * @var InverseSideIdTarget
     * @Id()
     * @OneToOne(targetEntity=InverseSideIdTarget::class, inversedBy="inverseSide")
     * @JoinColumn(nullable=false, name="associativeId")
     */
    public $associativeId;

    /**
     * @var OwningSide
     * @OneToOne(targetEntity=OwningSide::class, mappedBy="inverse")
     */
    public $owning;
}

/**
 * @Entity()
 * @Table(name="one_to_one_inverse_side_assoc_id_load_owning")
 */
class OwningSide
{
    /**
     * @var string
     * @Id()
     * @Column(type="string", length=255)
     * @GeneratedValue(strategy="NONE")
     */
    public $id;

    /**
     * Owning side
     *
     * @var InverseSide
     * @OneToOne(targetEntity=InverseSide::class, inversedBy="owning")
     * @JoinColumn(name="inverse", referencedColumnName="associativeId")
     */
    public $inverse;
}

/**
 * @Entity()
 * @Table(name="one_to_one_inverse_side_assoc_id_load_inverse_id_target")
 */
class InverseSideIdTarget
{
    /**
     * @var string
     * @Id()
     * @Column(type="string", length=255)
     * @GeneratedValue(strategy="NONE")
     */
    public $id;

    /**
     * @var InverseSide
     * @OneToOne(targetEntity=InverseSide::class, mappedBy="associativeId")
     */
    public $inverseSide;
}

Expected behavior

The expected behavior is that I can directly retrieve OwningSide and InverseSide in a symmetric fashion.

Approach to fix

The exception indicates that there is no field/column mapping for the associativeId field because it is used in association join column, so the first attempt to this added code to resolve the column. This was ultimately fruitless because even though the field was resolved, its contents are null because the owning relationship for the identifier has not been processed at this point.

The sourceEntity instance passed to loadOneToOneEntity is created immediately before this call, which moves data from the provided column-keyed array to the entity columns, with associations populated afterward. This means that we still have the original entity data array. I simply added a sourceEntityData argument to loadOneToOneEntity and provided this initial data. If the non-owning side cannot find data in the entity then it uses the original data array (which contains a superset of the entity data at this point) to form the query. This is a straightforward fix that solves the problem.

Originally created by @mcurland on GitHub (Dec 6, 2023). ### Bug Report An inverse one-to-one is resolved with a separate query in EntityPersister->loadOneToOneEntity, which is called by UnitOfWork immediately after entity construction. If this is not the owning side of the association, a freshly constructed entity is provided as the source with the assumption that sufficient data is available in the entity to resolve the association. The problem is that this is simply not true for an entity that uses an association as its identifier. In this case, the identifying association has not been initialized, so the entity has exactly zero useful information in it. This throws in loadOneToOneEntity indicating that the join column is not mapped. The result is that the owning side of the relationship needs to be pulled into the UoW before the inverse side. Obviously, I should be able to query either object without failure | Q | A |------------ | ------ | BC Break | no (assuming BC means _Backwards Compatibility_, which is NO (_Not Obvious_)) | Version | 2.17.1 (ongoing) #### Summary Entity identified with an association cannot be loaded if it is the inverse side of a non-identifying one-to-one relationship. #### Current behavior Exception is thrown indicating that the join column cannot be identified. #### How to reproduce Snippets pulled from associated pull request (which does not fail because it includes a fix). Any attempt to directly retrieve an InverseSide (findOneBy, getResult, etc.) that is not already cached will throw. ```php /** * @Entity() * @Table(name="one_to_one_inverse_side_assoc_id_load_inverse") */ class InverseSide { /** * Associative id (owning identifier) * * @var InverseSideIdTarget * @Id() * @OneToOne(targetEntity=InverseSideIdTarget::class, inversedBy="inverseSide") * @JoinColumn(nullable=false, name="associativeId") */ public $associativeId; /** * @var OwningSide * @OneToOne(targetEntity=OwningSide::class, mappedBy="inverse") */ public $owning; } /** * @Entity() * @Table(name="one_to_one_inverse_side_assoc_id_load_owning") */ class OwningSide { /** * @var string * @Id() * @Column(type="string", length=255) * @GeneratedValue(strategy="NONE") */ public $id; /** * Owning side * * @var InverseSide * @OneToOne(targetEntity=InverseSide::class, inversedBy="owning") * @JoinColumn(name="inverse", referencedColumnName="associativeId") */ public $inverse; } /** * @Entity() * @Table(name="one_to_one_inverse_side_assoc_id_load_inverse_id_target") */ class InverseSideIdTarget { /** * @var string * @Id() * @Column(type="string", length=255) * @GeneratedValue(strategy="NONE") */ public $id; /** * @var InverseSide * @OneToOne(targetEntity=InverseSide::class, mappedBy="associativeId") */ public $inverseSide; } ``` #### Expected behavior The expected behavior is that I can directly retrieve OwningSide and InverseSide in a symmetric fashion. #### Approach to fix The exception indicates that there is no field/column mapping for the associativeId field because it is used in association join column, so the first attempt to this added code to resolve the column. This was ultimately fruitless because even though the field was resolved, its contents are null because the owning relationship for the identifier has not been processed at this point. The sourceEntity instance passed to loadOneToOneEntity is created immediately before this call, which moves data from the provided column-keyed array to the entity columns, with associations populated afterward. This means that we still have the original entity data array. I simply added a sourceEntityData argument to loadOneToOneEntity and provided this initial data. If the non-owning side cannot find data in the entity then it uses the original data array (which contains a superset of the entity data at this point) to form the query. This is a straightforward fix that solves the problem.
admin closed this issue 2026-01-22 15:48:40 +01:00
Author
Owner

@greg0ire commented on GitHub (Dec 8, 2023):

assuming BC means Backwards Compatibility, which is NO

What else could it be confused with? 🤔

@greg0ire commented on GitHub (Dec 8, 2023): > assuming BC means Backwards Compatibility, which is NO What else could it be confused with? :thinking:
Author
Owner

@mcurland commented on GitHub (Dec 9, 2023):

assuming BC means Backwards Compatibility, which is NO

What else could it be confused with? 🤔

When I'm on the first line of a bug report the last thing I want to do is think about what the template means. I was thought out and running on less than 4 hours sleep for the 3rd night running (sick kids and sick dad). So I lazily Googled "what does BC Break mean?" and got a hit back in a doctrine bug report from the same template that said "I don't know what this means". I immediately felt a strong kinship with the previous filer, even if Google (complete with AI feedback) didn't know either. If it says: Does this break backwards compatibility? then it would be even easier to decipher. Anyway, back to real issues with _B_roken _C_ode.

@mcurland commented on GitHub (Dec 9, 2023): > > assuming BC means Backwards Compatibility, which is NO > > What else could it be confused with? 🤔 When I'm on the first line of a bug report the last thing I want to do is think about what the template means. I was thought out and running on less than 4 hours sleep for the 3rd night running (sick kids and sick dad). So I lazily Googled "what does BC Break mean?" and got a hit back in a doctrine bug report from the same template that said "I don't know what this means". I immediately felt a strong kinship with the previous filer, even if Google (complete with AI feedback) didn't know either. If it says: _Does this break backwards compatibility?_ then it would be even easier to decipher. Anyway, back to real issues with _B_roken _C_ode.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: doctrine/archived-orm#7271