phpのマジックメソッドを使ってRailsのfind_all_by_*メソッドを実装してみる

久々の更新です。
ネタは溜まっているんですがなかなか書くモチベが沸かず。

これから定期的に更新できるよう頑張ります。

今日はPHPのマジックメソッドについて書きます。

PHPのマジックメソッドの中に__callStaticというメソッドがあります。
これは、クラスで定義されていないメソッドに対してstaticなコールをした際に呼び出されるフックです。

この機能を使えば、Rubyでいうところのmethod missingのような挙動が可能になるのでは?
と考え、実験にRuby on Railsで以前まで使われていたfind_all_by_*を実装してみたいと思います。

ライブラリ等に依存しないシンプルなデモと、
実用化するために、FuelPHPのモデルを用いた例も作成します。

__callStaticの仕様を読む

__callStatic() は、 アクセス不能メソッドを静的コンテキストで実行したときに起動します。

引数 $name は、 コールしようとしたメソッドの名前です。 引数 $arguments は配列で、メソッド $name に渡そうとしたパラメータが格納されます。
PHP: オーバーロード – Manual

サンプルコードを呼んでみると、__callStaticはPHP 5.3以上で動作可能な機能のようです。

引数の説明が分かりにくいので、コードを交えつつ解説します。

<?php

class Sample {
    public static function __callStatic($name, $args) {
        var_dump($name, $args);
    }
}

Sample::hogehoge(1,2,3,4,5);

こんなコードがあったとします。
Sample::hogehoge()をコールすると、Sampleクラスにはhogehogeメソッドが定義されていないため、__callStaticが呼び出されます。
このとき、$nameにはメソッド名であるhogehogeが、$argsにはhogehogeに渡した引数である[1, 2, 3, 4, 5]が配列で格納されています。

これを利用して、find_all_by_*を実装してみたいと思います。

find_all_by_* とは?

先ほどから出ているfind_all_byメソッドとは何でしょうか。
やや古いRails(3.2.*)まで使用されていたデータ検索用のメソッドです。

カラム名を指定して、検索条件にあうすべてのレコードを取得する。
rails4からは、whereで代替することができる。
find_all_by - リファレンス - Railsドキュメント

4.*系からは非推奨になってしまいました。
とはいえ、今回はあくまで__callStaticが活躍できそうな方法を探ることが目的なので構わず実装します。

仕様

単一のカラムの指定も受け付け、
更に_and_で繋いだ複数カラムと値の組み合わせを受け付ける
というものを作ってみたいと思います。

例えば、クラス名::カラム名1_and_カラム名2(値1, 値2)とメソッドを呼び出せば、__callStatic内では
SELECT * FROM クラス名 WHERE カラム名1=値1 AND カラム名2=値2
と解釈してもらうことにします。

なお、このメソッドでは、カラム名にandが含まれることを考慮しません。

クラスを定義

まずは、2つのクラスを作成します。

<?php

abstract class Model {
    protected static $table_name = null;
}

class Test extends Model {
    protected static $table_name = 'test';
}

Modelクラスは抽象クラスで、継承される前提のクラスです。
TestクラスはModelクラスを継承し、実際にアプリケーション内で使用されるモデルクラスとします。

テーブル名は、クラス名から取得などをせずに、
おとなしく$table_nameというプロパティを定義しています。

検索用メソッドを作成

マジックメソッドの前に、汎用的な検索メソッドを作成します。
連想配列を受け取り、それをSQL文字列に変換するメソッドです。

Testクラスにはこれ以上書くことがないので、以下のコードではModelクラスのみ記述します。

class Model {
    const FIND_ALL_BY = 'find_all_by_';

    protected static $table_name = '';

    // 連想配列を与えて、条件にマッチする行を全件取得するSQLを作成する
    public static function find_all($where = array()) {
        $sql = 'SELECT * FROM '.static::$table_name;

        // 連想配列が指定されていたらWHERE句を生成
        if(count($where) > 0) {
            $keyval = array();
            foreach($where as $column => $value) {
                $keyval[] = $column.'='.$value;
            }
            $sql .= ' WHERE '.implode(' AND ', $keyval);
        }

        return $sql;
    }

find_allというメソッドを追加しました。

"コピペで使えるコード"を目指しているわけではないので、SQLの生成自体はかなり適当です。
クオートしていないので、クオートが必要な値が含まれていたら実行すらできません。
しかし、イメージは十分に伝わると思います。

SQLの作成が雑なのは、自力でSQLを生成するのは非現実的であることと、
この後FuelPHP対応の実用版を書くため、カラム名と値の組み合わせという情報さえ手に入れば十分だからです。

ちなみに、findAllメソッドの使用イメージはこんな感じです。

// "SELECT * FROM test WHERE id=1"
Test::find_all(array('id' => 1));

// "SELECT * FROM test WHERE name=Leko AND age => 22"
Test::find_all(array('name' => 'Leko', 'age' => 22));

マジックメソッドを定義

本題です。マジックメソッドを入れていきます。
先ほどfind_allメソッドを作成したので、検索自体はfind_allの責務です。
そのため__callStaticでは、カラム名と値の組み合わせさえ取得できればOKです。
find_allへパスする処理を実装します。

全体を書くとやや長くなるため、マジックメソッドのみ記述します。

public static function __callStatic($method_name, $args) {
    // メソッド名がfind_all_by_で始まる場合のみ解析を行う
    if(strpos($method_name, self::FIND_ALL_BY) === 0) {
        // find_all_by_を除去
        $method_name = str_replace(self::FIND_ALL_BY, '', $method_name);

        // カラム名 => 値の連想配列へ変換
        $columns = explode('_and_', $method_name);
        $where   = array_combine($columns, $args);

        return self::find_all($where);
    }
}
  1. 呼び出されたメソッド名がfind_all_by_*(※self::FIND_ALL_BY)の書式なら、
  2. メソッド名を_and_で千切って配列化し、
  3. array_combine()関数を使用してカラム名 => 値の連想配列へ変換し、
  4. それをfind_allメソッドへパスする

という処理になっています。

他のマジックメソッドの邪魔をしないために、
呼び出されたメソッドの名前が、find_all_by_で始まる場合のみ、処理するようにしています。

使用イメージは以下の通りです。

// "SELECT * FROM test WHERE id=1"
Test::find_all_by_id(1);

// "SELECT * FROM test WHERE name=Leko"
Test::find_all_by_name('Leko');

// "SELECT * FROM test WHERE name=Leko AND age=22"
Test::find_all_by_name_and_age('Leko', 22);

// "SELECT * FROM test WHERE name=Leko and created_at=2014-07-20 04:00:00"
Test::find_all_by_name_and_created_at('Leko', date('Y-m-d h:i:s'));

created_atのように、カラム名にアンダースコアが混じっていても問題ありません。

FuelPHPに組み込んでみる

本題2です。上記のマジックメソッドを実用化してみます。

FuelPHPを選んだ理由は、
メソッド名にアンダースコアを使うことをコーディング規約にしているのと、
普段業務で使用しているため勝手がわかるという理由からです。

また、自作したfind_allメソッドは、
同等以上の機能がFuelPHPのfind_byメソッドで実現されているためそちらを利用します。

また、find_by_*というメソッドもサポートしています。

・・・気にせず実装します。
_and_があれば差別化できますし、車輪の再発明だとしても、勉強のためです。

マジックメソッドをFuelPHPのモデルに組み込むと、下記のようになります。

abstract class Model_Base extends Model_Crud
{
    const FIND_ALL_BY = 'find_all_by_';

    protected static $_table_name = 'hoges';

    public static function __callStatic($method_name, $args) {
        // メソッド名がfind_all_by_で始まる場合のみ解析を行う
        if(strpos($method_name, self::FIND_ALL_BY) === 0) {
            // find_all_by_を除去
            $method_name = str_replace(self::FIND_ALL_BY, '', $method_name);

            // カラム名 => 値の連想配列へ変換
            $columns = explode('_and_', $method_name);
            $where   = array_combine($columns, $args);

            return self::find_by($where);
        }
    }
}

find_all_by_以外のメソッドには反応しないので、
FuelPHPの他のメソッドを邪魔することはありません。

PHPだってRailsっぽいことしたい

Railsは数回しか触ったことがなく、特に好きも嫌いも無いんですが、

  • PHPだと古臭いコードになる
  • PHPイケてない

とか言われると、「意外と色々な機能あるよ」と悲しい気持ちになるので、今回の記事に至りました。

確かにイケてないし文法や言語仕様の欠陥はどうしようもないですが、
今回の記事のように、Rubyちっくな機能も作れます。ダメダメではないよ!

ただ、当然ながらマジックメソッドを使用すると動作速度に影響しますし、
あまりトリッキーなことはやらないほうが身のためかもしれません。