Stphane
Stphane

Reputation: 3456

False "new entity found through the relationship" - Object actually exists in database

We have a service which purpose is to log user actions into database. The underlying entity ActionLog has a manyToOne relation with our User entity. Both those entities are tied to the same DBAL connection (and ORM EntityManager).

Issue is: an exception is raised when a new ActionLog entity is persisted and flushed, saying we should cascade persist the object set as the #user property because considered new:

A new entity was found through the relationship 'Doctrine\Model\ActionLog#user' that was not configured to cascade persist operations for entity: John Doe. To solve this issue: Either explicitly call EntityManager#persist() on this unknown entity or configure cascade persist this association in the mapping for example @ManyToOne(..,cascade={"persist"}).

And this is annoying because the User instance actually comes straight from the database and as such isn't new at all! We expect this User object to already be "MANAGED" by the entityManager and be referenced through the identity map (in other words, the object is not "detached").


So, why would Doctrine consider the User entity instance (authenticated user) as detached/new?


Using Symfony 4.0.6 ; doctrine/orm v2.6.1, doctrine/dbal 2.6.3, doctrine/doctrine-bundle 1.8.1

ActionLog model mapping extract

Doctrine\Model\ActionLog:
    type: entity
    table: action_log
    repositoryClass: Doctrine\Repository\ActionLogRepository

    manyToOne:
        user:
            targetEntity: Doctrine\Model\User
    id: # …
    fields: # …

Log service declaration

log_manager:
    class: Service\Log\LogManager
    public: true
    arguments:
        - "@?security.token_storage"
    calls:
        # setter required instead of the dependency injection
        # to prevent circular dependency.
        - ['setEntityManager', ["@doctrine.orm.entity_manager"]]

Log service implementation - Creates new ActionLog record

<?php

namespace Service\Log;

use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\User\UserInterface;
use Doctrine\Model\User;
use Doctrine\Model\ActionLog;

class LogManager
{
    /**
     * @var ObjectManager
     */
    protected $om;

    /**
     * @var TokenStorage
     */
    protected $tokenStorage;

    /**
     * @var User
     */
    protected $user;

    /**
     * @var bool
     */
    protected $disabled = false;

    public function __construct(TokenStorage $tokenStorage = null)
    {
        $this->tokenStorage = $tokenStorage;
    }

    public function setEntityManager(ObjectManager $om)
    {
        $this->om = $om;
    }

    public function log(string $namespace, string $action, string $message = null, array $changeSet = null)
    {
        $log = new ActionLog;
        $log
            ->setNamespace($namespace)
            ->setAction($action)
            ->setMessage($message)
            ->setChangeset($changeSet)
        ;

        if ($this->isDisabled()) {
            return;
        }

        if (!$log->getUser()) {
            $user = $this->getUser();
            $log->setUsername(
                $user instanceof UserInterface
                ? $user->getUsername()
                : ''
            );
            $user instanceof User && $log->setUser($user);
        }

        $this->om->persist($log);
        $this->om->flush();
    }

    public function setUser(User $user): self
    {
        $this->user = $user;

        return $this;
    }

    public function getUser(): ?UserInterface
    {
        if (!$this->user) {
            if ($token = $this->tokenStorage->getToken()) {
                $this->user = $token->getUser();
            }
        }

        return is_string($this->user) ? null : $this->user;
    }

    public function disable(bool $disabled = true): self
    {
        $this->disabled = $disabled;

        return $this;
    }

    public function isDisabled(): bool
    {
        return $this->disabled;
    }
}

User entity dump. As you can see infos come from the database.

User {#417 ▼
  #name: "John Doe"
  #email: "[email protected]"
  #password: "ec40577ad8057ee34ce0bb9414673bf3"
  #createdAt: DateTime @1523344938 {#427 ▶}
  #enabled: true
  #lastLogin: null
  #id: 1
}

# Associated database row
'1', 'John Doe', '[email protected]', 'ec40577ad8057ee34ce0bb9414673bf3', '2018-04-10 07:22:18', '1', '1', null

Upvotes: 1

Views: 678

Answers (1)

Stphane
Stphane

Reputation: 3456

The assert was right, the User instance being passed to ActionLog::setUser method was not a known reference from the ORM point of view.
What happens: the object comes from the authentication process which unserialize the User data from session storage on each request (what suggested yceruto) and a User instance is created.
My custom userProvider should refresh the user object via the ORM but it doesn't, hence the "new reference" upon persist. I have no idea why although my UserProvider implementation lets suppose it should:

/**
 * @var ObjectManager
 */
protected $em;

public function __construct(ObjectManager $em)
{
    $this->em = $em;
}

public function loadUserByUsername($username)
{
    $user = $this->em->getRepository(User::class)->loadUserByUsername($username);

    if ($user && $user->isAdmin()) {
        return $user;
    }

    throw new UsernameNotFoundException(
        sprintf('Username "%s" does not exist.', $username)
    );
}

public function refreshUser(UserInterface $user)
{
    return $this->loadUserByUsername($user->getUsername());
}

public function supportsClass($class)
{
    return UserInterface::class === $class;
}

This said, I managed to (temporarily) solve the issue using the ORM proxy mechanism with the help of the Doctrine\ORM\EntityManager::getReference method, this can be done since the rebuilt object from session holds the User id (primary key).

The fix consist in replacing the following instruction in the Log_manager service:

$this->user = $token->getUser();
# ↓ BECOMES ↓
$this->user = $this->om->getReference(User::class, $token->getUser()->getId());

Any idea on this? Misuse? Github issue? Whatever the reason, comments are quite welcome.

Upvotes: 2

Related Questions