TiMESPLiNTER
TiMESPLiNTER

Reputation: 5899

Doctrine 2 @Version doesn't work

I tried to implement a versionable ORM using Doctrine2.

Everything works well. When I update an existing entry the old entry gets inserted into the *_version table.

But when I update the entry in another request (and therefor a new instance of EntityManager) the old entry won't be written anymore to the *_version table although the entry in the basic table gets updated without any problems (even the version no. gets incremented by 1).

I like to show you my very simple versionable ORM:

UPDATE: The example code below works now!

Also check my Gist with the logEntityVersion helper method.

ProductBase.php

trait ProductBase
{
    /** 
     * @ORM\Id 
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    protected $id;
    /** 
     * @ORM\Column(type="string") 
     */
    protected $name;

    // getters and setters here
}

Product.php

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;

/**
 * Product
 *
 * @ORM\Entity
 * @ORM\Table(name="product")
 * @ORM\HasLifeCycleCallbacks
 */
class Product
{
    use ProductBase;

    /**
     * @ORM\OneToMany(targetEntity="ProductVersion", mappedBy="product", cascade={"persist"})
     */
    private $auditLog;

    /**
     * @ORM\Column(type="integer")
     * @ORM\Version
     */
    private $version = 1;

    public function __construct()
    {
        $this->auditLog = new ArrayCollection();
    }

    public function logVersion()
    {
        echo sprintf("Creating a new version for ID %s, version %s\n", $this->getId(), $this->getVersion());
        $this->auditLog[] = new ProductVersion($this);
    }

    // Version getter and setter
}

ProductVersion.php

use Doctrine\ORM\Mapping as ORM;

/**
 * ProductVersion
 *
 * @ORM\Entity
 * @ORM\Table(name="product_version")
 */
class ProductVersion
{
    use ProductBase;

    /**
     * @ORM\ManyToOne(targetEntity="Product", inversedBy="auditLog")
     */
    private $product;

    /**
     * @ORM\Column(type="integer")
     */
    private $version;

    public function __construct(Product $product)
    {
        $this->product = $product;
        $this->name = $product->getName();
        $this->version = $product->getVersion();

        var_dump($product->getVersion());
    }

    // Version getter and setter
}

And this is the code for inserting a new entry and update it to create a new version:

// Insert new product
$this->entityManager->beginTransaction();
$this->entityManager->flush();

$product = new Product();
$product->setName('Product V1');

$this->entityManager->persist($product);
$this->entityManager->flush();

$this->entityManager->commit();

$productId = $product->getId();

echo "Created Product with ID " . $product->getId() . "\n";

/** @var Product $foo */
$foo = $this->entityManager->getRepository('orm\Product')->find($productId);

// Log version (Product V1)
$this->entityManager->beginTransaction();
$this->entityManager->flush();
$foo->logVersion();
$this->entityManager->flush();
$this->entityManager->commit();

// Update 1
$foo->setName('Product V2');
$this->entityManager->flush();

// Log version (Product V2)
$this->entityManager->beginTransaction();
$this->entityManager->flush();
$foo->logVersion();
$this->entityManager->flush();
$this->entityManager->commit();

// Update 2 
$foo->setName('Product V3');
$this->entityManager->flush();

Schema generation

$tools = new SchemaTool($this->entityManager);

var_dump($tools->getCreateSchemaSql(array(
    $this->entityManager->getClassMetadata('orm\Product'),
    $this->entityManager->getClassMetadata('orm\ProductVersion')
)));

Upvotes: 3

Views: 3415

Answers (1)

Jasper N. Brouwer
Jasper N. Brouwer

Reputation: 21817

I see a couple of issues with your code, and unfortunately the concept as well:

Array vs Collection

You've defined Product::$auditLog as an array, but it needs to be a Collection. Use this in stead:

class Product
{
    private $auditLog;

    public function __construct()
    {
        $this->auditLog = new \Doctrine\Common\Collections\ArrayCollection();
    }

Association mapping

You've defined Product::$auditLog as being mapped by ProductVersion::$product, but haven't defined ProductVersion::$product as being inversed by Product::$auditLog. Fix it like this:

class ProductVersion
{
    /** @ORM\ManyToOne(targetEntity="Product", inversedBy="auditLog") */
    private $product;

Also, please validate your mappings and database schema. Every error might lead to unexpected results.

$ doctrine orm:validate-schema          // plain Doctrine2
$ app/console doctrine:schema:validate  // Symfony2

Versioning

You're using the @Version annotation in the trait, meaning both Product and ProductVersion will be versioned by Doctrine. As @Version is used for optimistic locking, I doubt this is what you're really after.

Records in an audit-log should never be updated (or deleted), only added. So locking records doesn't really make sense here.

Please remove ProductBase::$version and in stead add:

  • Product::$version with @Version annotation.
  • ProductVersion::$version without @Version annotation.

Now only Product will be versioned by Doctrine, ProductVersion will simply contain the version-number of the product that is logged.

PreUpdate event

You're using @PrePersist and @PreUpdate lifecycle callbacks to populate the audit-log. @PreUpdate is going to be a problem, as the docs state:

Changes to associations of the updated entity are never allowed in this event

Yet you are changing the Product::$auditLog association by adding a record to the collection.

You'll have to move this logic, and there are many options as to where to:

One option could be to start a transaction manually, flush (and keep track of versioned entities), create log entries, flush again, commit the transaction. Or you could replace the second flush with direct inserts using the DBAL connection.

Upvotes: 4

Related Questions