はじめに
前回の続きです。
今回はRails Tutorialの6章をやりたいと思います。
大部分省略していますが、ご了承ください。
また、CakePHPっぽくない、というご指摘ありましたらコメントいただけますと幸いです。
マイグレーションの設定
CakePHPはPhinxをラップしてマイグレーションクラスを作成しているのですね。
CakePHPのマイグレーションドキュメントを読んでも分からないときは、Phinxの方を読んでみると良いかと思います。
プラグインのロード
まずは、プラグインをロードする記述を追記します。
Plugin::load('Migrations');
テストDBの設定
フィクスチャを使用しますが、テストDBの設定をしておかないとテスト時にエラーが起こるので、作成しておきます。
Cloud9でも普通にDBは作成することができるので、test
というDBを作成しておきます。
'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
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
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
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
が入るようです。
上記に従い、文字長はバリデーションの方に入れました。
カスタムバリデーション
メールアドレスは正しいフォーマットかバリデーションをかけたいと思います。
まずはテストを追加します。
(本当は正しい方も記載した方が良いかと思います。)
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の受け売りです。。。
+ public static function validateMailFormat($value)
+ {
+ return (bool) preg_match("/\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i", $value);
+ }
フォーマットを確認するときには、自作の関数を作成することが一般的なようです。
本来はカスタムバリデートクラスを作った方が良いのかもしれませんが、UsersTable
に記載しました。
あとはこの関数をemail
のバリデーションルールに設定します。
$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
メソッドに任せることにしました。
$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
生成されたファイルを編集し、ユニークインデックスを貼るようにします。
<?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
を使用します。
保存前に呼ばれます。
+ public function beforeSave($event, $entity, $options)
+ {
+ $entity->email = mb_strtolower($entity->email);
+ return;
+ }
$entity
にプロパティが入ってくるようなので、そちらからemail
を取り出して小文字にした後、再度代入します。
テストコードも追記しておきます。
先ほどのユニークテストと合わせて記載します。
+ 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
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
モデルはpassword
とpassword_confirmation
の値を受け付け、一致していたらpassword_digest
にハッシュ化した値を代入するようにしたいと思います。
まずはバリデーションの追記から。
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'];
と記載することで、スキーマをインポートしてくれるそうです。
そのため、こんな感じになりました。
<?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'
],
];
}
テストコードの作成
// ...
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
を使用します。
データのハッシュ化
使用宣言を追記します。
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
のタイミングでは確認していません。
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 ユーザー登録