Search code examples
mongodbsymfonydoctrine-ormjmsserializerbundlejms-serializer

Gedmo Timestampable always updating reference when deserializing with JMS Serializer


I have a one-to-one relationship in my Symfony2 Project where a Question has a reference to a Video - both have a created and updated Gedmo\Timestampable behaviour which is basically working as expected. But a little bit too much:

When deserializing the Question with the attached Video (as ID only to avoid other changes in the Video metadata) the Video document always gets an update both on the created and the updated field. This doesn't seem right. I may understand why the updated field gets a new date - even if actually nothing has changed on the object itself, but why created ?

This is my code (simplyfied):

Class Question:

<?php

/**
 * Class Question
 *
 * @Serializer\AccessorOrder("alphabetical")
 * @MongoDB\Document(
 *   collection="Quiz",
 *   repositoryClass="MyNamespace\Bundle\QuizBundle\Repository\QuestionRepository",
 * )
 * @package MyNamespace\Bundle\QuizBundle\Document
 */
class Question
{
    /**
     * @var \MongoId
     * @MongoDB\Id(strategy="auto")
     * @Serializer\Type("string")
     * @Serializer\Groups({
     *   "quiz_admin_list",
     *   "quiz_admin_detail"
     * })
     */
    protected $id;

    /**
     * @var \DateTime
     *
     * @Assert\Date(
     *   message = "quiz:constraints.model.question.created.invalid"
     * )
     * @Serializer\Type("DateTime<'U'>")
     * @Serializer\Accessor(getter="getCreated", setter="setCreatedEmpty")
     * @Serializer\Groups({
     *   "quiz_admin_list",
     *   "quiz_admin_detail"
     * })
     * @Gedmo\Timestampable(on="create")
     * @MongoDB\Date
     */
    protected $created;


    /**
     * @var \DateTime
     *
     * @Assert\Date(
     *   message = "quiz:constraints.model.question.updated.invalid"
     * )
     * @Serializer\Type("DateTime<'U'>")
     * @Serializer\Accessor(getter="getUpdated", setter="setUpdatedEmpty")
     * @Serializer\Groups({
     *   "quiz_admin_list",
     *   "quiz_admin_detail"
     * })
     * @Gedmo\Timestampable(on="update")
     * @MongoDB\Date
     */
    protected $updated;

    /**
     * @var Video
     *
     * @Serializer\Type("MyNamespace\Bundle\CoreMediaAdminBundle\Document\Video")
     * @Serializer\Groups({
     *   "quiz_admin_list",
     *   "quiz_admin_detail"
     * })
     * @MongoDB\ReferenceOne(
     *   targetDocument="MyNamespace\Bundle\CoreMediaAdminBundle\Document\Video",
     *   cascade={"all"}
     * )
     */
    protected $answerVideo;

}

Class Video:

<?php

/**
 * Class Video
 * @Serializer\AccessorOrder("alphabetical")
 * @MongoDB\Document(
 *   collection="CoreMediaAdminVideo",
 *   repositoryClass="MyNamespace\Bundle\CoreMediaAdminBundle\Repository\VideoRepository",
 * )
 * @Vich\Uploadable
 * @package MyNamespace\Bundle\CoreMediaAdminBundle\Document
 */
class Video 
{

    /**
     * @MongoDB\Id(strategy="auto")
     * @Serializer\Type("string")
     * @Serializer\Groups({
     *   "core_media_list",
     *   "core_media_search",
     *   "core_media_video_list",
     *   "core_media_video_detail"
     * })
     */
    protected $id;

    /**
     * @Vich\UploadableField(
     *   mapping = "core_media_admin_video",
     *   fileNameProperty = "fileName"
     * )
     * @Serializer\Exclude
     * @var File $file
     */
    protected $file;

    /**
     * @MongoDB\Field(type="string")
     * @Serializer\Type("string")
     * @Serializer\Groups({
     *   "core_media_list",
     *   "core_media_search",
     *   "core_media_video_list",
     *   "core_media_video_detail"
     * })
     */
    protected $mimeType;

    /**
     * @var String
     *
     * @Assert\NotBlank(
     *   message = "core.media.admin:constraints.model.base.title.not_blank"
     * )
     * @Serializer\Type("string")
     * @Serializer\Groups({
     *   "core_media_list",
     *   "core_media_search",
     *   "core_media_video_list",
     *   "core_media_video_detail"
     * })
     * @MongoDB\Field(type="string")
     */
    protected $title;

    /**
     * @var \DateTime
     *
     * @Assert\Date(
     *   message = "core.media.admin:constraints.model.base.date.invalid"
     * )
     * @Serializer\Type("DateTime<'U'>")
     * @Serializer\Accessor(getter="getCreated", setter="setCreatedEmpty")
     * @Serializer\Groups({
     *   "core_media_list",
     *   "core_media_search",
     *   "core_media_video_list",
     *   "core_media_video_detail"
     * })
     * @Gedmo\Timestampable(on="create")
     * @MongoDB\Date
     */
    protected $created;

    /**
     * @var \DateTime
     *
     * @Assert\Date(
     *   message = "core.media.admin:constraints.model.base.date.invalid"
     * )
     * @Serializer\Type("DateTime<'U'>")
     * _Serializer\Accessor(getter="getUpdated", setter="setUpdatedEmpty")
     * @Serializer\Groups({
     *   "core_media_list",
     *   "core_media_search",
     *   "core_media_video_list",
     *   "core_media_video_detail"
     * })
     * @Gedmo\Timestampable(on="update")
     * @MongoDB\Date
     */
    protected $updated;

    /**
     * @var \DateTime
     *
     * @Assert\Date(
     *   message = "core.media.admin:constraints.model.base.date.invalid"
     * )
     * @Serializer\Type("DateTime<'U'>")
     * @Serializer\Groups({
     *   "core_media_list",
     *   "core_media_search",
     *   "core_media_video_list",
     *   "core_media_video_detail"
     * })
     * @Gedmo\Timestampable(on="update", field={"title", "tags", "comment", "dataOrigin", "description", "videoMetaData", "mimeType", "fileName", "file" })
     * @MongoDB\Date
     */
    protected $updatedContent;

}

The interesting thing is, no changes are made on Video objects during deserialization - there is only the update Query to set the created and updated fields of the Video.. I also tested the field parameter for Timestampable to force an update only when one of those fields get an update but this seems to be ignored completely.

Here is also the JSON which is deserialized and the corresponding MongoDB queries:

{
  "id": "547f31e650e56f2c26000063",
  "question_id": 12,
  "question_text": "Wer einen Gemüsegarten hat, sollte wissen, dass Schnecken…?",
  "answer_text": "test",
  "answer_video": {
    "id": "547f31d850e56f2c26000031"
  },
  "tags": [
    "Schnecken",
    "Basilikum",
    "Thymian",
    "Garten"
  ]
}

Queries:

db.QuizQuestion.find({
  "_id": ObjectId("547f31e650e56f2c26000063")
}).limit(1).limit();

db.CoreMediaAdminVideo.update({
  "_id": ObjectId("547f31d850e56f2c26000031")
},
{
  "$set": {
    "created": newISODate("2014-12-03T21:30:02+01:00"),
    "updated": newISODate("2014-12-03T21:30:02+01:00"),
    "updatedContent": newISODate("2014-12-03T21:30:02+01:00")
  }
});

db.ARDBuffetQuizQuestion.update({
  "_id": ObjectId("547f31e650e56f2c26000063")
},
{
  "$set": {
    "created": newISODate("2014-12-03T21:30:02+01:00"),
    "updated": newISODate("2014-12-03T21:30:02+01:00"),
    "questionText": "Wer einen Gemüsegarten hat, sollte wissen, dass Schnecken…?",
    "answerText": "test",
    "answerVideo": {
      "$ref": "CoreMediaAdminVideo",
      "$id": ObjectId("547f31d850e56f2c26000031"),
      "$db": "my-database"
    }
  }
});

db.ARDBuffetQuizQuestion.update({
  "_id": ObjectId("547f31e650e56f2c26000063")
},
{
  "$set": {
    "tags": [
      {
        "value": "Schnecken",
        "normalized": "schnecken"
      },
      {
        "value": "Basilikum",
        "normalized": "basilikum"
      },
      {
        "value": "Thymian",
        "normalized": "thymian"
      },
      {
        "value": "Garten",
        "normalized": "garten"
      }
    ]
  }
});

db.ARDBuffetQuizQuestion.find({
  "_id": ObjectId("547f31e650e56f2c26000063")
}).limit(1).limit();

db.CoreMediaAdminVideo.find({
  "_id": ObjectId("547f31d850e56f2c26000031")
}).limit(1).limit();

Solution

  • Gedmo\Timestampable will set (new) values for $created and $updated because that data isn't present when flushing the ObjectManager.

    Although the annotations in class Video define that $created and $updated should be included when serializing such an object, the JSON you show doesn't contain those keys/properties.

    Here's what happens:

    • The JSON doesn't contain the keys/properties created and updated.
    • When deserializing, the resulting object will have null values for $created and $updated.
    • When merge()-ing the object into the ObjectManager, nothing happens with it. The object simply becomes "managed", which means that during a flush, the ObjectManager will calculate the change-set for it and update it if necessary.
    • When flushing the ObjectManager, Gedmo\Timestampable will kick in (due to a PreUpdate event listener). It will see that $created and $updated contain null values, so it will assign new values.
    • Then the ObjectManager will retrieve the "current" data from the database, because it needs it to calculate the change-set. Normally, when find()-ing an object, it won't do this because the data is already retrieved during that find().
    • Because the values of $created and $updated are now different from those retrieved from the database, it will update them.

    So you'll have 2 options:

    1. find() the object first, then change it according to the JSON.
    2. Make sure the JSON contains all properties that are mapped (including created and updated).

    Also, I noticed setter="setCreatedEmpty" and setter="setUpdatedEmpty". I'm not sure what those methods do (because you don't show us), but the method-names indicate something other that simply assigning values.

    Answer to your comments

    When merge()-ing an object into the ObjectManager, it is marked as "dirty", which will trigger the calculation of a change-set. And because the reference of the DateTime objects has changed (the instances that Doctrine got from the db are always different from the instances that were created by deserializing JSON), the object will be updated. Then Gedmo\Timestampable will kick in and change the $updated property accordingly.

    If you don't want this to happen, you'll need to find() the current object, and only change value objects when the value they represent actually changed. Scalar values are no problem: you can set the same value and Doctrine won't see it as a change. But with value objects (like DateTime) objects, Doctrine will see a change when there reference changes (when a different instance is set).