PHP
CakePHP
cakephp3

【CakePHP3】 `Type` の話

More than 1 year has passed since last update.

社内用にまとめた内容の転載。


ココらへんの話をします


  • Table、Entityと Typeについて

  • DBから引っ張ってきた値がPHPのデータに変換されること、その逆にPHPのデータがDBにwrite可能な形に変換されること

  • 独自Typeの作成


Entity のpropertyは、どのように決定される(生成される)か?

例えばこんなケースはどうでしょうか。

例)「Userが登録した年を表示する」

$user = $this->Users->get(1);

$reggedYear = $user->created->year;

もう少し詳細に見てみると、 User::$created はこのようなデータになっています

// $created =

object(Cake\I18n\FrozenTime) {
'time' => '2014-05-02T06:09:17+09:00',
'timezone' => 'Asia/Tokyo',
'fixedNowTime' => false
}

ここで議論すべきは、この\Cake\l18n\FrozenTime がどこから来て、いつ生成されているのか?という点です。

cf) Class Cake\I18n\FrozenTime | CakePHP


なぜ createdFrozenTimeになっているのか

結論から言うと、 CakePHPのデフォルト設定として 「datetime型カラムを、FrozenTimeとして扱う」というように紐付けられています。

この紐付けが Type という仕組みです。

cf) データの型

ここではさらっと書かれていますが、

Database(datetime型) 

-> ORMがロード
-> Typeの指定に従い、CakePHPが触る型に変換 (FrozenDate)
-> Entityのpropertyとしてセット

というフローになります


Column <=> Typeの紐付け

原則として、(前項で挙げている)デフォルトの紐付け設定のまま利用するのであれば「各カラムに対応するTypeはどこで指定されているのか」を意識する必要はありません。

しかしながら、(当然のように)アプリケーション側から独自の紐付け設定を行う・もしくは上書きすることが可能です。

これはTable内で行われます。

以下の例は、「Database的には可変長文字列に過ぎないが、内容として構造データ(json)を保持しているカラム」を扱いやすくするための実装です。

namespace App\Model\Table;

use Cake\Database\Schema\Table as Schema;

class SpecialSales extends Table
{
protected function _initializeSchema(Schema $schema)
{
parent::_initializeSchema($schema);
$schema->columnType('data', 'json');
return $schema;
}
}

これにより、「$SpecialSales->dataは、アプリケーション側からアクセスした時点で既にjson_decode済み=(単なる文字列ではなく)Object化されている」事が保証されました。実装者は、明示的にdecode処理をするという一手間を意識せずに省略することができます。

cf) Schema\Table Objects

もう少しだけ実態のイメージを持つための補足を加えると、


  1. Databaseから引っ張ってきたデータを

  2. Tableクラスの保持しているスキーマ情報(ここにTypeを含む)に従って

  3. Entityを吐き出す

という流れになります。

そのため、「Entityを生成するのに必要な情報はTableが持っている」のです。


Database <> PHP間の変換

では、先の例で挙げたような「(PDO的には)日時型文字列にすぎないものを、Date系拡張クラスのインスタンス化処理を施す」「(PDO的には)json-serialized文字列にすぎないものを、json_decode()を施したObjectに変換する」といった処理というのは、どの段で行われているのでしょうか。

また、PHPのオブジェクトを保存する = Databaseに投げ込む際の変換はどうでしょうか。

その仕事を担う正体が Type になります。

Databaseと(Cake)PHPの橋渡し的存在です。

大まかに言って、次の記事が最も入門的な解説を行っています。

cf) 独自の型を作成する


toPHP()

まず、コレが「Databaseから引っ張ってきた値をPHPのクラス(や値)に変換する」仕事を行っている正体です。

例として、次は「TypeとしてBooleanを指定された場合に、フェッチ直後に通る処理」です。

// https://github.com/cakephp/cakephp/blob/master/src/Database/Type/BoolType.php#L68

public function toPHP($value, Driver $driver)
{
if ($value === null) {
return null;
}
if (is_string($value) && !is_numeric($value)) {
return strtolower($value) === 'true' ? true : false;
}
return !empty($value);
}

標準で梱包されているTypeを見る限り、(PDOの)返り値がNULLだった場合に(各種インスタンスや有効値ではなく)NULLを返すというのがお作法になります。

その後、(string)trueと同値でなければfalseを返すし、数値や数字文字列であれば「== 0以外はFALSE」と判定しています。

このように、「引っ張ってきた値をbooleanとして解釈する」処理が施されるのです。


marshal()

こちらも「PHPの値に変換する」働きを持つので toPHP()と混同しがちなのですが、これは「ユーザーからrequestされたデータやアプリケーション側が集約してきたデータを投入し、Entityオブジェクトを生成する(or 更新する)」際の処理になります。

すなわち、 $Table->newEntity($data) だったり $Table->patchEntity($entity, $data)などを実行する際にフックされます。

次は IntegerType の例で、「整数値として解釈可能であればintにキャストした値を、そうでなければnullを」返すように処理を施しています。

すなわち、「Entityのpropertyとして整数値が保持される、あるいは不適合ならnullらせる」事を意味します。

// https://github.com/cakephp/cakephp/blob/master/src/Database/Type/IntegerType.php#L79

public function marshal($value)
{
if ($value === null || $value === '') {
return null;
}
if (is_numeric($value) || ctype_digit($value)) {
return (int)$value;
}
if (is_array($value)) {
return 1;
}
return null;
}

他方で、興味深いことにJsonTypeでは「与えられた値をそのまま保持する」ようになっています。

// https://github.com/cakephp/cakephp/blob/master/src/Database/Type/JsonType.php#L72

/**
* Marshalls request data into a JSON compatible structure.
*
* @param mixed $value The value to convert.
* @return mixed Converted value.
*/

public function marshal($value)
{
return $value;
}

これは、「Databaseに突っ込む」もしくは「返り値をユーザーに(=ブラウザに)表示する」までは 何かのインスタンスだろうと配列だろうと、直前まで生データのまま扱える 状態を保持していることになります。便利。


toDatabase, toStatement, toExpression

最後が toDatabase() / toStatement(), toExpression() の3つです。

これらは全て「PHPデータをDatabaseに突っ込む」際に呼ばれ、各driverに喰わせるデータへと変換を行います。


toDatabase + toStatement

toDatabaseは「Databaseに挿入するvalue」を、toStatementは「Databaseに通知するtype」を指定するメソッドです。

再度IntegerTypeを例に用いると、それぞれ次のようになります。

// https://github.com/cakephp/cakephp/blob/df1b9b8aa75ece32dbe5e5343a249f3058ba1f3f/src/Database/Type/IntegerType.php#L31

/**
* Convert integer data into the database format.
*
* @param mixed $value The value to convert.
* @param \Cake\Database\Driver $driver The driver instance to convert with.
* @return int
*/

public function toDatabase($value, Driver $driver)
{
if ($value === null || $value === '') {
return null;
}
if (!is_scalar($value)) {
throw new InvalidArgumentException('Cannot convert value to integer');
}
return (int)$value;
}

// https://github.com/cakephp/cakephp/blob/df1b9b8aa75ece32dbe5e5343a249f3058ba1f3f/src/Database/Type/IntegerType.php#L67

/**
* Get the correct PDO binding type for integer data.
*
* @param mixed $value The value being bound.
* @param \Cake\Database\Driver $driver The driver.
* @return int
*/

public function toStatement($value, Driver $driver)
{
return PDO::PARAM_INT;
}

これが実際に呼ばれているのは、ドライバー内の実装になります。

// https://github.com/cakephp/cakephp/blob/df1b9b8aa75ece32dbe5e5343a249f3058ba1f3f/src/Database/Statement/PDOStatement.php#L64

// @see
public function bindValue($column, $value, $type = 'string')
{
if ($type === null) {
$type = 'string';
}
if (!ctype_digit($type)) {
list($value, $type) = $this->cast($value, $type);
}
$this->_statement->bindValue($column, $value, $type);
}

// https://github.com/cakephp/cakephp/blob/df1b9b8aa75ece32dbe5e5343a249f3058ba1f3f/src/Database/TypeConverterTrait.php#L31

/**
* Converts a give value to a suitable database value based on type
* and return relevant internal statement type
*
* @param mixed $value The value to cast
* @param \Cake\Database\Type|string $type The type name or type instance to use.
* @return array list containing converted value and internal type
*/

public function cast($value, $type)
{
if (is_string($type)) {
$type = Type::build($type);
}
if ($type instanceof TypeInterface) {
$value = $type->toDatabase($value, $this->_driver);
$type = $type->toStatement($value, $this->_driver);
}
return [$value, $type];
}


toExpression

前二者とは経路が事なり、「実際に利用されるステートメントを生成する」のがtoExpression になります。

こちらは由来するInterfaceが異なり、 ExpressionTypeInterface となっている点に注意して下さい。

こちらについてはコアコード内での実装例が少なく、またbookでの例 が明瞭だったのでそちらに説明を譲ります。

(MySQL等の)Database関数を用いた処理を行う際などに利用されます。


独自Typeの作成

前章の内容が理解されているのであれば、独自のTypeを実装するのはとても簡単な仕事です。

App\Database\Type以下に、 HogeTypeというクラスを設置しましょう。

このクラスには、 Cake\Database\Type\ExpressionTypeInterface を実装する必要がありますが、基本的には Cake\Database\Type を継承してしまえば、必要最低限のメソッドの実装だけで済みます。

例えば、コアコードを覗いてみると UuidType はStringTypeを親に持っており、コメントを除けば数行程度の実装量しかありません。


独自Typeの読み込み・紐付け

独自に作成したTypeをアプリケーションから簡単に呼び出すために、「定義済みTypeの登録」を行います。

この仕事を担うのがType::map()です。

例えばphp serializedな文字列を扱うApp\Database\Type\PhpSerializedTypeを作成し、serializedという名前で登録するのであれば

Type::map('serialized', 'App\Database\Type\PhpSerializedType');

となります。

これをconfig/boostrap.phpなど適切な箇所に記述して下さい。

その上で、各Tableクラスのinitializeschema()メソッド内で対応fieldに対して紐付けを行います。(先述の内容)

protected function _initializeSchema(Schema $schema)

{
parent::_initializeSchema($schema);
$schema->columnType('data', 'serialized');
return $schema;
}