Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
4
Help us understand the problem. What is going on with this article?
@naoki85

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

More than 3 years have passed since last update.

はじめに

前回の続きです。
今回はRails Tutorialの6章をやりたいと思います。
大部分省略していますが、ご了承ください。

また、CakePHPっぽくない、というご指摘ありましたらコメントいただけますと幸いです。

マイグレーションの設定

CakePHPはPhinxをラップしてマイグレーションクラスを作成しているのですね。
CakePHPのマイグレーションドキュメントを読んでも分からないときは、Phinxの方を読んでみると良いかと思います。

プラグインのロード

まずは、プラグインをロードする記述を追記します。

config/bootstrap.php
Plugin::load('Migrations');

テストDBの設定

フィクスチャを使用しますが、テストDBの設定をしておかないとテスト時にエラーが起こるので、作成しておきます。
Cloud9でも普通にDBは作成することができるので、testというDBを作成しておきます。

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を許可しないことを追記するくらいです。

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

テストコードの編集

まずはテストコードを編集します。
(テストコードのうまい書き方はまだ良く分かりません。。。)

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はテスト終了後のクリーンアップを担当してくれます。
テスト終了後に何かしたければ、こちらに記載すると良いかと思います。

nameemailともに、

  • 空文字は不可。
  • 最大文字長を設定。(nameが50文字、emailが255文字)

とします。
これでテストをすると、REDになります。

モデルの編集

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

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種類あるとのことです。
バリデーション
こちらの方の記事が分かりやすかったです。
【CakePHP3】バリデーション・アプリケーションルールについて

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

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

buildRulesというのが保存前に実行されるルールですが、ここにisUniqueが入るようです。
上記に従い、文字長はバリデーションの方に入れました。

カスタムバリデーション

メールアドレスは正しいフォーマットかバリデーションをかけたいと思います。
まずはテストを追加します。
(本当は正しい方も記載した方が良いかと思います。)

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の受け売りです。。。

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のバリデーションルールに設定します。

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

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

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

Emailのユニーク設定

メールアドレスの保存をユニークにします。
すでにモデル生成時にbuildRulesに記載がありますが、DBのusersテーブルにはインデックスが貼られていないので、マイグレーションを再度使用します。

$ bin/cake bake migration AddIndexToUsersEmail

生成されたファイルを編集し、ユニークインデックスを貼るようにします。

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を使用します。
保存前に呼ばれます。

src/Model/Table/UsersTable.php
+ public function beforeSave($event, $entity, $options)
+ {   
+     $entity->email = mb_strtolower($entity->email);   
+     return;
+ }

$entityにプロパティが入ってくるようなので、そちらからemailを取り出して小文字にした後、再度代入します。
テストコードも追記しておきます。
先ほどのユニークテストと合わせて記載します。

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
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

$ 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モデルはpasswordpassword_confirmationの値を受け付け、一致していたらpassword_digestにハッシュ化した値を代入するようにしたいと思います。

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

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

フィクスチャの修正

カラムを追記したので、フィクスチャを修正する必要があります。
とはいえ、テーブルを変更するたびにいちいち更新するのは面倒です。
CakePHPのドキュメントにそんなときはこうしろ、というような記載がありました。
テーブル情報のインポート

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

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'
        ],
    ];
}

テストコードの作成

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を使用します。
データのハッシュ化

使用宣言を追記します。

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のタイミングでは確認していません。

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章のユーザー登録をやっていきたいと思います。
次回は少し遅くなるかもしれません。
できました→その5 ユーザー登録

4
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
naoki85
Web系のエンジニアです。 RubyやPHPを主に書いています。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
4
Help us understand the problem. What is going on with this article?