開発環境
PHP 8.0.16RC1
Apache/2.4.52 (Debian)
CakePHP 4.3.6
方針
- URLによってDBを切り替える(マルチテナント的な)
- CakePHPではSQLを発行するのにいくつかの方法があるが、Entityでデータを扱う
- セッションのラッパークラス的なものを作りたい
- フォームバリデーションのメッセージを変更したい
マルチテナント
マルチテナントというものにはあまり理解はないが、ここでは「URLによってDBを切り替える」を目標とする。
【URL例(ドメイン略)】
① /company1/controller/action ⇒ testdb1に接続する
② /company2/controller/action ⇒ testdb2に接続する
ルートの設定
use Cake\Routing\Route\DashedRoute;
use Cake\Routing\RouteBuilder;
return static function (RouteBuilder $routes) {
$routes->setRouteClass(DashedRoute::class);
$routes->scope('/', function (RouteBuilder $builder) {
$validTenant = '[a-z0-9-]+';
$builder->connect('/:tenant', ['controller' => 'Top', 'action'=>'index'], array('tenant' => $validTenant));
$builder->connect('/:tenant/:controller', array(), array('tenant' => $validTenant));
$builder->connect('/:tenant/:controller/:action/*', array(), array('tenant' => $validTenant));
$builder->fallbacks();
});
};
ModelのTableクラスの共通クラスを作る。
各テーブルはこのクラスを継承する。
<?php
namespace App\Model\Table;
use Cake\ORM\Table;
use Cake\Routing\Router;
class CommonTable extends Table
{
public static function defaultConnectionName(): string
{
$tenant = Router::getRequest()->getParam('tenant');
switch ($tenant) {
case 'company1':
return 'testdb1';
case 'company2':
return 'testdb2';
}
return 'default';
}
}
各データベース設定は /config/app.php
および /config/app_local.php
で行う。
クエリビルダー
基本的にはCookbookを読めばわかるので、個人的にわかりづらかった部分について記述する。
テーブルの結合について、joinを使えば割と簡単に記述することができる。
$users = TableRegistry::getTableLocator()->get('Users'); // インスタンス
$users->setAlias('u'); // 別名
$query = $users->find(); // ここからクエリ
$query
->join([
'table' => 'roles',
'alias' => 'r',
'type' => 'INNER', // INNER | LEFT | RIGHT
'conditions' => 'r.role = u.role',
])
joinは元のテーブルに他のテーブルを結合した結果を得る操作で、
joinで結合されたテーブルのデータはエンティティクラスに変換されない。
また、重複を取り除く操作も指定しない限り行われない。・・・とのことらしい。
先述したとおり、Entityでデータを扱うのが目標なので、これはやらない。
アソシエーションでつなぐ。
$users = TableRegistry::getTableLocator()->get('Users'); // インスタンス
$users->setAlias('u'); // 別名
$users->belongsTo('r', [ // 別名
'className' => 'Roles',
'bindingKey' => ['role'],
'foreignKey' => ['role'],
'conditions' => ['r.deleted'=>'0'],
'joinType' => 'INNER',
]);
$query = $users->find(); // ここからクエリ
$query->contain(['r']);
モデル側で設定しておく例が多数あるのだけど、INNERだったりLEFTだったり、フラグを見たり
条件が地味に異なる場合が多いとそれはそれで困るので、その都度設定することにしようかなぁ・・・と。
(そうでなければTableクラスのinitializeで設定すればいいと思う)
namespace App\Model\Table;
class UsersTable extends CommonTable
{
// join用の基本設定を定義しておく
protected $default = [
'Roles' => [ // ←この名称で呼び出せばこの配列の条件でアソシエーションをどうにかする
'association' => 'belongsTo', // アソシエーション種別 belongsTo, hasOne, hasMany, belongsToMany
'className' => 'Roles',
'bindingKey' => ['role'],
'foreignKey' => ['role'],
'conditions' => [],
'joinType' => 'INNER',
],
];
}
class CommonTable extends Table
{
public function addAssociation($name, $alias, $options=[]) {
$options = array_merge($this->default[$name], $options);
$association = $options['association'] ?? 'belongsTo';
return $this->{$association}($alias,$options);
}
}
class TopController extends Controller
function index()
{
$user = TableRegistry::getTableLocator()->get('Users');
$user->setAlias('u');
// 基本設定に条件 deleted = 1 を追加してアソシエーションを設定
$user->addAssociation('Roles', 'r', ['conditions' => ['r.deleted' => 1]]);
$query = $user->find()->contain(['r']);
// => SELECT * FROM Users u INNER JOIN roles r ON r.role = u.role AND r.deleted = 1;
}
}
メインテーブル(FROM)ではないテーブル同士の結合
また、こういうSQLの場合に悩んだ。
SELECT a.id AS aid, a.*, b.*, c.* FROM a
LEFT JOIN b ON a.id = b.id /* a => b */
LEFT JOIN c ON b.id = c.id /* a => b => c */
LEFT JOIN d ON b.id = d.id /* a => b => d */
LEFT JOIN e ON d.id = e.id /* a => b => d => e */
aとbは先述どおりにアソシエーションを繋ぐ。
一方、bとcは、cの条件をbのクラスに記述する必要がある。
$a = TableRegistry::getTableLocator()->get('a');
$a->belongsTo('b', $config); // aにbのアソシエーションを設定する。
$a->b->belongsTo('c', $config); // bにcアソシエーションを設定するには「$a->b」とする
$a->b->belongsTo('d', $config); // dも同様
$a->b->d->belongsTo('e', $config); // eはさらにdを繋ぐ
$query = $a->find()
->contain(['b', 'b.c', 'b.d', 'b.d.e'); // 関係性の通りに記述する必要がある。
// SELECTの指定について
$query->select('a.id'); // 単一指定
$query->select(['b.id', 'c.id']); // 複数指定
$query->select(['count' => 'count(a.id)']); // 別名指定 count(a.id) AS count ←これはメインとなるEntityのオブジェクトに設定される
$query->select($a); // テーブルaの全カラム指定。(a.* ではなく全カラムを記述)
$query->select($a->b); // 結合テーブルでも同様
// クエリはここまで実行されていない。
// イテレーションはクエリーを実行する
foreach ($query as $row) {
$aid = $row->id; // メインテーブルであるaのカラムidを取得する
$bid = $row->b->id; // 結合したテーブルbのカラムidを取得する
$did = $row->b->d->id;
$eid = $row->b->d->e->id;
}
// all() の呼び出しはクエリーを実行し、結果セットを返す
$results = $query->all();
// 結果セットがあれば すべての行を取得できる
$data = $results->toList();
// クエリーからキーと値の配列への変換はクエリーを実行する
$data = $query->toArray();
今回はとりあえずhasMany, belongsToManyについてはノータッチ。
セッションのラッパー
そもそもなんで作りたいかというと、この辺が理由。
- read, write じゃなくて get, set が楽かな・・・
- セッションを全取得するメソッドほしくない・・・?
設定がデフォルトなら$_SESSIONで取得できそう
方法としては、SessionHelperクラスを作るか、コントローラ内で済むならSessionComponentでも作ればいい気がする。
フォームバリデーション
コマンドで生成したTableクラスのvalidatorが適用できる場面って意外と少ない。
Formクラスを作ってもいいけど、画面ごとに作るか、コントローラのメソッドでやるかの違いくらい。
一般化できそうならまとめるかもしれない。
書き方としてはこんな感じ。
$validator = new Validator(); // Cake\Validation\Validator
$validator->requirePresence($field); // キー必須
$validator->notEmptyString($field); // 入力必須
$validator->integer($field); // 数値
メッセージの変更についてはCookbookに全然記述がなかった。
Validatorクラスを継承する。
<?php
namespace App\Validation;
use Cake\Validation\Validator;
class AppValidator extends Validator
{
// デフォルトメッセージはここで指定。
protected $_messages = [
'required' => '{0}が入力されていません',
'integer' => '{0}には数字(整数)を入力してください',
];
protected $_errors = [];
public function validate(array $data, bool $newRecord = true): array
{
return $this->_errors = parent::validate($data, $newRecord);
}
// エラーメッセージを配列で取得する
public function getErrorMessagesArray() {
$errors = [];
foreach ($this->_errors as $field => $msgs) {
foreach ($msgs as $rule => $msg) {
$errors[] = $msg;
}
}
return $errors;
}
// エラーメッセージを指定文字区切りで取得する
public function getErrorMessages($delimiter=PHP_EOL) {
$errors = $this->getErrorMessagesArray();
return implode($delimiter, $errors);
}
/**
* Iterates over each rule in the validation set and collects the errors resulting
* from executing them
*
* @param string $field The name of the field that is being processed
* @param \Cake\Validation\ValidationSet $rules the list of rules for a field
* @param array $data the full data passed to the validator
* @param bool $newRecord whether is it a new record or an existing one
* @return array<string, mixed>
*/
protected function _processRules(string $field, \Cake\Validation\ValidationSet $rules, array $data, bool $newRecord): array
{
$errors = parent::_processRules($field, $rules, $data, $newRecord);
foreach ($errors as $key => $error) {
$errors[$key] = $this->getMessage($key, $field) ?? $error;
}
return $errors;
}
/**
* Gets the required message for a field
*
* @param string $field Field name
* @return string|null
*/
public function getRequiredMessage(string $field): ?string
{
if (!isset($this->_fields[$field])) {
return null;
}
return $this->getMessage('required', $field);
}
/**
* Gets the notEmpty message for a field
*
* @param string $field Field name
* @return string|null
*/
public function getNotEmptyMessage(string $field): ?string
{
if (!isset($this->_fields[$field])) {
return null;
}
return $this->getMessage('required', $field);
}
/**
* メッセージのキーを取得する。
*
* @param string $key メッセージのキー
* @param null|array $args メッセージに設定する値
* @return string|null Translated string.
*/
public function getMessage(string $key, string $field)
{
$message = $this->_messages[$key] ?? null;
if (!$message) {
return null;
}
// App\Locale\{code}\validation.po に設定できるようにする。
return __d('validation', $message, $this->_rules[$field]['name']);
}
}