2
votes

Quick Description:

When using merge() with lifecycle callbacks on @PrePersist and @PreUpdate, PrePersist is called on a blank entity and NOT the entity i'm trying to merge, so the entity i'm trying to merge never hits my @PrePersist callback!

@PreUpdate just never fires.

I can get both callbacks to fire in pathological cases, so it's not event registration that's the culprit.

Longer Description:

I have an entity that has some validation rules that I want to run anytime anytime an instance of that class is persisted. If the validation fails, it will throw a "ValidationException" which my stack knows how to handle and feed back to my client UI.

I run my validation by using symfony's built in validator (in particular, to use the UniqueEntity constraint). LIke so

$errorList = $validator->validate($newEntity);
if(count($errorList) > 0) {
    throw new ValidationException($errorList);
}

If i put this code in a Controller, this works exactly as I would expect and my tests pass.

Now i want to do this any time the entity is persisted so a developer does not have to explicitly call validate (forced validation). I tried registering a @PrePersist callback and injecting the validator:

protected $validator;
public function prePersist($args) {
    $entity = $args->getEntity();
    $errorList = $this->validator->validate($newEntity);
    if(count($errorList) > 0) {
        throw new ValidationException($errorList);
    }
}

This works in simple cases.

$obj = new Entity();
$obj->setAField('foo');
$em->persist($obj);
$em->flush();

Unfortunately, my actual constructor uses merge. This is because my 'saveEntity' route receives a JSON package from the client and deals with saving new entities AND saving edits to existing entities. It looks something like this:

$json = $request->request->get('objJSON');
$detachedEntity = $this->serializer->deserialize($json, 'MyEntity');
   //do any clean up here. In some cases you need to manually retrieve and set 
   //associated classes...
$mergedEntity = $em->merge($detachedEntity);
   //do any post-merge actions

$em->persist($mergedEntity);
$em->flush();

This workflow works in general. However my validation fails! After a long venture through the heart of the symfony code i found the reason:

merge() creates an empty entity and persists it. This empty entity has none of the properties filled in, so the validation ignores it (ignoring nulls). After it persists it, it merges the fields from the passed entity in and returns to the controller.

//From UnitOfWork.php:doMerge()  - line 1788
// If there is no ID, it is actually NEW.
        if ( ! $id) {
            $managedCopy = $this->newInstance($class);

            $this->persistNew($class, $managedCopy);

This triggers @PrePersist on a blank entity (useful!). It then merges the data from the detachedEntity i passed in into this managed entity.

I tried tracing the code on the persist() after the merge, but I had trouble finding where it actually commits the data to the database...

When the final persist is called, it's not inserting the entity, so @PrePersist is not triggered. OK. This is kind of frusterating, but I figured i could just treat it as an UPDATE instead (i want this checks run anytime an entity changes anyways), so i registered a @PreUpdate callback with identical code. It never gets called.

So.. is there something horribly wrong with my workflow, or is there a different callback I could call?

I find it really frusterating that the @PrePersist is called with the blank entity made by merge so I would prefer if there was another way to do that.

UPDATE This might be a bug... http://www.doctrine-project.org/jira/browse/DDC-2406?page=com.atlassian.jira.plugin.system.issuetabpanels:changehistory-tabpanel

1

1 Answers

2
votes

Checking your identifier values

You should get the @PreUpdate callback if the merge operation detects that you pass it a detached entity (as opposed to a new one), but it doesn't trigger until you call ->flush().

If you only get @PrePersist callbacks, you should check if $detachedEntity actually contains the identifier values (generally, the properties that are marked with @ORM\Id) that allows $em->merge(...) to handle it as detached.

To test this, you can verify that ClassMetadata#getIdentifierValues returns the entity's id value(s). Getting a classmetadata instance is a bit cumbersome, but based on your code above, this should work:

$id = $em->getClassMetadata(get_class($detachedEntity))
         ->getIdentifierValues($detachedEntity);
if (!$id) {
    // $entity will be considered as NEW and trigger @PrePersist
    // instead of @PreUpdate
}

 

An alternative strategy

I think this is a more common approach (which may or may not be appropriate for you, depending on what work the deserialize method really does):

  1. Get an id from the json data: $id = $json['path-to-id']
  2. a) Get the (managed) entity: $entity = $em->getRepository('MyBundle:MyEntity')->find($id)
  3. b) ...or create a new one, if that's your case: $entity = new MyEntity(...)
  4. Manually "merge" the remaining $json data into $entity (using set methods or whatever)
  5. Persist/flush

 

Additional tips

As a side note, if you always do $em->merge(...), then you don't need $em->persist(...). $em->merge(...) is meant for attaching detached entities, but if you pass a new entity to it, it will forward it to UnitOfWork#persistNew (which schedules the entity for insertion with the next flush), just like $em->persist(...) does.

Also, if you don't mind reading code, a quick look at the interals of UnitOfWork can be an enlightening experience.

Good luck. :-)

 

Edit (additional suggestion)

How about using a save method that handles both new and detached instances well, and optionally flushes them at once, e.g:

public function save($entity, $flush=false, $mergeCheck=true) {
    // Check for merging, if requested (trying to persist a detached object without merging will result in a new INSERT).
    if ($mergeCheck) {
        $id = $this->_em->getClassMetadata(get_class($entity))->getIdentifierValues($entity);
        if ($id) { $entity = $this->_em->merge($entity); } // May also merge cascaded associations.
    }
    $this->_em->persist($entity); // May also persist cascaded associations.
    if ($flush) { $this->_em->flush(); }
    return $entity; // Will return the managed instance, which may be different from the passed instance if the latter was detached.
}

If you're using custom repositories, I guess this method could go there.