Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix association handling when there is a MappedSuperclass in the middle of an inheritance hierarchy #8415

Merged

Conversation

mpdude
Copy link
Contributor

@mpdude mpdude commented Jan 6, 2021

This fixes two closely related bugs.

  1. When inheriting a to-one association from a mapped superclass, update the sourceEntity class name to the current class only when the association is actually declared in the mapped superclass.
  2. Reject association types that are not allowed on mapped superclasses only when they are actually declared in a mapped superclass, not when inherited from parent classes.

Background

Currently, when a many-to-one association is inherited from a MappedSuperclass, mapping information will be updated so that the association has the current (inheriting) class as the source entity.

private function addInheritedRelations(ClassMetadata $subClass, ClassMetadata $parentClass): void
{
foreach ($parentClass->associationMappings as $field => $mapping) {
if ($parentClass->isMappedSuperclass) {
if ($mapping['type'] & ClassMetadata::TO_MANY && ! $mapping['isOwningSide']) {
throw MappingException::illegalToManyAssociationOnMappedSuperclass($parentClass->name, $field);
}
$mapping['sourceEntity'] = $subClass->name;
}

This was added in 7dc8ef1 for DDC-671.

The reason for this is that a mapped superclass is not an entity itself and has no table.

So, in the database, associations can only be from the inheriting entities' tables towards the referred-to target. This is also the reason for the limitation that only to-one associations may be added in mapped superclasses, since for those the database foreign key can be placed on the table(s) of the inheriting entities (and there may be more than one child class).

Issue with the current code

Neither the decision to update the sourceEntity nor the validation check should be based on $parent->isMappedSuperclass.

This only works in the simple case where the class hierarchy is Mapped Superclass → Entity.

The check is wrong when we have an inheritance hierarchy set up and the class hierarchy is Base Entity → Mapped Superclass → Child Entity.

Bug 1: The association should keep the root entity as the source. After all, in a JTI, the root table will contain the foreign key, and we need to base joins on that table when traversing FROM LeafClass l JOIN l.target.

Bug 2: Do not reject the to-many association declared in the base class. It is ok to have the reverse (owning) side point back to the base entity, as it would be if there were no mapped superclasses at all. The mapped superclass does not declare, add or otherwise interfere with the to-many association at all.

Suggested fix

Base the decision to change the sourceEntity on $mapping['inherited'] being set. This field points to the topmost parent entity class in the ancestry tree where the relationship mapping appears for the first time.

When it is not set, the current class is the first entity class in the hierarchy with that association. Since we are inheriting the relation, it must have been added in a mapped superclass above, but was not yet present in the nearest parent entity class.

In that case, it may only be a to-one association and the source entity needs to be updated.

(See #10396 for a clarification of the semantics of inherited.)

Example

Here is a simplified example of the class hierarchy.

See the two tests added for more details – one is for checking the correct usage of a to-one association against/with the base class in JTI. The other is to test that a to-many association on the base class is not rejected.

I am sure that there are other tests that (still) cover the update of sourceEntity is happening.

/**
 * @Entity
 */
class AssociationTarget
{
    /**
     * @Column(type="integer")
     * @Id
     * @GeneratedValue
     */
    public $id;
}

/**
 * @Entity
 * @InheritanceType("JOINED")
 * @DiscriminatorColumn(name="discriminator", type="string")
 * @DiscriminatorMap({"1" = "BaseClass", "2" = "LeafClass"})
 */
class BaseClass
{
    /**
     * @Column(type="integer")
     * @Id
     * @GeneratedValue
     */
    public $id;

    /**
     * @ManyToOne(targetEntity="AssociationTarget")
     */
    public $target;
}

/**
 * @MappedSuperclass
 */
class MediumSuperclass extends BaseClass
{
}

/**
 * @Entity
 */
class LeafClass extends MediumSuperclass
{
}

When querying FROM LeafClass l, it should be possible to JOIN l.target. This currently leads to an SQL error because the SQL join will be made via LeafClass.target_id instead of BaseClass.target_id. LeafClass is considered the sourceEntity for the association – which is wrong–, and so the foreign key field is expected to be in the LeafClass table (using JTI here).

Fixes #5998, fixes #7825.

Updated:

I have removed the abstract entity class, since it is not relevant for the issue and took the discussion off course. Also, the discriminator map now contains all classes.

Updated 2:

Added the second variant of the bug, namely that a to-many association would wrongly be rejected in the same situation.

@mpdude mpdude force-pushed the mapped-superclass-association-inheritance branch 3 times, most recently from 33f0f3d to 77acfb8 Compare January 7, 2021 07:43
@mpdude
Copy link
Contributor Author

mpdude commented Jan 7, 2021

It seems #5998 describes the same bug. #7825 might include the attempt to fix it, although it's a bigger (abandoned?) PR and seems to address additional things.

@mpdude mpdude force-pushed the mapped-superclass-association-inheritance branch 2 times, most recently from 4414090 to 5c0d4bf Compare January 7, 2021 08:34
@greg0ire
Copy link
Member

greg0ire commented Jan 7, 2021

Please add something to sum #7825 (comment) up in the docs, that kind of knowledge of what is supported and what isn't could come in handy to both users and maintainers.

@greg0ire
Copy link
Member

greg0ire commented Jan 7, 2021

I don't think 2.7 is still maintained, but https://github.com/doctrine/orm/blob/2.8.x/.doctrine-project.json says it is. Maybe @beberlei or @SenseException will know?

@mpdude
Copy link
Contributor Author

mpdude commented Jan 7, 2021

@greg0ire Regarding additional documentation on MappedSuperclass: I have often missed that myself 👍. Maybe someone who knows better than me could give me some starting pointers...?

Is a @MappedSuperclass declaration always necessary when a class contains fields or associations that need to be persisted, but when that class is not an @Entity?

I sometimes had the impression that things also work without the declaration?

Also, what about @Entity classes that are abstract?

@greg0ire
Copy link
Member

greg0ire commented Jan 7, 2021

Maybe someone who knows better than me could give me some starting pointers...?

Sure, but it won't be me I'm afraid 😅

@SenseException
Copy link
Member

For ORM it should the current version that is supported. In that case it's 2.8. I'm not sure if there are exceptions for 2.7, like security issues. I think this should target 2.8.x. I can see confusion what the repository "says" and what the documentation shows, because the docs still label it as upcoming. This will be handled in a different PR.

@mpdude @Entity shouldn't be used in an abstract because Entities can't be abstract itself, while a MappedSuperclass can. There's probably a way to make things work in a way, but this might result in problems on ORM updates in the future.

From the docs: https://www.doctrine-project.org/projects/doctrine-orm/en/2.8/reference/inheritance-mapping.html

Typically, the purpose of such a mapped superclass is to define state and mapping information that is common to multiple entity classes.

If there are entities with similar mapping, the MappedSuperclass can contain these common fields. There's not much extended info about other possible use cases and I'm not sure if there are some that are considered as a intentional feature. The sentence

Mapped superclasses ... can appear in the middle of an otherwise mapped inheritance hierarchy (through Single Table Inheritance or Class Table Inheritance).

seems to open a lot of possibilities that might not be covered by ORM updates.

@mpdude
Copy link
Contributor Author

mpdude commented Jan 9, 2021

@SenseException I'm afraid I might not fully understand your comment.

Regarding the abstract base @Entity, that might be a leftover from the real life case I came from; I'll check that and think the problem persists if I remove it. 

I will also rebase this PR to 2.8. Will it be backported to 2.7, or what is the exact state of that (security fixes only?).

But more in general, are you suggesting that it is not supported, discouraged or otherwise unsafe to use @MappedSuperclass in hierarchies below @Entity classes?

Updated, see next comment.

@mpdude mpdude changed the base branch from 2.7.x to 2.8.x January 11, 2021 07:32
@mpdude
Copy link
Contributor Author

mpdude commented Jan 11, 2021

Whether or not the base class from my example is abstract does not make a difference for the issue.

Just as a side note, when the class is not abstract, for obvious reasons I get an error message

[base class] has to be part of the discriminator map of [base class] to be properly mapped in the inheritance hierarchy. Alternatively you can make [base class] an abstract class to avoid this exception from occurring

This seems to confirm that an abstract @Entity was not completely unexpected when that part of the ORM was designed. Also, at https://www.doctrine-project.org/projects/doctrine-orm/en/2.13/reference/architecture.html#entities it says

Entities support inheritance, polymorphic associations, and polymorphic queries. Both abstract and concrete classes can be entities. Entities may extend non-entity classes as well as entity classes, and non-entity classes may extend entity classes.

But as I said, it does not matter here.

Regarding MappedSuperclass in the middle of a class hierarchy, apart from the documentation quoted above there has also been some discussion in #7825. If this approach were completely beyond what ORM was designed for, my guess is it would have been rejected over there already, which apparently wasn't the case.

@andrews05
Copy link
Contributor

andrews05 commented Feb 7, 2021

Would be great to see this working!
I'm surprised at what a minor change it was to make it work and pass all those tests, considering everything it took to achieve this on the v3 branch.

Just for reference, here's what @guilhermeblanco said in #7825:

Actually we originally designed to have any mix of inheritance together, but for simplicity we killed the support for mixing the JTI and STI together, but was always intended to support JTI + MappedSuperclass and STI + MappedSuperclass.

@mpdude mpdude force-pushed the mapped-superclass-association-inheritance branch from 5c0d4bf to 719fb73 Compare February 9, 2021 07:37
@mpdude
Copy link
Contributor Author

mpdude commented Feb 9, 2021

@andrews05 Great to have you on board here! You must have spent a lot of time working on #7825, so I think you have some valuable expertise to add. From your point of view, are there any edge cases we should cover as well?

@mpdude mpdude force-pushed the mapped-superclass-association-inheritance branch from 5939fc8 to 038f0e6 Compare February 9, 2021 07:52
@andrews05
Copy link
Contributor

@mpdude Hm, I would say if it's passing all the tests I added, then that should cover all the edge cases I'm aware of.

@mpdude
Copy link
Contributor Author

mpdude commented May 31, 2021

Could anybody give me hints what I could do to get this along? 🙏🏻

@vv12131415
Copy link
Contributor

vv12131415 commented Jun 6, 2021

@mpdude since 2.8 is unmaintained, first thing to do, is to change the branch to 2.9

then you can tag someone who can do review for this part of code

upd: you can also see the history https://github.com/doctrine/orm/commits/2.9.x/lib/Doctrine/ORM/Mapping
and tag those who have most recent commits there

@mpdude mpdude changed the base branch from 2.8.x to 2.9.x June 7, 2021 09:21
@mpdude mpdude force-pushed the mapped-superclass-association-inheritance branch from 6e9e456 to 2b47af6 Compare June 7, 2021 09:21
@mpdude mpdude changed the base branch from 2.9.x to 2.0.x June 7, 2021 09:22
@mpdude mpdude changed the base branch from 2.0.x to 2.9.x June 7, 2021 09:22
@mpdude
Copy link
Contributor Author

mpdude commented Jun 7, 2021

@beberlei you last worked on this area of the code in 2010 when you did 7dc8ef1. @guilhermeblanco you were involved in the discussion of #7825, which should be fixed by this PR.

Would it be possible for either of you to review this PR? 💚

@mpdude
Copy link
Contributor Author

mpdude commented Feb 17, 2022

Rebased onto 2.11.x. The fix itself is a three-line change, the rest are tests added ;-).

@greg0ire
Copy link
Member

@mpdude I removed the usage of the simple annotation loader a while ago. This means the annotations in your tests need to come with corresponding use statements.

@mpdude
Copy link
Contributor Author

mpdude commented Feb 17, 2022

Thank you @greg0ire for the heads-up.

@mpdude
Copy link
Contributor Author

mpdude commented Dec 22, 2022

I'd still like to get this resolved and merged, and I am willing to spend the time to port and this to the current 2.x branch.

@beberlei Do you see a chance to review this and what would you need to make this as easy as possible?

@derrabus derrabus changed the base branch from 2.11.x to 2.14.x December 22, 2022 16:34
@mpdude
Copy link
Contributor Author

mpdude commented Jan 14, 2023

I have an idea how the check can be written even more clearly based on #10397 and #10398

@mpdude mpdude force-pushed the mapped-superclass-association-inheritance branch from 26694a5 to 0408e70 Compare January 15, 2023 00:03
@mpdude
Copy link
Contributor Author

mpdude commented Jan 16, 2023

@derrabus Would it be possible for you to review this?

If it is too complex, has missing context or similar, let me know and I'll try to make it as easy as possible for you.

@mpdude mpdude changed the title Fix wrong association mapping when there is a MappedSuperclass in the middle of an inheritance hierarchy Fix association handling when there is a MappedSuperclass in the _middle_ of an inheritance hierarchy Jan 17, 2023
@mpdude mpdude changed the title Fix association handling when there is a MappedSuperclass in the _middle_ of an inheritance hierarchy Fix association handling when there is a MappedSuperclass in the middle of an inheritance hierarchy Jan 17, 2023
beberlei
beberlei previously approved these changes Jan 17, 2023
@mpdude mpdude force-pushed the mapped-superclass-association-inheritance branch 2 times, most recently from a2a27b5 to ed87b18 Compare January 17, 2023 14:32
…le of an inheritance hierarchy

This fixes two closely related bugs.

1. When inheriting a to-one association from a mapped superclass, update the `sourceEntity` class name to the current class only when the association is actually _declared_ in the mapped superclass.
2. Reject association types that are not allowed on mapped superclasses only when they are actually _declared_ in a mapped superclass, not when inherited from parent classes.

Currently, when a many-to-one association is inherited from a `MappedSuperclass`, mapping information will be updated so that the association has the current (inheriting) class as the source entity.

https://github.com/doctrine/orm/blob/2138cc93834cfae9cd3f86c991fa051a3129b693/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php#L384-L393

This was added in 7dc8ef1 for [DDC-671](doctrine#5181).

The reason for this is that a mapped superclass is not an entity itself and has no table.

So, in the database, associations can only be from the inheriting entities' tables towards the referred-to target. This is also the reason for the limitation that only to-one associations may be added in mapped superclasses, since for those the database foreign key can be placed on the table(s) of the inheriting entities (and there may be more than one child class).

Neither the decision to update the `sourceEntity` nor the validation check should be based on `$parent->isMappedSuperclass`.

This only works in the simple case where the class hierarchy is `Mapped Superclass → Entity`.

The check is wrong when we have an inheritance hierarchy set up and the class hierarchy is `Base Entity → Mapped Superclass → Child Entity`.

Bug 1: The association should keep the root entity as the source. After all, in a JTI, the root table will contain the foreign key, and we need to base joins on that table when traversing `FROM LeafClass l JOIN l.target`.

Bug 2: Do not reject the to-many association declared in the base class. It is ok to have the reverse (owning) side point back to the base entity, as it would be if there were no mapped superclasses at all. The mapped superclass does not declare, add or otherwise interfere with the to-many association at all.

Base the decision to change the `sourceEntity` on `$mapping['inherited']` being set. This field points to the topmost _parent entity_ class in the ancestry tree where the relationship mapping appears for the first time.

When it is not set, the current class is the first _entity_ class in the hierarchy with that association. Since we are inheriting the relation, it must have been added in a mapped superclass above, but was not yet present in the nearest parent entity class.

In that case, it may only be a to-one association and the source entity needs to be updated.

(See doctrine#10396 for a clarification of the semantics of `inherited`.)

Here is a simplified example of the class hierarchy.

See the two tests added for more details – one is for checking the correct usage of a to-one association against/with the base class in JTI. The other is to test that a to-many association on the base class is not rejected.

I am sure that there are other tests that (still) cover the update of `sourceEntity` is happening.

```php
/**
 * @entity
 */
class AssociationTarget
{
    /**
     * @column(type="integer")
     * @id
     * @GeneratedValue
     */
    public $id;
}

/**
 * @entity
 * @InheritanceType("JOINED")
 * @DiscriminatorColumn(name="discriminator", type="string")
 * @DiscriminatorMap({"1" = "BaseClass", "2" = "LeafClass"})
 */
class BaseClass
{
    /**
     * @column(type="integer")
     * @id
     * @GeneratedValue
     */
    public $id;

    /**
     * @manytoone(targetEntity="AssociationTarget")
     */
    public $target;
}

/**
 * @MappedSuperclass
 */
class MediumSuperclass extends BaseClass
{
}

/**
 * @entity
 */
class LeafClass extends MediumSuperclass
{
}
```

When querying `FROM LeafClass l`, it should be possible to `JOIN l.target`. This currently leads to an SQL error because the SQL join will be made via `LeafClass.target_id` instead of `BaseClass.target_id`. `LeafClass` is considered the `sourceEntity` for the association – which is wrong–, and so the foreign key field is expected to be in the `LeafClass` table (using JTI here).

Fixes doctrine#5998, fixes doctrine#7825.

I have removed the abstract entity class, since it is not relevant for the issue and took the discussion off course. Also, the discriminator map now contains all classes.

Added the second variant of the bug, namely that a to-many association would wrongly be rejected in the same situation.
@mpdude mpdude force-pushed the mapped-superclass-association-inheritance branch from ed87b18 to 8d9ebed Compare January 17, 2023 14:37
@greg0ire greg0ire added this to the 2.14.2 milestone Jan 17, 2023
@greg0ire greg0ire merged commit 69c7791 into doctrine:2.14.x Jan 17, 2023
@greg0ire
Copy link
Member

Thanks @mpdude !

@mpdude
Copy link
Contributor Author

mpdude commented Jan 17, 2023

Thank you all!

@mpdude mpdude deleted the mapped-superclass-association-inheritance branch January 17, 2023 18:23
mpdude added a commit to mpdude/doctrine2 that referenced this pull request Jan 24, 2023
…ed superclass in the hierarchy

This picks the test case from doctrine#9517 and rebases it onto 2.14.x.

The problem has been covered in doctrine#8415, so this PR closes doctrine#9517 and fixes doctrine#9516.

Co-authored-by: Robert D'Ercole <[email protected]>
mpdude added a commit to mpdude/doctrine2 that referenced this pull request Jan 24, 2023
…ed superclass in the hierarchy

This picks the test case from doctrine#9517 and rebases it onto 2.14.x.

The problem has been covered in doctrine#8415, so this PR closes doctrine#9517 and fixes doctrine#9516.

Co-authored-by: Robert D'Ercole <[email protected]>
@greg0ire greg0ire added the Bug label Apr 12, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

table inheritance 'JOINED' issue with selecting reference fields from base table
7 participants