4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

CakePHP4 で悩んだことメモ

Posted at

開発環境

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

ルートの設定

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クラスの共通クラスを作る。
各テーブルはこのクラスを継承する。

/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 で行う。

クエリビルダー

基本的には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で設定すればいいと思う)

/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',
		],
	];
}
/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);
	}
}
/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
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クラスを継承する。

/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']);
	}
}
4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?