CakePHP3 には2つのバリデーションクラスがあり、管理したい項目によってこれらを使い分ける。大体のケースにおいて フロントやコントローラ内で項目1つ1つについて頑張る必要がなくなる ので是非利用していきたい。
Cake\ORM\RulesChecker
クラス- データの検証 - buildRules
- (カスタム)ルールチェッカーの作成
- Entity の状態をチェックするため フォーム提供せずシステム側で担保するべき項目のバリデーションで利用する
Cake\Validation\Validator
クラス- カスタムバリデーション
- 条件付きバリデーション
- Form から POST で渡る
$this->request->getData()
からnewEntity() / patchEntity()
時に動作 $entity->errors()
で「なにがなぜ弾かれているのか」分かり$this->save()
が false を返す- 上記エラー持ちエンティティをフォームに突っ込む
$this->Form->create($entity)
と$this->Form->control()
がメッセージ表示 - フォームによる操作をチェックするため フォーム提供しユーザに入力させる項目のバリデーションで利用する
// in Table
// データを保存する前の検査 (DBつながない)
public function validationDefault(Validator $validator)
{
$validator
->allowEmpty('id', 'create');
$validator
->requirePresence('name', 'create')
->notEmpty('name');
// 全角カナ制限したいケース
$validator
->requirePresence('name_phonetic', 'create')
->notEmpty('name_phonetic', __('Empty'))
->add('name_phonetic', 'zenkaku_kana', [
'rule' => function ($value, $context) {
return (bool)preg_match('/^[ァ-ヶ・ー ]+$/u', $value);
},
'message' => '全角カナで入力してください。',
]);
// メールアドレスの確認欄とのマッチングをさせるケース
$validator
->email('email_confirm')
->notEmpty('email_confirm')
->add('email_confirm', 'confirmEmail', [
'rule' => function ($value, $context) {
return ($value == $context['data']['email']);
},
'message' => __('メールアドレスが確認用メールアドレス欄と一致しません'),
]);
// 固定・携帯電話のどちらかを入力させたいケース
// - View 側で $this->Form->input('telephone, ['required'=>false]); する
// - サーバでジャッジする前にフロントで弾かれフォームサブミット自体できなくなる
// - Controller 側で if ($entity->errors()) $this->redirect($this->referer()); で戻してやる
// - ちなみに $this->Form->text(); などでフォーム出力するとエラーメッセージ表示 div が自動挿入されない
// - 上記理由で手動挿入せんとあかん場合は $this->Form->error() を添えてあげる
$validator
->notEmpty('telephone', '固定・携帯電話番号どちらかを入力してください。')
->allowEmpty('telephone', function ($context) {
return !empty($context['data']['cellular_phone']);
});
return $validator;
}
// 保存される/されているデータの整合性を保つ検査 (DBつなぐ)
public function buildRules(RulesChecker $rules)
{
// email のシステム全体での重複を許さない
$rules->add($rules->isUnique(['email']), [
'errorField' => 'email',
'message' => MSG_VALIDATE_DUPLICATE,
]);
// validateEmptyPhone
$validateEmptyPhone = function ($owner) {
return !(empty($owner->telephone) && empty($owner->cellular_phone));
};
$rules->add($validateEmptyPhone, [
'errorField' => 'cellular_phone',
'message' => '携帯電話/固定電話番号のどちらかは必須入力です。',
]);
return $rules;
}
// App\Model\Entity\User
/**
* Set Password via DefaultPasswordHasher
* Set hashed password through patchEntity() in CREATE & UPDATE .
* @param string $password
* @return string (hashed password)
*/
protected function _setPassword($password) {
return (new DefaultPasswordHasher)->hash($password);
}
/**
* Get formatted code.
* @param void
* @return string
*/
protected function getCode() {
return sprintf('%05d', $this->code);
}
// App\Model\Entity\User
protected $_virtual = ['full_name'];
/**
* Get User's full name.
* @return string $full_name
*/
protected function _getFullName() {
$full_name = $this->last_name.' '.$this->first_name;
return $full_name;
}
// App\Model\Entity\User
/**
* Check Password by DefaultPasswordHasher
* Older password verification on UPDATE.
* @param string $plainString
* @return bool true | false
*/
public function checkPassword(string $plainString='') {
if (empty($plainString)) return false;
return (new DefaultPasswordHasher)->check($plainString, $this->password);
}
if (!$article->isNew()) {
echo '既に保存されました!';
}
// すべての article を検索、この時点ではクエリーは走らない。
$query = $articles->find();
// イテレーションはクエリーを実行する
foreach ($query as $row) {
}
// all() の呼び出しはクエリーを実行し、結果セットを返す
$results = $query->all();
// isEmpty() で中身があるかどうか
if ($query->isEmpty()) echo '空です';
// count() とかで件数がとれる
echo 'データは'.$query->count().'件です';
// 結果セットがあれば すべての行を取得できる
$data = $results->toArray();
// クエリーから配列への変換はクエリーを実行する
$data = $query->toArray();
$query = $this->Users->find(); // クエリオブジェクトを準備
$userNum = $query->count(); // ここでは準備していたクエリに対する COUNT が返る
$user = $query->first(); // 吐かれるクエリは LIMIT 1 となる
// キー/値のペアを検索する
$query = $this->Articles->find('list');
$data = $query->toArray();
// $data = [
// 1 => '最初の投稿',
// 2 => '私が書いた2つ目の記事',
// ];
// toArray() と array_column を使って結果セットから ID 配列を生成
$managerUserIds = array_column($this->Users->find(['Users.role' => 'manager'])->toArray(), 'id');
// in controller
$terminals = $this->Terminals->find()
->where([
'event_id' => $event_id,
'NOT' => ['user_id IS' => null], // null は IS 必須
])
->contain('Users')
->all();
$users = [];
// http://www.chitana.me/entry/2016/12/03/164348
$conditions = ['Trees.is_initial' => false];
foreach ($trees as $tree) {
$conditions['OR'][] = [
'Trees.path LIKE' => $tree->path.'_.',
];
}
$results = $this->find()->where($conditions);
// IN
$users = TableRegistry::get('User')->find()
->where(['User.id IN' => [1, 2, 3]])
->all();
// NOT IN
$users = TableRegistry::get('User')->find()
->where([
'NOT' => ['User.id IN' => [1, 2, 3]]
])
->all();
// find() のオプションとして
$query = $articles->find('all', ['contain' => ['Authors', 'Comments']]);
// クエリーオブジェクトのメソッドとして
$query = $articles->find('all');
$query->contain(['Authors', 'Comments']);
// フィルタリングのクロージャ
$query->contain(['Schedules' => function($q) use ($id) {
return $q->where(['Schedules.date >=' => new time('today')]);
}]);
// contain() 後なら order() なりで関連モデルを指定できる (未検証)
$query = $articles->find();
->contain(['Authors', 'Comments'])
->order(['Authors.rate' => 'desc']); // Article を Author の rate (人気度) 降順で
マニュアル にある通り、 Articles belongsToMany Tags
のとき「タグ Tag を持つ記事 Article を探索」するような 特定の関連データを持つデータのフィルタリング をしたいときは matching() メソッドを利用する。
// コントローラーやテーブルのメソッド内で
$tagName = 'CakePHP';
$query = $articles->find();
$query->matching('Tags', function ($q) use ($tagName) {
return $q->where(['Tags.name' => $tagName]);
});
// notMatching() ってのもあるよ
$tagName = '退屈';
$query = $articlesTable
->find()
->notMatching('Tags', function ($q) use ($tagName) {
return $q->where(['Tags.name' => $tagName]);
});
// matching の 連結
$tagNames = ['CakePHP', 'PHP'];
$categoryNames = ['IT', 'コンピュータ'];
$query = TableRegistry::get('Posts')->find();
$query->matching('Tags', function (Query $q) use ($tagNames) {
return $q->where(['Tags.name IN' => $tagNames]);
})->matching('Categories', function (Query $q) use ($categoryNames) {
return $q->where(['Categories.name IN' => $categoryNames]);
});
// 深い関連の matching
$query = $products->find()->matching(
'Shops.Cities.Countries', function ($q) {
return $q->where(['Countries.name' => 'Japan']);
}
);
// 深い関連の matching の closure に 変数を渡す
$username = 'markstory';
$query = $articles->find()->matching('Comments.Users', function ($q) use ($username) {
return $q->where(['username' => $username]);
});
// in Table (Users hasMany Terminals)
public function getUsersByEventId(int $event_id=null) {
if (empty($event_id)) return [];
$users = [];
$this->Terminals = TableRegistry::get('Terminals');
$terminals = $this->Terminals->find()
->distinct(['user_id'])
->where([
'event_id' => $event_id,
'NOT' => ['user_id' => false],
])
->contain('Users')
->all();
foreach ($terminals ?? $terminals ?? [] as $terminal) {
$users[] = $terminal->user;
}
return $users;
}
// サブクエリー
$sub_query = $this->Hoges->find();
// ネストしたクエリー
$main_query = $this->Moges->find()->from(['SubTable' => $sub_query]);
// 発行 SQL
SELECT * FROM(SELECT * FROM hoge Hoges) SubTable
// 取り出し
$main_query->toArray();
// 存在するカラム (プロパティ) なら動的に生成される「カスタム Finder 」で条件を渡せる
$this->Users->findAllByUsername('joebob');
$this->Users->findAllByUsernameAndAge('john', 24); // 複数もいける
Finder は find()
第一引数へキーを渡すことで、以降の検索条件をモデルへ登録できる機能。ビルトインで all / list / threaded
が備わっており、これに独自の検索条件 ( = データアクセスレイヤのドメインロジック ) をモデルで吸収し、コントローラの各所に find()
条件が分散することを防ぐことができる。
因みにビルトイン Finder である list
などは呼び出し時に拡張したりすることもできるので、フォームの <select>
とかもできる限りこれで生成した方がよい。キー/値のペアを検索する
// key / value をカスタム
$query = $articles->find('list', [
'keyField' => 'slug',
'valueField' => 'title'
]);
$data = $query->toArray();
// $data = [
// 'first-post' => '最初の投稿',
// 'second-article-i-wrote' => '私が書いた2つ目の記事',
// ];
// 関連エンティティのデータを valueField へ
$query = $articles->find('list', [
'keyField' => 'id',
'valueField' => 'author.name'
])->contain(['Authors']);
// valueField にクロージャ仕込む
$query = $articles->find('list', [
'keyField' => 'id',
'valueField' => function ($article) {
return $article->author->calc('favorite_rate');
}
]);
// groupField で ネスト
$query = $articles->find('list', [
'keyField' => 'slug',
'valueField' => 'title',
'groupField' => 'author_id'
]);
$data = $query->toArray();
// $data = [
// 1 => [
// 'first-post' => '最初の投稿',
// 'second-article-i-wrote' => '私が書いた2つ目の記事',
// ],
// 2 => ...
// ];
finder メソッドは共通で使うクエリーをパッケージ化する理想的な方法です。 クエリーを抽象化できるようにすることで、メソッドは使いやすくなります。 fineder メソッドは、あなたが作成したい finder の名前が Foo の場合、 findFoo というように規約に則ったメソッドを作成することで定義されます。
$options
内のキーはcontain
やconditions
などが予約済みなので注意!!conditions
: 中に突っ込んだハッシュがwhere()
に足された状態で Query スタートcontain
: 中に突っ込んだハッシュがcontain()
に足された状態で Query スタート
// App\Model\Queues
/**
* Finder: Awaiting queues.
* @param Query $query
* @param array $options
* @return Query
*/
public function findAwaiting(Query $query, array $options) {
$contains = [];
$conditions = [];
if (isset($options['user_id'])) $conditions += ['Queues.user_id' => $options['user_id']];
if (isset($options['type'])) $conditions += ['Queues.type' => $options['type']];
if (!empty($options['contained'])) $contains = ['Users'];
return $query
->contain($contains)
->where($conditions)
->order(['Queues.created' => 'desc']);
}
/**
* Custom Finder: index
*
* 検索+ページネーション的な index 画面で
* GET で受け取る検索条件から where 条件配列こねこねを Entity Method で
* find() 以降のデータアクセスロジックをカスタム Finder「index」で持ってくる的なやつ
*
* @param Query $Query
* @param array $options = [where, User]
* @return Query
*/
public function findIndex(Query $Query, array $options) {
if (!$where = $options['where'] ?? null) return false;
if (!$User = $options['User'] ?? null) return false;
return $Query
->where($where)
->matching('UserReports', function ($q) use ($User) {
return $q->where(['UserReports.user_id' => $User->id]);
});
}
// In Controller
if (!$User = $this->Auth->user()) throw new \Exception('Forbidden.');
$_Report = $this->Reports->newEntity($this->request->query(), ['validate' => false]);
$Reports = $this->Reports->find('index', [
'where' => $_Report->generateSearchConditions(),
'User' => $User,
]);
$Reports = $this->paginate($Reports, [
'limit' => PAGINATE_LIMIT,
'order' => ['Reports.issued_date' => 'desc'],
]);
$this->set(compact('User', '_Report', 'Reports'));
// find() の重ね掛け前提で組むともっと複雑な表現を抽象化できる
$this->Users->find('index')->find('recent');
beforeFind - cookbook
cakephp3で基本のorderを指定する場合はbeforeFind()メソッドをTableクラスに追加する。
// Set default sort.
public function beforeFind(Event $event ,Query $query, ArrayObject $options, $primary) {
if (!isset($query->order)) {
$query->order(['Tags.sort' => 'asc']);
}
return $query;
}
// Set excluding is_delete.
public function beforeFind(Event $event ,Query $query, ArrayObject $options, $primary) {
$query->where(['Tags.is_delete' => false]);
return $query;
}
CakePHP2 系では id を渡して存在確認してたけど、配列で条件を渡す hasAny() と統一された。
$fancyTable = TableRegistry::get('FancyTable');
$exists = $fancyTable->exists(['name' => 'fancy', 'active' => false]);
// In Controller.
$User = $this->Users->patchEntity($User, $this->request->getData());
if (empty($this->request->data('password'))) {
$User->unsetProperty('password');
$User->dirty('password', false); // 変更されませんでしたよ
}
$this->Users->save($User); // POST に password があっても変更されない
- 失敗時に例外を吐く saveOrFail()
- try catch で囲む際は
save()
よりもsaveOrFail()
で例外吐いてもらったほうがよい
- try catch で囲む際は
- 複数保存
- 条件つきの一括更新
- 複数の関連モデルを同時に更新したい
delete()
は通常の delete()
と違い beforeDelete / afterDelete
などのイベントコールバックが発生しないので注意。
// Controller.
public function deleteAnyConditions($conditions)
{
$deletedNumber = $this->deleteAll($conditions);
if ($deletedNumber === false) throw new \Exception('Failed to delete all entities.');
return $deletedNumber;
}
ぶっちゃけ deleteAll()
と Tables 設定頑張るより負荷分散して delete()
で一個ずつ殺すのが楽。
public function deleteAnyConditions($conditions, $per=1000)
{
$deleted = 0;
$target = $this->find()->count();
for ($i = 0; $i < $target; $i += $per) {
$Targets = $this->find()
->where($conditions)
->offset($i)
->limit($per);
foreach ($Targets as $Target) {
$this->delete($Target);
$deleted++;
}
}
return $deleted;
}
// usersテーブルのidカラムの最大値は?
$query = $this->Users->find();
$ret = $query->select(['max_id' => $query->func()->max('id')])->first();
echo $ret->max_id;
namespace App\Controller;
class EventsController extends AdminsAppController
{
public function index()
{
try {
$Query = $this->Events->find()
->select(['remnant' => '(Events.capacity - COUNT(Applicants.id))'])
->leftJoinWith('Applicants')
->group(['Events.id'])
->enableAutoFields(true);
if ($this->request->query('sort') == 'remnant') {
$Query->order(['remnant' => $this->request->query('direction')]);
}
if (isset($this->request->data['remnant'])) {
$Query->formatResults(function (\Cake\Collection\CollectionInterface $results) {
return $results->filter(function ($row) {
// Return to result sets, according to query.
return ($row->remnant >= $this->request->query('remnant'));
});
});
}
$Events = $this->paginate($Query);
} catch (NotFoundException $exception) {
return $this->redirect(['action' => 'index']);
}
$this->set(compact('Events'));
}
}
// View
//
// <th>
// <?php if ($this->request->query('direction') == 'asc'): ?>
// <?php echo $this->Paginator->sort('remnant', '残り枠', ['direction' => 'desc']) ?>
// <?php else: ?>
// <?php echo $this->Paginator->sort('remnant', '残り枠', ['direction' => 'asc']) ?>
// <?php endif ?>
// </th>
/**
* Count user's storage usage.
* @param int $user_id
* @return int $byte
*/
public function countStorageUsage(int $user_id) {
$this->Users = TableRegistry::get('Users');
$query = $this->Users->find();
$results = $query->where(['Users.id' => $user_id])
->select([
'usage' => $query->func()->sum('Files.size'),
])
->leftJoinWith('Files', function ($q) {
return $q->where(['Files.size >' => 0]);
})
->group(['User.id'])
->enableAutoFields(true); // select() したカラム以外も付与して返却。
return $results->first()->usage;
}
// Collection ライブラリーの combine() メソッドを使います
// これは find('list') と等価です
$keyValueList = $articles->find()->combine('id', 'title');
// 上級な例
$results = $articles->find()
->where(['id >' => 1])
->order(['title' => 'DESC'])
->map(function ($row) { // map() は Collection のメソッドで、クエリーを実行します
$row->trimmedTitle = trim($row->title);
return $row;
})
->combine('id', 'trimmedTitle') // combine() も Collection のメソッドです
->toArray(); // これも Collection のメソッドです
foreach ($results as $id => $trimmedTitle) {
echo "$id : $trimmedTitle";
}
// フィールド、条件、関連が構築済であると仮定します。
$query->formatResults(function (\Cake\Collection\CollectionInterface $results) {
return $results->map(function ($row) {
$row['age'] = $row['birth_date']->diff(new \DateTime)->y;
return $row;
});
});
// filter で抽出する例
$query->formatResults(function (\Cake\Collection\CollectionInterface $results) {
return $results->filter(function ($row) {
return ($row->is_publish); // true のやつだけリザルトセットに返す
});
});
// In UsersController.
$Users = $this->Users->find()->where($conditions); // Users 検索条件付与
$Users = $Users->select($this->Users); // 全カラム取得
// Users.type みたいなしょーもないカラムで番号を返してこのあとページネーションでソートしたい
$Users = $Users->select(['type_number' => $Users->newExpr()->addCase(
[
$Users->newExpr()->add(['type' => 'DMP']),
$Users->newExpr()->add(['type' => 'REP']),
$Users->newExpr()->add(['type' => 'HBP']),
$Users->newExpr()->add(['type' => 'RFP']),
$Users->newExpr()->add(['type' => 'NAP']),
],
[0, 1, 2, 3, 4],
['integer', 'integer', 'integer', 'integer', 'integer']
)]);
// Users は User.type_number が付与されてる ( select as 的な )
foreach ($Users ?? [] as $User) {
echo $User->type; // DMP
echo $User->type_number; // 0
}
// ページネーションを強制 ( $this->Paginator->sort('type_number') だけで動けよ... )
if ($this->request->query('sort') == 'type_number') {
$Users->order(['type_number' => $this->request->query('direction')]);
}
$Users = $this->paginate($Users, ['limit' => PAGINATE_LIMIT]);
$this->set(compact('Users'));
// 町 (city) を人口 (population size) に基いて SMALL、MEDIUM、LARGE に分類
$cities = $this->cities->find()
->where(function ($exp, $q) {
return $exp->addCase(
[
$q->newExpr()->lt('population', 100000),
$q->newExpr()->between('population', 100000, 999000),
$q->newExpr()->gte('population', 999001),
],
['SMALL', 'MEDIUM', 'LARGE'], // 条件に合致したときの値
['string', 'string', 'string'] // それぞれの値の型
);
});
// Case1: Published.
$publishedCase = $query->newExpr()
->addCase(
$query->newExpr()->add(['published' => 'Y']),
1,
'integer'
);
// Case2: Unpublished
$unpublishedCase = $query->newExpr()
->addCase(
$query->newExpr()->add(['published' => 'N']),
1,
'integer'
);
// Let's find with case.
$articles = $this->articles->find()
->select([
'number_published' => $query->func()->count($publishedCase),
'number_unpublished' => $query->func()->count($unpublishedCase)
]);
$tagName = 'PHP';
$categoryName = 'IT';
$postsTable = TableRegistry::get('Posts');
$conditions = [];
$conditions[] = ['Posts.id IN' =>
$postsTable->find()
->select(['Posts.id'])
->matching('Tags', function (Query $q) use ($tagName) {
return $q->where(['Tags.name' => $tagName]);
})
];
$conditions[] = ['Posts.id IN' =>
$postsTable->find()
->select(['Posts.id'])
->matching('Categories', function (Query $q) use ($categoryName) {
return $q->where(['Categories.name' => $categoryName]);
})
];
$query = $postsTable->find();
$query->where(['OR' => $conditions]);
マニュアル 高度な条件 を参照。
$query = $articles->find()
->where(function ($exp) {
$orConditions = $exp->or_(['author_id' => 2])
->eq('author_id', 5);
return $exp
->add($orConditions)
->eq('published', true)
->gte('view_count', 10);
});
// SQL
SELECT *
FROM articles
WHERE (
(author_id = 2 OR author_id = 5)
AND published = 1
AND view_count >= 10)
<?php
// 純粋マッチングでは不可能なので Group/Having で抽出する
// https://goo.gl/XThFuQ
// In Table.
$tagNames = ['CakePHP', 'PHP'];
$query = TableRegistry::get('Posts')->find();
$query
->matching('Tags', function (Query $q) use ($tagNames) {
return $q->where(['Tags.name IN' => $tagNames]);
})
->group(['Posts.id'])
->having([
$this->query()->newExpr()->eq('COUNT(DISTINCT Tags.name)', count($tagNames))
]);
// https://book.cakephp.org/3.0/ja/orm/query-builder.html#delete
$query = $articles->query(); //findじゃないよ
$query->delete()
->where(['id' => $id])
->execute();
$query = $articles->query();
$query->update()
->set(['published' => true])
->where(['id' => $id])
->execute();