7

Let's say I have an Doctrine's (version 2) entity as follows:

<?php
namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * User
 *
 * @ORM\Table(name="users")
 * @ORM\Entity
 */
class User {
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=50, nullable=false)
     */
    private $name;

    /**
     * @var string
     *
     * @ORM\Column(name="group_id", type="string", length=6, nullable=true)
     */
     private $groupId;

    // Getters and setters...
}

Now, I would like to manage User's relation to Group, but with some conditions, like:

  1. returning NULL (or some sort of skeleton/template of \AppBundle\Entity\Group with fixed values not loaded from database) if Group of users.group_id does not exist, even if it is set to a value (no key restrictions set in the database to prevent this behaviour), so some sort of validation/check required
  2. lazy load Group, when calling $user->getGroup()

I am reading Google on and on and I'm confused of how to achieve that properly (in the line with Doctrine/Symfony way).

I could add a ManyToOne to the entity's class relation like this:

/**
 * @var \AppBundle\Entity\Group
 *
 * @ORM\ManyToOne(targetEntity="AppBundle\Entity\Group")
 * @ORM\JoinColumns({
 *   @ORM\JoinColumn(name="group_id", referencedColumnName="id")
 * })
 */
private $group;

But then how to prevent exceptions caused by non-existing foreign keys? I.e. I would like to retrieve user's group details when it is available in my database, but when it is not I do not want Doctrine to throw an exception an crash my application.

People say that using an Entity Manager from within an Entity is a very bad practice and I agree. But I am confused about using Proxy Objects or Inheritance Mapping for this purposes.

It seems like using Models could be the way, but I couldn't find any strong documentation of how to properly implement them in Doctrine2.

Please help if you can. In Kohana it was so, so simple (but immature this way).


EDIT:

@Massimiliano Fedel suggested trying catching an exception in User::getGroup() method, to eventually make non-existed groups return as NULL.

So I have commited this code:

/**
 * Get group
 *
 * @return \AppBundle\Entity\Group
 */
public function getGroup() {
    try {
        $group = $this->group;
    } catch (\Doctrine\ORM\EntityNotFoundException $e) {
        $group = null;
    }
    return $group;
}

Unfortunatelly, it seems that exception cannot be catched this way, because framework exits with an Doctrine\ORM\EntityNotFoundException:

Entity of type 'AppBundle\Entity\Group' for IDs id(999) was not found

EDIT 2:

Below you can find some basing schema of User flow, i.e. why I can not ensure that all Users will have available Groups in my database.

enter image description here

Jan Doggen
  • 8,799
  • 13
  • 70
  • 144
roomcays
  • 927
  • 1
  • 7
  • 22
  • Can you explain a bit more of the actual use case? Let's suppose we have a user with a group not in your database. What do you expect to happen in between requests? – Cerad Feb 12 '16 at 13:51
  • @Cerad what do you mean "between requests"? – roomcays Feb 12 '16 at 14:08
  • So you make a user with a group that is not in the database and obviously you don't want to actually persist the group. (Why I don't know). So now the user navigates to some other page. Do you expect the $user object to still have the same group? If so, where will the group be stored? If we knew why you wanted this sort of functionality then perhaps we could offer more suggestions. – Cerad Feb 12 '16 at 14:19
  • @Cerad Hmm, the user-group example is just a mockup to make things simple and understanable. However, the real case is about API-API communication, where some records are produced by my API, then send to remote API, which processes them somehow, and then they're returned back to my API **but** meanwhile a lot of other records, not created by my system, are also returned to me, and so it happens that they're related with groups that I do not have access to.... – roomcays Feb 12 '16 at 14:49

4 Answers4

1

1)Have you tried catching the exception inside the getter method of the "group"? so that you can catch the exception and return "null" in case an exception occured.

2) as from the doctrine 2.1 documentation: "Associations are marked as Lazy by default, which means the whole collection object for an association is populated the first time its accessed." http://doctrine-orm.readthedocs.org/projects/doctrine-orm/en/latest/tutorials/extra-lazy-associations.html

1

Well, what about creating a service, that is going to wrap your CRUD operations on the User entity, let's say UserService? This way, you can leave group_id as it is and add group field that is not managed (no annotations, not persisted to DB).

The UserService will have getGroup method, that will take User as an argument, then retrieve his group_id and use EntityManager (or indeed GroupService) to fetch the Group entity, if none was found, you will return null, otherwise you will set the returned Group to the unmanaged field of the entity, so you don't have to fetch it again next time.

Jan Mares
  • 795
  • 10
  • 22
  • This idea looks promising, although I am still mentaly more conviced to using some "Model-alike" wrappers (however "service" could be just another name for that). I'm trying this your idea out right now, Jan. I do not know yet how to easily & properly proxy `User` (as an entity) methods and properties to the `UserService`. Maybe you've got some idea for that too? – roomcays Feb 17 '16 at 14:37
  • I would not proxy them. Entities should be mere holders of the data (wrapping it nicely, with methods, that can protest when trying to set something nasty, but that is it). That is why Doctrine 2 moved from Active Record pattern to Data Mapper and I consider that as a big improvement. That is also why using `EntityManager` in entities is considered as a bad practice - entities are supposed to be stupid objects. Services on the other hand can be clever and "at a hand", for this you need service container. Have a look at this: http://symfony.com/doc/current/book/service_container.html – Jan Mares Feb 17 '16 at 14:45
1

Considering your specific use-case, your best bet are the Doctrine event subscribers.

Please be careful as those are separate from the regular Symfony event subscribers. The latter are related to the generic Symfony workflow and the former is specific to Doctrine.

Specifically, the postLoad event is what you need. The idea here is to:

  1. Add a group property to User with corresponding typehinted getter and setter
  2. Create a custom Doctrine subscriber, called MyBundle\Entity\EventSubscriber\UserSubscriber
  3. Inject it with the Doctrine entity manager service
  4. Create the postLoad method like this:

    public function postLoad(LifecycleEventArgs $args)
    {
        $user = $args->getEntity();
    
        if ($user instanceof User && $user->getGroupId()) {
            $r = $this->em->getRepository('MyBundle:Group');
            $group = $r->find($user->getGroupId());
    
            if ($group instanceof Group) {
                $user->setGroup($group);
            }
    
            // If not an instance of Group, no group can be found: do nothing and leave it NULL
        }
    }
    

The postLoad method is called whenever a Doctrine entity is loaded from the manager, meaning all entities found through Doctrine will be populated with their group when possible.

If you want lazy loading... first, are you sure you want this? User groups are generally used in most pages in Symfony applications - in authorization checks, for instance. If groups are loaded at every page it really isn't worth it.

If you absolutely need this, you could use the Doctrine proxy factory service and populate your User with a proxy instead of an actual entity (not sure how this works exactly):

$group = $this->em->getProxyFactory()->getProxy('MyBundle:Group', $user->getGroupId());
$user->setGroup($group);

In that case you would have to keep the following because some loading attempts will fail if the id does not exist:

/**
 * Get group
 *
 * @return \AppBundle\Entity\Group
 */
public function getGroup()
{
    static $loaded = false;

    if (!$loaded) {
        try {
            $this->group;
        } catch (\Doctrine\ORM\EntityNotFoundException $e) {
            $this->group = null;
        }
        $loaded = true;
    }

    return $this->group;
}

I tweaked the original code of Jan Mares as at every call of getGroup() Doctrine would have attempted to load the Group.

Zephyr
  • 1,598
  • 11
  • 22
  • Thank you Zephyr for your involvement. I will not be able to check your solution in next 12 hours when the bounty time is over, but it looks convincing. I was not aware of *Doctrine event subscribers*, which seems like a very powerfull tool to help with extra-ordinary situations. This is the kind of help I was looking for. My thanks go also to Jan Mares and Massimiliano Fedel. I will mark this answer as the correct one as soon I verify it (1-3 days). – roomcays Feb 22 '16 at 20:46
0

OK, first, your User-Group relationship is not correctly defined. You absolutely need to add the ManyToOne relationship:

/**
 * @var \AppBundle\Entity\Group
 *
 * @ORM\ManyToOne(targetEntity="AppBundle\Entity\Group")
 * @ORM\JoinColumns({
 *   @ORM\JoinColumn(name="group_id", referencedColumnName="id")
 * })
 */
private $group;

Some word about this. In the JoinColumns clause, by default, the relationship is nullable, meaning you can have NULL instead of the foreign key in the resulting group_id column (the join column that Doctrine will create for you).

If you want to have a non-nullable relationship, the definition has to go like this instead:

/**
 * @var \AppBundle\Entity\Group
 *
 * @ORM\ManyToOne(targetEntity="AppBundle\Entity\Group")
 * @ORM\JoinColumns({
 *   @ORM\JoinColumn(name="group_id", referencedColumnName="id", nullable=false)
 * })
 */
private $group;

So, by default, you can have a NULL group for a User. Once this is properly defined, regarding your questions:

1.

If your application is properly created, you should never end up with a User related to a Group that has not yet been committed to the database. Every Group you tie to a User has to be taken from Doctrine somehow before persisting the User itself. Be it from within the application or from a form. If you need to validate that the Group exists before persisting the User, you have to tackle the problem upstream instead, by being 100% sure that any Group you use is taken from the database.

In this specific context, the setGroup setter should already ensure that what is provided is indeed a Group entity (if not you should add a typehint), and Doctrine already ensures that this entity already exists in the database. I agree that the Doctrine errors can get ugly, but a delivered product should not include any of those anyway.

Understand that Symfony validation is generally used with forms, with the intent of validating potential violations introduced by the end user, not by the developer (generally).

2.

With a properly defined relationship, as shown above, Doctrine will take care of this for you automatically. By default, all Doctrine relationships are loaded lazily.

Also:

/**
 * Get group
 *
 * @return \AppBundle\Entity\Group
 */
public function getGroup() {
    try {
        $group = $this->group;
    } catch (\Doctrine\ORM\EntityNotFoundException $e) {
        $group = null;
    }
    return $group;
}

You don't need to do this. Remove it.

Zephyr
  • 1,598
  • 11
  • 22
  • Zephyr, thank you for your answer. This, though, could not be applied to my situation as I do try to build Symfony+Doctrine application based on database schema already defined some time ago. I can not (and will never can) ensure valid existing relation between `User` and `Group` (see [my comment here](http://stackoverflow.com/questions/35363940/in-doctrine-how-to-achieve-additional-model-functionallities-for-entity#comment-58436756)). **`User`s can have `group_id` relating to non-existing group** and I can not change this.This is why I need some another, possibly _more abstract_ approach. – roomcays Feb 22 '16 at 10:41
  • Ok then, is your application in charge of adding User-Group content, or is it only reading it? Is this some kind of refactoring or are you using external data? You say above that you what you are actually trying to set up API-API communication, but Doctrine can be quite different from communicating with a (say) Rest API for instance. – Zephyr Feb 22 '16 at 10:58
  • My application is in charge of adding `User` content, and `Groups` are (say) read-only: there is fixed amount of groups that I can "bind" _some_ `Users` to. Now, some more in-depth description: my app connects to external API, where it loads `Users` from, then it stores them in local DB. Other module of my app generates a report of all locally stored `Users` and presents it via, let's say, table on a HTML page. These users which can be _connected_ with local groups are presented with some extra group-details. For others, group details are substituted with some dummy data. – roomcays Feb 22 '16 at 11:21
  • To (hopefully) put some more light on the `User`-`Group` relation in my situation I have commited a [schema](http://oi64.tinypic.com/1sfno5.jpg). – roomcays Feb 22 '16 at 11:44
  • It seems you have some control over the `Group` table. Would it not be possible to clean it? If not, what would you do with a `User` related to a non-existing `Group`? Would you allow the app to edit it anyway? – Zephyr Feb 22 '16 at 14:32
  • The only control over the `Group` table is that I can add my own groups, but I can not predict to what groups other *user sources* will relate to. It is also a fact, that `Groups` are rather static (i.e. their amount is fixed). In the perfect case non-existing `Groups` that `Users` would refer to, could be *mocked* using plain PHP class of `Group` entity, where only their `id` would be filled (as known from `Users.group_id`). However, returning `null` is also acceptable. Editing of groups via app is currently not a concern at all, but if it will, non-existing groups should not be editable. – roomcays Feb 22 '16 at 15:00
  • Sorry if it wasn't clear, when I was saying "Would you allow the app to edit it anyway?", I was talking about the `User`, not the `Group`. Would you allow to show/edit `User` entities related to a non-existing `Group`? I will post a new answer but I need a response to that first to be accurate. – Zephyr Feb 22 '16 at 15:37
  • Yes, `Users` related to non-existing `Group` should be *viewable*, and possibly (as the editing is not a part of report generation process) *editable*. – roomcays Feb 22 '16 at 15:51