CakePHP4 で悩んだことメモ

## 開発環境

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に接続する

ルートの設定

```php:config/routes.php
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:/src/Model/Table/CommonTable.php
<?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``` で行う。

https://book.cakephp.org/4/ja/orm/database-basics.html#database-configuration


## クエリビルダー

基本的にはCookbookを読めばわかるので、個人的にわかりづらかった部分について記述する。

https://book.cakephp.org/4/ja/orm/query-builder.html

テーブルの結合について、joinを使えば割と簡単に記述することができる。

```php
$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でデータを扱うのが目標なので、これはやらない。

アソシエーションでつなぐ。

https://book.cakephp.org/4/ja/orm/associations.html


```php
$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で設定すればいいと思う）

```php:/src/Model/Table/UsersTable.php
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',
		],
	];
}
```

```php:/src/Model/Table/CommonTable.php
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);
	}
}
```

```php:/src/Controller/TopController.php
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の場合に悩んだ。

```sql: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のクラスに記述する必要がある。

```php
$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クラスを作ってもいいけど、画面ごとに作るか、コントローラのメソッドでやるかの違いくらい。
一般化できそうならまとめるかもしれない。

書き方としてはこんな感じ。

```php
$validator = new Validator(); // Cake\Validation\Validator
$validator->requirePresence($field); // キー必須
$validator->notEmptyString($field); // 入力必須
$validator->integer($field); // 数値
```

https://book.cakephp.org/4/ja/core-libraries/validation.html

メッセージの変更についてはCookbookに全然記述がなかった。
Validatorクラスを継承する。

```php:/src/Validation/AppValidation.php
<?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']);
	}
}
```