【CakePHP3】テーブルオブジェクトからスキーマ情報を参照する

【CakePHP3】テーブルオブジェクトからスキーマ情報を参照する

梅雨のジメジメにめっぽう弱い kagata です。

今回は表題のとおり CakePHP3 のテーブルオブジェクトからスキーマ情報を参照していろいろやる方法をご紹介します。Cake3 のモデル周辺は、べんりな道具が本当に充実していますよね。

例題1:入力データの null を空文字列に置き換える

例えば、データベースに次のようなテーブルがあるとします。

CREATE TABLE `posts` (
  `id` int unsigned NOT NULL AUTO_INCREMENT,
  `subject` varchar(255) NOT NULL,
  `body` text NOT NULL,
  `created` datetime NOT NULL,
  `modified` datetime NOT NULL,
  PRIMARY KEY (`id`)
)

null を撲滅するため、どのカラムにも not null 制約をかけています。

このテーブルに、サードパーティの REST API か何かからとってきたデータを流し込む処理を CakePHP で書くことになりました。おおむね次のようなコードになるでしょう。

// コントローラだかシェルだかで

$table = TableRegistry::get('Posts'); // テーブルオブジェクトを取り出す

$data = $this->fetch(); // 外からデータをとってくる

$entity = $table->newEntity($data); // データからエンティティを作る

$table->save($entity); // エンティティをテーブルに保存する

ここで、外部からとってきたデータが次のような中身だった場合を考えます。

array(2) {
  ["subject"]=>
  string(21) "内容のない記事"
  ["body"]=>
  NULL
}

body フィールドが null なので、このデータは not null 制約により保存されません。ふつうはそういう挙動が期待されるはずです。しかし、何か事情があって subjectbody が null のデータも捨てるわけにはいかない、としたらどうでしょう。

null 撲滅の旗をおろしたくはないので、null は null 相当の文字列型データ '' に読み替えることにします。データをとってくる $this->fetch() の中で読み替えてやるのがいちばん素直な気がしますが、ここでは展開の都合上 PostsTable::newEntity() の中で処理してやることを考えます。こんなときは、テーブルのコールバック beforeMarshal を使うと便利です。

    // PostsTable に実装する
    public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $options)
    {
        // subject と body について処理する
        foreach (['subject', 'body'] as $key) {
            if (isset($data[$key]) && $data[$key] === null) {
                $data[$key] = '';
            }
        }
    }

これで、Table::newEntity() に渡される $data['body'] が null のとき、その戻り値の $entity->body は空文字列となります。

beforeMarshal コールバックについてくわしくはマニュアルの下記記事を参照してください。

データの保存 - 3.x

例題2:複数のテーブルで null を空文字列に置き換える

さて、ここからが本題です。

上のような処理を、別のテーブルにも同じように適用したい、と思いました。そこで、この処理をビヘイビアに切り出すことにします。

この場合、例題1の実装ではカラム名をハードコードしている点が問題になります。当然、テーブルごとにカラムの構成は異なります。テーブルクラスごとのカラム定義をいちいちハードコードするのはつらいですよね、

そんなときに使えるのが TableSchema オブジェクトです。テーブルオブジェクトは、自身が扱う RDB 側テーブルのスキーマ情報をプロパティとして保持しています。ここから、カラム名の配列を取得しましょう。

    // ビヘイビアに実装する
    public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $options)
    {
        // ビヘイビアからテーブルオブジェクトを経由してスキーマオブジェクトを取得!
        $tableSchema = $this->_table->getSchema();

        // TableSchema::columns() で RDB 側のカラム名を取得!
        foreach ($tableSchema->columns() as $key) {
            if (isset($data[$key]) && $data[$key] === null) {
                $data[$key] = '';
            }
        }
    }

これで、どんなカラム構成のテーブルも共通したロジックで取り扱えるようになりました。例題1であえてテーブルオブジェクトにこの処理を記述したのは、こうやってスキーマ情報にアクセスしたかったからだったのです。

例題3:not null 制約のないカラムでは null を読み替えない

よかったよかった、と思っているところで、あるテーブルのあるカラムでは null を許容しないといけないことに気がつきました。いいかげんストーリーに無理が出てきたような気もしますが、どうにかしてみましょう。

あるカラムに not null 制約がかかっているかどうかは TableSchema::isNullable() で調べられます。

    // ビヘイビアに実装する
    public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $options)
    {
        $tableSchema = $this->_table->getSchema();

        foreach ($tableSchema->columns() as $key) {
            // not null 制約がかかっていることもチェック!
            if (isset($data[$key]) && $data[$key] === null && !$tableSchema->isNullable($key)) {
                $data[$key] = '';
            }
        }
    }

これで、 not null 制約のかかったカラムに null が渡ってきたら空文字列が保存され、かかっていないカラムには null がそのまま保存されます。

例題4:文字列型のカラムのみ null を読み替える

ここまで null をひたすら空文字列に読み替えてきましたが、落ち着いて考えてみると文字列型でないカラムもあるはずです。そこに空文字列を押し込むのはいかにも乱暴だし、実際保存に失敗することもあるでしょう。

そこで、文字列型のフィールドにのみ空文字列への読み替えを適用してみます。スキーマオブジェクトからカラムの型を取得するには TableSchema::typeMap() を使います。

    // ビヘイビアに実装する
    public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $options)
    {
        $tableSchema = $this->_table->getSchema();

        foreach ($tableSchema->typeMap() as $key => $type) {
            if (
                isset($data[$key])
                && $data[$key] === null
                && !$tableSchema->isNullable($key)
                && in_array($type, ['varchar', 'text']) // 型が varchar ないし text の場合のみ処理する!
            ) {
                $data[$key] = '';
            }
        }
    }

条件文が長くなって若干かっこ悪いですが、まあこんな要領でカラムの型による判定ができます。

なお、登場しうるデータの型名についてはマニュアルの下記記事を参照してください。

データベースの基本 - 3.x

例題5:固定値でなく RDB 側で定義した初期値に読み替える

例題4では文字列型のみ処理する場合を考えました。ここではさらに日付・時刻型についても考えたいと思います。

日付・時刻型の場合、null 相当値にふさわしい値がカラムの用途などによって異なる場合がままあります。null っぽい(?)のは最小値を null 相当とみなす方法ですが、「データを公開する日付」のような場合には最大値を設定して「できるだけ未来まで公開しない」よう表現する場合もあるでしょう。また最小値をとるにしても、例えば MySQL では場合によって最小値が 1000-01-01 だったり 0000-00-00 だったりします。

参考:MySQL :: MySQL 5.6 リファレンスマニュアル :: 11.3.1 DATE、DATETIME、および TIMESTAMP 型

そこで、日付・時刻型の場合には RDB 側で定義した初期値を null 相当値とみなすことにします。スキーマオブジェクトからカラムごとの初期値を取り出すには TableSchema::defaultValues() を使います。

    // ビヘイビアに実装する
    public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $options)
    {
        $tableSchema = $this->_table->getSchema();
        $defaultValues = $tableSchema->defaultValues(); // カラムごとの初期値定義を取得!

        foreach ($tableSchema->typeMap() as $key => $type) {
            if (isset($data[$key]) && $data[$key] === null && !$tableSchema->isNullable($key) {
                // 文字列型フィールドでは null → '' に置換
                if (in_array($type, ['varchar', 'text')) {
                    $data[$key] = '';
                    continue;
                }

                // 日付・時刻型フィールドでは null → RDB 側で定義した初期値に置換!
                if (in_array($type, ['date', 'datetime')) {
                    $data[$key] = $defaultValues[$key];
                    continue;
                }
            }
        }
    }

まとめ

CakePHP3 のテーブルやビヘイビアからスキーマの定義を読み出す方法を紹介しました。テーブルのふるまいを DRY に書きたいときにたいへん便利です。ぜひ使ってみてください。

なお、not null 制約のかかったカラムに null を書き込もうとするとエラーになるのは本来なら自然な挙動です。今回は「外部 API のやむにやまれぬ事情」ということで片づけましたが、一般的にはエラーは起こすべきところで起こるようにすべきです。例題のような処理は適切でない場面も多々ありますので、ご利用は計画的にどうぞ。

エラーを無視するな | プログラマが知るべき97のこと

  • このエントリーをはてなブックマークに追加

この記事を読んだ人にオススメ