Byron Adams


Ramblings about code, life and everything else.


The problem with using models in your migrations

Database migrations are an immensely useful part of any framework, they allow you to automatically update the schema and data of your application, and keep a record of the changes that have been made over time. In this post I’m specifically talking about migrations in Yii2.

In the past I’ve often found it useful to call upon my models to do some heavy lifting in my migrations, take the example below into consideration.

class m160305_012122_user_activation_hash extends \yii\db\Migration
{
public function up()
{
// Add column to table
$this->addColumn('user', 'activation_hash', $this->string(32));
// Generate hash for existing users
foreach (User::find()->all() as $user) {
$user->generateActivationHash();
$user->save();
}
}
public function down()
{
$this->dropColumn('user', 'activation_hash');
}
}

Here I’ve added a new column to my user table which contains a generated hash. This seems like a good solution on the surface, The code is fairly DRY and easy enough to understand.

The problem only arises later on when we add new columns to our user table

class m160305_012123_user_email extends \yii\db\Migration
{
public function up()
{
// Add email address column to user model
$this->addColumn('user', 'email_address', $this->string(150)->unique());
}
public function down()
{
$this->dropColumn('user', 'email_address');
}
}

Looks fine, right? it runs, and can be reversed. So whats the problem? Before I get to that.. I need to clarify my main assumption;

When adding a new column to a model, we not only create a migration, but we also update the validation rules on our model, preferably we regenerate a base model.

In the above example we would at least added some rules, whether they where generated or manually defined.

public function rules()
{
return [
[['email_address'], 'email'],
[['email_address'], 'unique'],
];
}

This is where the problem occurs, imagine a new developer joined the project, or you simply wanted to clean out your development environments, or deploy a new instance; all of the aforementioned scenarios have one thing in common, starting with a fresh database.

So you run yii migrate up it begins to apply migrations in the correct order and then..

*** applying m160305_012122_user_activation_hash
> add column activation_hash string(32) to table user ... done (time: 0.003s)
Exception 'yii\base\UnknownPropertyException' with message 'Getting unknown property: app\models\User::email_address'
in /vagrant/www/vendor/yiisoft/yii2/base/Component.php:143
Stack trace:
#0 /vagrant/www/vendor/yiisoft/yii2/db/BaseActiveRecord.php(247): yii\base\Component->__get('email_address')
#1 /vagrant/www/vendor/yiisoft/yii2/validators/Validator.php(237): yii\db\BaseActiveRecord->__get('email_address')
#2 /vagrant/www/vendor/yiisoft/yii2/base/Model.php(352): yii\validators\Validator->validateAttributes(Object(app\models\User), Array)

This is happening because calling $user->save() internally calls $user->validate() which then processes the validation rules we’ve defined for our model.

So you’re probably thinking; the fix for this is just to disable validation by calling $user->save(false) instead, that would fix this particular issue but other ones will quickly crop up.

For example, if your model takes advantage of events like ActiveRecord::EVENT_AFTER_FIND, ActiveRecord::EVENT_BEFORE_SAVE etc. It could be referencing columns that don’t exist while migrations are being applied and you will get a similar error as the one above.

In conclusion it’s best to avoid using your models in migrations as there could be unforeseen problems down the road.

Finally, here’s my proposed implementation of the original migration:

class m160305_012122_user_activation_hash extends \yii\db\Migration
{
public function up()
{
// Add column to table
$this->addColumn('user', 'activation_hash', $this->string(32));
// Generate a new hash for each user
$query = (new \yii\db\Query)->select('id')->from('user');
foreach ($query->each() as $row) {
$this->update(
'user',
['activation_hash' => Yii::$app->security->generateRandomString()],
['id' => $row['id']]
);
}
}
public function down()
{
$this->dropColumn('user', 'activation_hash');
}
}

This may not be as easy to read as the original, but it is decoupled from the ActiveRecord.