Migrating Schemas

Even though MongoDB is schemaless, introducing some kind of object mapper means that your object definitions become your schema. You may have a situation where you rename a property in your object model but need to load values from older documents where the field is still using the former name. While you could use MongoDB's $rename operator to migrate everything, sometimes a lazy migration is preferable. Doctrine offers a few different methods for dealing with this problem!

Note

The features in this chapter were inspired by Objectify, an object mapper for the Google App Engine datastore. Additional information may be found in the Objectify schema migration documentation.

Renaming a Field

Let's say you have a simple document that starts off with the following fields:

<?php

/** @Document */
class Person
{
    /** @Id */
    public $id;

    /** @Field(type="string") */
    public $name;
}

Later on, you need rename name to fullName; however, you'd like to hydrate fullName from name if the new field doesn't exist.

<?php

/** @Document */
class Person
{
    /** @Id */
    public $id;

    /** @Field(type="string") @AlsoLoad("name") */
    public $fullName;
}

When a Person is loaded, the fullName field will be populated with the value of name if fullName is not found. When the Person is persisted, this value will then be stored in the fullName field.

Caution

A caveat of this feature is that it only affects hydration. Queries will not know about the rename, so a query on fullName will only match documents with the new field name. You can still query using the name field to find older documents. The $or query operator could be used to match both.

Transforming Data

You may have a situation where you want to migrate a Person's name to separate firstName and lastName fields. This is also possible by specifying the @AlsoLoad annotation on a method, which will then be invoked immediately before normal hydration.

<?php

/** @Document @HasLifecycleCallbacks */
class Person
{
    /** @Id */
    public $id;

    /** @Field(type="string") */
    public $firstName;

    /** @Field(type="string") */
    public $lastName;

    /** @AlsoLoad({"name", "fullName"}) */
    public function populateFirstAndLastName($fullName)
    {
        list($this->firstName, $this->lastName) = explode(' ', $fullName);
    }
}

The annotation is defined with one or a list of field names. During hydration, these fields will be checked in order and, for each field present, the annotated method will be invoked with its value as a single argument. Since the firstName and lastName fields are mapped, they would then be updated when the Person was persisted back to MongoDB.

Unlike lifecycle callbacks, the @AlsoLoad method annotation does not require the @HasLifecycleCallbacks class annotation to be present.

Moving Fields

Migrating your schema can be a difficult task, but Doctrine provides a few different methods for dealing with it:

  • @AlsoLoad - load values from old fields or transform data through methods
  • @NotSaved - load values into fields without saving them again
  • @PostLoad - execute code after all fields have been loaded
  • @PrePersist - execute code before your document gets saved

Imagine you have some address-related fields on a Person document:

<?php

/** @Document */
class Person
{
    /** @Id */
    public $id;

    /** @Field(type="string") */
    public $name;

    /** @Field(type="string") */
    public $street;

    /** @Field(type="string") */
    public $city;
}

Later on, you may want to migrate this data into an embedded Address document:

<?php

/** @EmbeddedDocument */
class Address
{
    /** @Field(type="string") */
    public $street;

    /** @Field(type="string") */
    public $city;

    public function __construct($street, $city)
    {
        $this->street = $street;
        $this->city = $city;
    }
}

/** @Document @HasLifecycleCallbacks */
class Person
{
    /** @Id */
    public $id;

    /** @Field(type="string") */
    public $name;

    /** @NotSaved */
    public $street;

    /** @NotSaved */
    public $city;

    /** @EmbedOne(targetDocument="Address") */
    public $address;

    /** @PostLoad */
    public function postLoad()
    {
        if ($this->street !== null || $this->city !== null)
        {
            $this->address = new Address($this->street, $this->city);
        }
    }
}

Person's street and city fields will be hydrated, but not saved. Once the Person has loaded, the postLoad() method will be invoked and construct a new Address object, which is mapped and will be persisted.

Alternatively, you could defer this migration until the Person is saved:

<?php

/** @Document @HasLifecycleCallbacks */
class Person
{
    // ...

    /** @PrePersist */
    public function prePersist()
    {
        if ($this->street !== null || $this->city !== null)
        {
            $this->address = new Address($this->street, $this->city);
        }
    }
}

The @HasLifecycleCallbacks annotation must be present on the class in which the method is declared for the lifecycle callback to be registered.

Fork me on GitHub