CakePHPでRails Tutorialをやってみる〜その４ ユーザーのモデルを作成する〜

# はじめに

* [その１ Cloud9の設定](http://qiita.com/naoki85/items/f4bfabc941381ca5ff35)
* [その２ ほぼ静的なページの作成](http://qiita.com/naoki85/items/0b7e7ea106a1c661ed35)
* [その３ レイアウトの作成](http://qiita.com/naoki85/items/d4bc9e333f2f5b2bcff3)

前回の続きです。
今回は[Rails Tutorial](https://railstutorial.jp/chapters/modeling_users?version=4.2#cha-modeling_users)の6章をやりたいと思います。
大部分省略していますが、ご了承ください。

また、CakePHPっぽくない、というご指摘ありましたらコメントいただけますと幸いです。
# マイグレーションの設定
CakePHPは[Phinx](http://docs.phinx.org/en/latest/index.html)をラップしてマイグレーションクラスを作成しているのですね。
[CakePHPのマイグレーションドキュメント](https://book.cakephp.org/3.0/ja/migrations.html)を読んでも分からないときは、[Phinx](http://docs.phinx.org/en/latest/index.html)の方を読んでみると良いかと思います。
## プラグインのロード
まずは、プラグインをロードする記述を追記します。

```php:config/bootstrap.php
Plugin::load('Migrations');
```
## テストDBの設定
フィクスチャを使用しますが、テストDBの設定をしておかないとテスト時にエラーが起こるので、作成しておきます。
Cloud9でも普通にDBは作成することができるので、`test`というDBを作成しておきます。

```diff:config/app.php
'test' => [
    'className' => 'Cake\Database\Connection',
    'driver' => 'Cake\Database\Driver\Mysql',
    'persistent' => false,
    'host' => 'localhost',
    //'port' => 'non_standard_port_number',
-   'username' => 'my_app',
-   'password' => 'secret',
-   'database' => 'test_myapp',
+   'username' => getenv('C9_USER'),
+   'password' => '',
+   'database' => 'test',
    'encoding' => 'utf8',
-   'timezone' => 'UTC',
+   'timezone' => '+09:00',
    'cacheMetadata' => true,
```
# マイグレーションファイルの作成と実行
マイグレーションファイルは下記のコマンドで作成することができます。
今回は`users`テーブルを作成したいと思います。

```
$ bin/cake bake migration CreateUsers name:string email:string created modified
```
これで`config/Migrations`にタイムスタンプ付きのマイグレーションファイルが作成されます。
この中身を編集し、下記のようにします。
`NULL`を許可しないことを追記するくらいです。

```php:config/Migrations/xxxxxxxxxxxxxx_CreateUsers.php
<?php
use Migrations\AbstractMigration;

class CreateUsers extends AbstractMigration
{
    /**
     * Change Method.
     *
     * More information on this method is available here:
     * http://docs.phinx.org/en/latest/migrations.html#the-change-method
     * @return void
     */
    public function change()
    {
        $table = $this->table('users');
        $table->addColumn('name', 'string', [
            'default' => null,
            'limit' => 255,
            'null' => false,
        ]);
        $table->addColumn('email', 'string', [
            'default' => null,
            'limit' => 255,
            'null' => false,
        ]);
        $table->addColumn('created', 'datetime', [
            'default' => null,
            'null' => false,
        ]);
        $table->addColumn('modified', 'datetime', [
            'default' => null,
            'null' => false,
        ]);
        $table->create();
    }
}
```
その後、下記のコマンドで実行します。

```
$ bin/cake migrations migrate
```
これで特にエラーが起きなければ、テーブルが作成できていると思います。

```
mysql> DESC users;
+----------+--------------+------+-----+---------+----------------+
| Field    | Type         | Null | Key | Default | Extra          |
+----------+--------------+------+-----+---------+----------------+
| id       | int(11)      | NO   | PRI | NULL    | auto_increment |
| name     | varchar(255) | NO   |     | NULL    |                |
| email    | varchar(255) | NO   |     | NULL    |                |
| created  | datetime     | NO   |     | NULL    |                |
| modified | datetime     | NO   |     | NULL    |                |
+----------+--------------+------+-----+---------+----------------+
5 rows in set (0.00 sec)
```
# ユーザーモデルの作成
テーブルは作成できたので、次はモデルを作成します。
折角なので、`bake`コマンドで作成します。

```
$ bin/cake bake model Users
# 下記のファイルが生成されます。
# src/Model/Entity/User.php
# src/Model/Table/UsersTable.php
# tests/Fixture/UsersFixture.php
# tests/TestCase/Table/UsersTableTest.php
```
## テストコードの編集
まずはテストコードを編集します。
（テストコードのうまい書き方はまだ良く分かりません。。。）

```php:tests/TestCase/Table/UsersTableTest.php
<?php
namespace App\Test\TestCase\Model\Table;

use App\Model\Table\UsersTable;
use Cake\ORM\TableRegistry;
use Cake\TestSuite\TestCase;

/**
 * App\Model\Table\UsersTable Test Case
 */
class UsersTableTest extends TestCase
{

    /**
     * Test subject
     *
     * @var \App\Model\Table\UsersTable
     */
    public $Users;
    private $data;

    /**
     * Fixtures
     *
     * @var array
     */
    public $fixtures = [
        'app.users'
    ];

    /**
     * setUp method
     *
     * @return void
     */
    public function setUp()
    {
        parent::setUp();
        $config = TableRegistry::exists('Users') ? [] : ['className' => UsersTable::class];
        $this->Users = TableRegistry::get('Users', $config);
        $this->data = ['name' => 'Example User', 'email' => 'user@example.com'];
    }

    /**
     * tearDown method
     *
     * @return void
     */
    public function tearDown()
    {
        unset($this->Users);

        parent::tearDown();
    }
    
    public function testValidation() {
        $user = $this->Users->newEntity($this->data);
        $this->assertEmpty($user->errors());
    }
    
    public function testValidationOfName() {
        $this->data['name'] = '';
        $user = $this->Users->newEntity($this->data);
        $this->assertNotEmpty($user->errors());
        
        $this->data['name'] = str_repeat('a', 51);
        $user = $this->Users->newEntity($this->data);
        $this->assertNotEmpty($user->errors());
    }
    
    public function testValidationOfEmail()
    {
        $this->data['email'] = '';
        $user = $this->Users->newEntity($this->data);
        $this->assertNotEmpty($user->errors());
        
        $this->data['email'] = sprintf("%s@example.com", str_repeat('a', 244));
        $user = $this->Users->newEntity($this->data);
        $this->assertNotEmpty($user->errors());
    }
}
```
`setUp`は分かりやすく、テスト前に実行してくれる共通処理を記載します。
`tearDown`はテスト終了後のクリーンアップを担当してくれます。
テスト終了後に何かしたければ、こちらに記載すると良いかと思います。

`name`、`email`ともに、

* 空文字は不可。
* 最大文字長を設定。（`name`が50文字、`email`が255文字）

とします。
これでテストをすると、REDになります。
## モデルの編集
CakePHP 3にはモデルの中に **Table** と **Entity** があります。
両者の違いは現段階では良く分かっていません。
（Rubyの比較的新しいFWであるHanamiも、分かれていたような気が。。。）
とりあえず、今回バリデーションまわりはTableの方に記載していきます。

```php:src/Model/Table/UsersTable.php
<?php
namespace App\Model\Table;

use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;
use Cake\Auth\DefaultPasswordHasher;

/**
 * Users Model
 *
 * @method \App\Model\Entity\User get($primaryKey, $options = [])
 * @method \App\Model\Entity\User newEntity($data = null, array $options = [])
 * @method \App\Model\Entity\User[] newEntities(array $data, array $options = [])
 * @method \App\Model\Entity\User|bool save(\Cake\Datasource\EntityInterface $entity, $options = [])
 * @method \App\Model\Entity\User patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
 * @method \App\Model\Entity\User[] patchEntities($entities, array $data, array $options = [])
 * @method \App\Model\Entity\User findOrCreate($search, callable $callback = null, $options = [])
 *
 * @mixin \Cake\ORM\Behavior\TimestampBehavior
 */
class UsersTable extends Table
{

    /**
     * Initialize method
     *
     * @param array $config The configuration for the Table.
     * @return void
     */
    public function initialize(array $config)
    {
        parent::initialize($config);

        $this->setTable('users');
        $this->setDisplayField('name');
        $this->setDisplayField('email');
        $this->setDisplayField('password');
        $this->setDisplayField('password_confirmation');
        $this->setPrimaryKey('id');

        $this->addBehavior('Timestamp');
    }

    /**
     * Default validation rules.
     *
     * @param \Cake\Validation\Validator $validator Validator instance.
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator)
    {
        $validator
            ->integer('id')
            ->allowEmpty('id');

        $validator
            ->scalar('name')
            ->requirePresence('name')
            ->notEmpty('name')
            ->add('name', [
                'length' => [
                    'rule' => ['maxLength', 50],
                    'message' => 'Name need to be at most 50 characters long',
                ]
            ]);

        $validator
            ->email('email')
            ->requirePresence('email')
            ->notEmpty('email')
            ->add('email', [
                'length' => [
                    'rule' => ['maxLength', 255],
                    'message' => 'Email need to be at most 255 characters long',
                ]
            ]);

        return $validator;
    }

    /**
     * Returns a rules checker object that will be used for validating
     * application integrity.
     *
     * @param \Cake\ORM\RulesChecker $rules The rules object to be modified.
     * @return \Cake\ORM\RulesChecker
     */
    public function buildRules(RulesChecker $rules)
    {
        $rules->add($rules->isUnique(['email']));

        return $rules;
    }
}
```
CakePHPにはバリデーションが2種類あるとのことです。
[バリデーション](https://book.cakephp.org/3.0/ja/core-libraries/validation.html)
こちらの方の記事が分かりやすかったです。
[【CakePHP3】バリデーション・アプリケーションルールについて](http://qiita.com/o0h/items/edf1d6821d84ae83495b)

上記の記事を引用させていただきますと、

> 具体例を挙げると、
* 「emailは必須項目」はvalidationに
* 正規表現を用いた「email形式であること」は validationに
* Databaseの内容にまつわる「emailがまだ利用されていないこと(isUnique)」はapplication rulesに

`buildRules`というのが保存前に実行されるルールですが、ここに`isUnique`が入るようです。
上記に従い、文字長はバリデーションの方に入れました。
# カスタムバリデーション
メールアドレスは正しいフォーマットかバリデーションをかけたいと思います。
まずはテストを追加します。
（本当は正しい方も記載した方が良いかと思います。）

```diff:tests/TestCase/Table/UsersTableTest.php
public function testValidationOfEmail()
{
    // ...

+   $invalid_emails = ['user@example,com', 'user_at_foo.org', 'user.name@example.',
+                      'foo@bar_baz.com', 'foo@bar+baz.com'];
+   foreach ($invalid_emails as $email) {
+       $this->data['email'] = $email;
+       $user = $this->Users->newEntity($this->data);
+       $this->assertNotEmpty($user->errors());
+   }
}
```
REDになることを確認したら、モデルの方にフォーマットのバリデーションを追記します。
正規表現は[Rails Tutorial](https://railstutorial.jp/chapters/modeling_users?version=4.2#sec-format_validation)の受け売りです。。。

```diff:src/Model/Table/UsersTable.php
+ public static function validateMailFormat($value)
+ {
+       return (bool) preg_match("/\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i", $value);
+ }
```
フォーマットを確認するときには、自作の関数を作成することが一般的なようです。
本来はカスタムバリデートクラスを作った方が良いのかもしれませんが、`UsersTable`に記載しました。
あとはこの関数を`email`のバリデーションルールに設定します。

```diff:src/Model/Table/UsersTable.php
$validator
    ->email('email')
    ->requirePresence('email')
    ->notEmpty('email')
    ->add('email', [
        'length' => [
            'rule' => ['maxLength', 255],
            'message' => 'Email need to be at most 255 characters long',
        ]
    ])
+   ->add('email', 'custom', [
+       'rule' => [$this, 'validateMailFormat'],
+       'message' => 'Invalid mail format'
+   ]);
```
`custom`はルール名になります。（このあたりは少し怪しいですが。。。）
`rule`の配列にて`$this`を設定してあげることで、このモデル内のメソッドを探しにいきます。
この書き方がよく分からず、少し詰まりました。

これでテストの方はGREENになるかと思います。

## 9/4 追記
icchii様からご指摘をいただきましたが、Eメールのフォーマットバリデーションは、`email`で実施されているようです。
ソースコードはこちらに載っておりました。
[Validation/Validation.php](https://api.cakephp.org/3.5/source-class-Cake.Validation.Validation.html#626)

そのため、カスタムフォーマットは削除し、`email`メソッドに任せることにしました。

```diff:src/Model/Table/UsersTable.php
$validator
    ->email('email')
    ->requirePresence('email')
    ->notEmpty('email')
    ->add('email', [
        'length' => [
            'rule' => ['maxLength', 255],
            'message' => 'Email need to be at most 255 characters long',
        ]
    ]);
```
こちらに関する差分は、下記のリンクをご参照ください。
[https://github.com/naoki85/cakephp_de_rails_tutorial/commit/b895b00936b90a34188d6462404fab6112f535b7](https://github.com/naoki85/cakephp_de_rails_tutorial/commit/b895b00936b90a34188d6462404fab6112f535b7)
# Emailのユニーク設定
メールアドレスの保存をユニークにします。
すでにモデル生成時に`buildRules`に記載がありますが、DBの`users`テーブルにはインデックスが貼られていないので、マイグレーションを再度使用します。

```
$ bin/cake bake migration AddIndexToUsersEmail
```
生成されたファイルを編集し、ユニークインデックスを貼るようにします。

```php:config/Migrations/xxxxxxxxxxxxxx_AddIndexToUsersEmail.php
<?php
use Migrations\AbstractMigration;

class AddIndexToUsersEmail extends AbstractMigration
{
    /**
     * Change Method.
     *
     * More information on this method is available here:
     * http://docs.phinx.org/en/latest/migrations.html#the-change-method
     * @return void
     */
    public function change()
    {
        $table = $this->table('users');
        $table->addIndex(['email'], ['unique' => true, 'name' => 'idx_users_email']);
        $table->update();
    }
}
```
再度マイグレーションを実行します。

```
$ bin/cake migrations migrate
```
## メールアドレスを保存時に小文字にする
大文字の場合と小文字の場合があると面倒なので、保存時に小文字にします。
これもモデルのコールバックメソッドである`beforeSave`を使用します。
保存前に呼ばれます。

```diff:src/Model/Table/UsersTable.php
+ public function beforeSave($event, $entity, $options)
+ {   
+     $entity->email = mb_strtolower($entity->email);   
+     return;
+ }
```
`$entity`にプロパティが入ってくるようなので、そちらから`email`を取り出して小文字にした後、再度代入します。
テストコードも追記しておきます。
先ほどのユニークテストと合わせて記載します。

```diff:tests/TestCase/Table/UsersTableTest.php
+ public function testApplicationRulesOfEmail()
+ {
+     $user = $this->Users->newEntity($this->data);
+     $user_clone = clone $user;
+     $this->Users->save($user);
+     $this->assertEquals(false, $this->Users->save($user_clone));
+ }
+ 
+ public function testBeforeSave()
+ {
+     $user = $this->Users->newEntity($this->data);
+     $this->data['email'] = 'USER@example.com';
+     $user_clone = $this->Users->newEntity($this->data);
+     $this->Users->save($user);
+     $this->assertEquals(false, $this->Users->save($user_clone));
+ }
```
初めて使ったのですが、`clone`でオブジェクトのクローンを作成できるのですね。
初めのテストは、

* Userモデルのクローンを作成
* 先に片方のUserを保存する。
* もう一方が保存できないことを確認する。

次のテストも似たものですが、メールアドレスを大文字にしても保存できないことを確認しているつもりです。
# パスワードのカラムを追加
ユーザーのログイン機能を実装するにあたり、パスワードのカラムを`users`テーブルに追加します。
このあたりから、少しオレオレコードになってきてしまいました。。。
## マイグレーションファイルの作成
テーブルにはハッシュ化したパスワードを保存したいので、カラム名を`password_digest`とします。

```
$ bin/cake bake migration AddColumnToUsersPasswordDigest
```
```php:config/Migrations/xxxxxxxxxxxxxx_AddColumnToUsersPasswordDigest.php
<?php
use Migrations\AbstractMigration;

class AddColumnToUsersPasswordDigest extends AbstractMigration
{
    /**
     * Change Method.
     *
     * More information on this method is available here:
     * http://docs.phinx.org/en/latest/migrations.html#the-change-method
     * @return void
     */
    public function change()
    {
        $table = $this->table('users');
        $table->addColumn('password_digest', 'string', [
            'default' => null,
            'limit'   => 255,
            'null'    => false,
            'after'   => 'email']);
        $table->update();
    }
}
```
`after`をつければ指定したカラムの後に追加できます。
[Adding a Column After Another Column](http://docs.phinx.org/en/latest/migrations.html#adding-a-column-after-another-column)

```
$ bin/cake migrations migrate
```
こんな感じのテーブルになります。

```
mysql> desc users;
+-----------------+--------------+------+-----+---------+----------------+
| Field           | Type         | Null | Key | Default | Extra          |
+-----------------+--------------+------+-----+---------+----------------+
| id              | int(11)      | NO   | PRI | NULL    | auto_increment |
| name            | varchar(255) | NO   |     | NULL    |                |
| email           | varchar(255) | NO   | UNI | NULL    |                |
| password_digest | varchar(255) | NO   |     | NULL    |                |
| created         | datetime     | NO   |     | NULL    |                |
| modified        | datetime     | NO   |     | NULL    |                |
+-----------------+--------------+------+-----+---------+----------------+
6 rows in set (0.01 sec)
```
##  モデルの修正
`Users`モデルは`password`と`password_confirmation`の値を受け付け、一致していたら`password_digest`にハッシュ化した値を代入するようにしたいと思います。

まずはバリデーションの追記から。

```diff:src/Model/Table/UsersTable.php
class UsersTable extends Table
{
    // ...

    public function validationDefault(Validator $validator)
    {
        // ...
            
+       $validator
+           ->scalar('password')
+           ->requirePresence('password')
+           ->notEmpty('password')
+           ->add('password', [
+               'length' => [
+                   'rule' => ['minLength', 6],
+                   'message' => 'Email need to be at least 6 characters long',
+               ]
+           ]);
+           
+       $validator
+           ->add('password_confirmation', [
+               'compare' => [
+                   'rule' => ['compareWith', 'password'],
+                   'message' => 'Not correct password',
+               ]
+           ]);

        return $validator;
    }
}
```
CakePHPには`compareWith`というバリデーションがあり、それで対象のカラムと比較することができます。
[CakePHP 3 - Compare passwords](https://stackoverflow.com/questions/28401893/cakephp-3-compare-passwords)
## フィクスチャの修正
カラムを追記したので、フィクスチャを修正する必要があります。
とはいえ、テーブルを変更するたびにいちいち更新するのは面倒です。
CakePHPのドキュメントにそんなときはこうしろ、というような記載がありました。
[テーブル情報のインポート](https://book.cakephp.org/3.0/ja/development/testing.html#id16)

`public $import = ['model' => 'Users'];`と記載することで、スキーマをインポートしてくれるそうです。
そのため、こんな感じになりました。

```php:tests/Fixture/UsersFixture.php
<?php
namespace App\Test\Fixture;

use Cake\TestSuite\Fixture\TestFixture;

/**
 * UsersFixture
 *
 */
class UsersFixture extends TestFixture
{

    public $import = ['model' => 'Users'];

    /**
     * Records
     *
     * @var array
     */
    public $records = [
        [
            'id' => 1,
            'name' => 'test_name',
            'email' => 'test@example.com',
            'password_digest' => '',
            'created' => '2017-08-31 22:42:05',
            'modified' => '2017-08-31 22:42:05'
        ],
    ];
}
```
## テストコードの作成

```diff:tests/TestCase/Model/Table/UsersTableTest.php
// ...

    public function setUp()
    {
        parent::setUp();
        $config = TableRegistry::exists('Users') ? [] : ['className' => UsersTable::class];
        $this->Users = TableRegistry::get('Users', $config);
-       $this->data = ['name' => 'Example User', 'email' => 'user@example.com'];
+       $this->data = ['name' => 'Example User', 'email' => 'user@example.com',
+           'password' => 'foobar', 'password_confirmation' => 'foobar'];
    }

// ...

+   public function testValidationOfPasswordAndPasswordConfirmation()
+   {
+       $this->data['password'] = '';
+       $user = $this->Users->newEntity($this->data);
+       $this->assertNotEmpty($user->errors());
+       
+       $this->data['password'] = str_repeat('a', 5);
+       $user = $this->Users->newEntity($this->data);
+       $this->assertNotEmpty($user->errors());
+       
+       $this->data['password_confirmation'] = 'hogehoge';
+       $user = $this->Users->newEntity($this->data);
+       $this->assertNotEmpty($user->errors());
+   }

// ...
```
# パスワードをハッシュ化する
次は保存前にハッシュ化するところです。
ハッシュ化ルールは、CakePHPに備わっている`DefaultPasswordHasher`を使用します。
[データのハッシュ化](https://book.cakephp.org/3.0/ja/core-libraries/security.html#id5)

使用宣言を追記します。

```diff:src/Model/Table/UsersTable.php
 use Cake\ORM\Query;
 use Cake\ORM\RulesChecker;
 use Cake\ORM\Table;
 use Cake\Validation\Validator;
+use Cake\Auth\DefaultPasswordHasher;
```
`beforeSave`にて、`password`をハッシュ化して`password_digest`に代入します。
バリデーション側で一致しているものとみなし、`beforeSave`のタイミングでは確認していません。

```diff:src/Model/Table/UsersTable.php
public function beforeSave($event, $entity, $options)
{
+   $hasher = new DefaultPasswordHasher();
+   $entity->password_digest = $hasher->hash($entity->password);
    
    $entity->email = mb_strtolower($entity->email);
    
    return;
}
```
これでテストコード内に`debug`なんかを仕込んで確認すると、パスワードがハッシュ化されていることが分かります。

```
object(App\Model\Entity\User) {

    'id' => (int) 2,
    'name' => 'Example User',
    'email' => 'user@example.com',
    'password_digest' => '$2y$10$EvwaxXK4ctPq5VVBW1i5s.YEPqG9GSgPdTF8Ny0O4cMBmhyWA8GEK',
    'created' => object(Cake\I18n\FrozenTime) {

            'time' => '2017-09-01T20:06:53+09:00',
            'timezone' => 'Asia/Tokyo',
            'fixedNowTime' => false

    },
    // ...
}
```
# さいごに
なかなか素直にいかず、四苦八苦しております。
もっと良い書き方などがあれば、ぜひコメントいただければ、と思います。

次回は7章のユーザー登録をやっていきたいと思います。
次回は少し遅くなるかもしれません。
できました→[その５ ユーザー登録](http://qiita.com/naoki85/items/aa92310d4b165b3d7717)