【CakePHP3】 `Type` の話

  • 20
    いいね
  • 0
    コメント

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

ココらへんの話をします

  • 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;
}