Issue persisting nested nested embedded documents
Asked Answered
S

1

7

Update: Pretty certain this a bug, made an issue on Jira: http://bit.ly/gpstW9
Update (5th May 2011): Upon jwage's recommendation I have switch to Referenced relationships between Categories and Post (as opposed to Embdedded).

I'm using the latest version of Doctrine ODM (fresh from Git).

I have three levels of Documents (Two embedded); Category -> EmbedsMany: Post -> EmbedsMany PostVersion.

PostVersion is automatically handled by Post. When I make a new post, it actually makes a new PostVersion under the hood as well.

My issue is that Doctrine get confused with PostVersions, if I retrieve an existing Category and add a new Post to it, the new Post's PostVersions get add to the first Post in the Category's $posts collection.

Step-by-step:

  1. Make a new Post (Post1) and Category
  2. Add Post1 to Category
  3. Persist Category, Flush, Clear
  4. Retrieve Category
  5. Make a new Post (Post2)
  6. Add Post2 to Category
  7. Flush

At this stage in the database, there should be one Category, two Posts and each Post has one PostVersion. However, what actually happens is there is one Category, two Posts, the first Post has two PostVersions and the second Post has zero PostVersions.

The Documents themselves during the request are correct, it's just want is persisted to the database that is wrong. What am I missing?

Expected Result:

{
  "_id": ObjectId("4da66baa6dd08df1f6000001"),
  "name": "The Category",
  "posts": [
    {
      "_id": ObjectId("4da66baa6dd08df1f6000002"),
      "activeVersionIndex": 0,
      "versions": [
        {
          "_id": ObjectId("4da66baa6dd08df1f6000003"),
          "name": "One Post",
          "content": "One Content",
          "metaDescription": null,
          "isAutosave": false,
          "createdAt": "Thu, 14 Apr 2011 13:36:10 +1000",
          "createdBy": "Cobby"
        }
      ]
    },
    {
      "_id": ObjectId("4da66baa6dd08df1f6000004"),
      "activeVersionIndex": 0
      "versions": [
        {
          "_id": ObjectId("4da66baa6dd08df1f6000005"),
          "name": "Two Post",
          "content": "Two Content",
          "metaDescription": null,
          "isAutosave": false,
          "createdAt": "Thu, 14 Apr 2011 13:36:10 +1000",
          "createdBy": "Cobby"
        }
      ]
    }
  ]
}

Actual Result:

{
  "_id": ObjectId("4da66baa6dd08df1f6000001"),
  "name": "The Category",
  "posts": [
    {
      "_id": ObjectId("4da66baa6dd08df1f6000002"),
      "activeVersionIndex": 0,
      "versions": [
        {
          "_id": ObjectId("4da66baa6dd08df1f6000003"),
          "name": "One Post",
          "content": "One Content",
          "metaDescription": null,
          "isAutosave": false,
          "createdAt": "Thu, 14 Apr 2011 13:36:10 +1000",
          "createdBy": "Cobby"
        },
        {
          "_id": ObjectId("4da66baa6dd08df1f6000005"),
          "name": "Two Post",
          "content": "Two Content",
          "metaDescription": null,
          "isAutosave": false,
          "createdAt": "Thu, 14 Apr 2011 13:36:10 +1000",
          "createdBy": "Cobby"
        }
      ]
    },
    {
      "_id": ObjectId("4da66baa6dd08df1f6000004"),
      "activeVersionIndex": 0
    }
  ]
}

Here are my Documents

Category.php

<?php

namespace Documents\Blog;

use Doctrine\Common\Collections\ArrayCollection;

/**
 * @Document(collection="blog")
 * @HasLifecycleCallbacks
 */
class Category
{

    /**
     * @Id
     */
    private $id;

    /**
     * @String
     */
    private $name;

    /**
     * @EmbedMany(targetDocument="Documents\Blog\Post")
     */
    private $posts;

    public function __construct($name = null)
    {
        $this->posts = new ArrayCollection();
        $this->setName($name);
    }

    public function getId()    
    {
        return $this->id;
    }

    public function getName()
    {
        return $this->name;
    }

    public function setName($name)
    {
        $this->name = $name;
    }

    public function getPosts()
    {
        return $this->posts->toArray();
    }

    public function addPost(Post $post)
    {
        $this->posts->add($post);
    }

    public function getPost($id)
    {
        return $this->posts->filter(function($post) use($id){
            return $post->getId() === $id;
        })->first();
    }

}

Post.php

<?php

namespace Documents\Blog;

use Doctrine\Common\Collections\ArrayCollection;

/**
 * @EmbeddedDocument
 * @HasLifecycleCallbacks
 */
class Post
{

    /**
     * @Id
     */
    private $id;

    private $firstVersion;

    private $activeVersion;

    /**
     * @Int
     */
    private $activeVersionIndex;

    /**
     * @EmbedMany(targetDocument="Documents\Blog\PostVersion")
     */
    private $versions;

    static private $currentUser;

    private $isDirty = false;

    public function __construct($name = "", $content = "")
    {
        if(!self::$currentUser){
            throw new \BlogException("Cannot create a post without the current user being set");
        }

        $this->versions      = new ArrayCollection();
        $this->activeVersion = $this->firstVersion = new PostVersion($name, $content, self::$currentUser);
        $this->versions->add($this->firstVersion);
        $this->isDirty = true;
    }

    public function getId()     
    {
        return $this->id;
    }

    public function getFirstVersion()
    {
        return $this->firstVersion;
    }

    public function getActiveVersion()
    {
        return $this->activeVersion;
    }

    public function setName($name)
    {
        $this->_setVersionValue('name', $name);
    }

    public function getName()
    {
        return $this->getActiveVersion()->getName();
    }

    public function setContent($content)
    {
        $this->_setVersionValue('content', $content);
    }

    public function getContent()
    {
        return $this->getActiveVersion()->getContent();
    }

    public function setMetaDescription($metaDescription)
    {
        $this->_setVersionValue('metaDescription', $metaDescription);
    }

    public function getMetaDescription()
    {
        return $this->getActiveVersion()->getMetaDescription();
    }

    public function getVersions()
    {
        return $this->versions->toArray();
    }

    private function _setVersionValue($property, $value)
    {   
        $version = $this->activeVersion;

        if(!$this->isDirty){
        // not dirty, make a new version
            $version = new PostVersion($version->getName(), $version->getContent(), self::getCurrentUser());
        }

        $refl = new \ReflectionProperty(get_class($version), $property);
        $refl->setAccessible(true);

        // updated current user
        $refl->setValue($version, $value);

        // unset ID
        $refl = new \ReflectionProperty(get_class($version), 'id');
        $refl->setAccessible(true);
        $refl->setValue($version, null);

        // updated self
        if(!$this->isDirty){
            $this->activeVersion = $version;
            $this->versions->add($version);
            $this->isDirty = true;
        }

        // no first version, this must be the first
        if($this->versions->count() === 1){
            $this->firstVersion = $version;
        }
    }

    static public function setCurrentUser($user)
    {
        self::$currentUser = $user;
    }

    static public function getCurrentUser()
    {
        return self::$currentUser;
    }

    /**
     * @PostLoad
     */
    public function findFirstVersion()
    {
        $firstVersion = null;
        foreach($this->versions as $version){
            if(null === $firstVersion){
                // first iteration, start with any version
                $firstVersion = $version;
                continue;
            }

            if($version->getCreatedAt() < $firstVersion->getCreatedAt()){
                // current version is newer than existing version
                $firstVersion = $version;
            }
        }

        if(null === $firstVersion){
            throw new \DomainException("No first version found.");
        }

        $this->firstVersion = $firstVersion;
    }

    /**
     * @PostLoad
     */
    public function findActiveVersion()
    {
        $this->activeVersion = $this->versions->get($this->activeVersionIndex);
    }

    /**
     * @PrePersist
     * @PreUpdate
     */
    public function doActiveVersionIndex()
    {
        $this->activeVersionIndex = $this->versions->indexOf($this->activeVersion);
        $this->isDirty = false;
    }

    /**
     * @PostPersist
     * @PostUpdate
     */
    public function makeClean()
    {
        $this->isDirty = false;
    }

    public function getCreatedBy()
    {
        return $this->getFirstVersion()->getCreatedBy();
    }

    public function getCreatedAt()
    {
        return $this->getFirstVersion()->getCreatedAt();
    }

}

PostVersion.php

<?php

namespace Documents\Blog;

/**
 * @EmbeddedDocument
 */
class PostVersion
{

    /**
     * @Id
     */
    private $id;

    /**
     * @String
     */
    private $name;

    /**
     * @String
     */
    private $content;

    /**
     * @String(nullable="true")
     */
    private $metaDescription;

    /**
     * @Boolean
     */
    private $isAutosave = false;

    /**
     * @Date
     */
    private $createdAt;

    /**
     * @String
     */
    private $createdBy;

    public function __construct($name, $content, $author)
    {
        $this->setName($name);
        $this->setContent($content);
        $this->setCreatedBy($author);
        $this->touch();
    }

    public function __clone()
    {
        if($this->id){
            $this->id = null;
            $this->touch();
        }
    }

    private function touch()
    {
        $this->createdAt = new \DateTime();
    }

    public function getId()     
    {
        return $this->id;
    }

    public function getName()
    {
        return $this->name;
    }

    public function setName($name)
    {
        $this->name = $name;
    }

    public function getContent()
    {
        return $this->content;
    }

    public function setContent($content)
    {
        $this->content = $content;
    }

    public function getIsAutosave()
    {
        return $this->isAutosave;
    }

    public function setIsAutosave($isAutosave)
    {
        $this->isAutosave = $isAutosave;
    }

    public function getCreatedAt()
    {
        return $this->createdAt;
    }

    public function setCreatedAt(\DateTime $createdAt)
    {
        $this->createdAt = $createdAt;
    }

    public function getCreatedBy()
    {
        return $this->createdBy;
    }

    public function setCreatedBy($createdBy)
    {
        $this->createdBy = $createdBy;
    }

    public function setMetaDescription($metaDescription)
    {
        $this->metaDescription = $metaDescription;
    }

    public function getMetaDescription()
    {
        return $this->metaDescription;
    }

}

...time to get dirty with xdebug I think.

Sepulture answered 14/4, 2011 at 3:55 Comment(0)
S
1

For now I've worked around the issue by creating an EventSubscriber which delays persisted nested embedded documents, it looks like this:

<?php

namespace Application\Blog\Domain\EventSubscribers;

use Application\Blog\Domain\Document\Post,
    Doctrine\ODM\MongoDB\Event\LifecycleEventArgs,
    Doctrine\ODM\MongoDB\Mapping\ClassMetadata;

/**
 * Handles delayed insert of nested embedded documents to work around Doctrine ODM bug :(
 */
class VersionManager implements \Doctrine\Common\EventSubscriber
{

    private $versions = array();

    /**
     * Returns an array of events this subscriber wants to listen to.
     *
     * @return array
     */
    public function getSubscribedEvents()
    {
        return array('prePersist', 'postPersist');
    }

    /**
     * Move versions out of Posts into temporary storage so they are flushed without versions
     *
     * @param \Doctrine\ODM\MongoDB\Event\LifecycleEventArgs $eventArgs
     * @return void
     */
    public function prePersist(LifecycleEventArgs $eventArgs)
    {
        $document = $eventArgs->getDocument();
        if($document instanceof Post){
            $dm = $eventArgs->getDocumentManager();
            $meta = $dm->getClassMetadata(get_class($document));
            $this->addVersion($meta, $document);
            $this->clearVersions($meta, $document);
        }
    }

    /**
     * Move the temporary versions back onto the Posts and flush
     *
     * @param \Doctrine\ODM\MongoDB\Event\LifecycleEventArgs $eventArgs
     * @return void
     */
    public function postPersist(LifecycleEventArgs $eventArgs)
    {
        $dm = $eventArgs->getDocumentManager();
        $hasChanges = count($this->versions) > 0;

        foreach($this->versions as $oid => $value){
            $post = $value['document'];
            $versions = $value['versions'];
            $meta = $dm->getClassMetadata(get_class($post));
            $meta->setFieldValue($post, 'versions', $versions);
            unset($this->versions[$oid]);
        }

        if($hasChanges){
            $dm->flush();
        }
    }

    /**
     * Add versions to temporary storage
     *
     * @param \Doctrine\ODM\MongoDB\Mapping\ClassMetadata $meta
     * @param \Application\Blog\Domain\Document\Post $post
     * @return void
     */
    private function addVersion(ClassMetadata $meta, Post $post)
    {
        $this->versions[spl_object_hash($post)] = array(
            'document' => $post,
            'versions' => $meta->getFieldValue($post, 'versions')
        );
    }

    /**
     * Remove versions from a Post
     *
     * @param \Doctrine\ODM\MongoDB\Mapping\ClassMetadata $meta
     * @param \Application\Blog\Domain\Document\Post $post
     * @return void
     */
    private function clearVersions(ClassMetadata $meta, Post $post)
    {
        $meta->setFieldValue($post, 'versions', null);
    }

}
Sepulture answered 19/4, 2011 at 1:17 Comment(3)
Was this ever resolved in Doctrine itself as I too am having some trouble with something similar.Everetteeverglade
I see you found the the issue on GitHub, it is still open: github.com/doctrine/mongodb-odm/pull/232Sepulture
Yeah thanks. That PR worked for me. Hopefully they will merge it soon.Everetteeverglade

© 2022 - 2024 — McMap. All rights reserved.