symfonyのフォームフィルターの活用 +α

こんにちは。小川です。今日はsymfonyのフォームフィルターをご紹介します。

本題に入る前に、symfonyに関連するお知らせが何点かあります。

まず、12月1日にsymfony 1.3/1.4がリリースされました!
symfony 1.3は1.2までとの互換性を保ち古い機能を残したバージョン、symfony 1.4は1.3から古い機能を削除したバージョンになります。

大きな変更点として、以下のような内容があげられます。
◆SwiftMailerメール送信ライブラリを標準で搭載
◆フォームクラスの改良
◆標準のORMがDoctrineに変更
◆Doctrineが1.0から1.2、Propelが1.3から1.4へバージョンアップ

詳しいことについては、以下のリンクをご参照ください。
symfony 1.3/1.4 の新しい機能
プロジェクトを1.2から1.3/1.4にアップグレードする

あともう1点、ぜひ見ていただきたいのが2009年のアドベントカレンダー「More with symfony」(日本名:もっと知りたいsymfony)です。昨年のJobeetのように12月1日から24日まで1日ずつ公開していく形式ですが、今回は前回と大きな違いがあります。

なんと今回のアドベントカレンダーは日本語版も同時公開となっています。実は今回僕も翻訳に参加し、11/12日目の「Doctrine の高度な使用方法、14/15日目の「Doctrine のテーブル継承の活用」の翻訳をさせていただいてます。
また、Jobeetがsymfonyのチュートリアルだったのに対し、今回のMore with symfonyはレシピブックのような形で、symfonyをすでにお使いになっている方々の様々なニーズにも応えられる内容となっています。
Jobeetも1.3/1.4用に更新されたものが公開されていますし、symfonyをまだ使ったことのない人はJobeetを、symfonyに慣れてきてもっと色々なことが知りたい場合はMore with symfonyを、といったように、symfonyの学習の流れが出来上がってきたのかなあという感じです。

現時点ですべての章が公開されているわけではありませんが、symfonyをもっと知りたいすべての方々にお勧めできる内容ですので、ぜひぜひ見てみてください!
また、おかしな部分がありましたらこのブログのコメントでも結構ですのでご連絡いただけましたら幸いです。

さて、ここからが本題です。フォームフィルターとはsymfonyのフォームオブジェクトの検索用の拡張をしたクラスです。今回はこのフォームフィルターの仕組みや活用方法などをご紹介していきます。
前述の通りフォームフィルターはフォームの拡張です。ですので、入力フォームを制御するためのウィジェットやバリデータを管理する機構はフォームと同様に持っており、入力フォームを作成し、入力内容のバリデーションをおこなう流れはフォームオブジェクトとほぼ同じです。また、フォームと同様に各ORMごとの拡張も用意されています。

実際には入力された値をもとにDBから取得するオブジェクトのフィルタリングを行うのが主な使い方になると思いますので、今回はsfFormFilterDoctrineに絞ってみていきます。

◆フォームフィルターを使って検索フォームを実装する

何はともあれ、フォームフィルターオブジェクトを実際に作成してみましょう。せっかくですのでsymfony 1.4で動かしていきます。まずは以下のスキーマとフィクスチャーを用意しましょう。

config/doctrine/schema.yml


sfClass:
  columns:
    name:
      type: string(255)
      notnull: true
    namespace:
      type: string(255)
sfMethod:
  columns:
    class_id:
      type: integer
      notnull: true
    name:
      type: string(255)
      notnull: true
    description:
      type: string(10000)
      notnull: true
  relations:
    sfClass:
      local: class_id
      foreignAlias: sfMethods

data/fixtures/fixtures.yml


sfClass:
  ClassLoader:
    name: ClassLoader
    namespace: Symfony\Foundation
  Container:
    name: Container
    namespace: Symfony\Components\DependencyInjection
  EventDispatcher:
    name: EventDispatcher
    namespace: Symfony\Components\EventDispatcher
sfMethod:
  ClassLoader_method_1:
    sfClass: ClassLoader
    name: loadClass
    description: "Loads the given class or interface."
  Container_method_1:
    sfClass: Container
    name: setService
    description: "Sets a service."
  Container_method_2:
    sfClass: Container
    name: getService
    description: "If a service is both defined through a setService() method and with a set*Service() method, the former has always precedence."
  EventDispatcher_method_1:
    sfClass: EventDispatcher
    name: connect
    description: "Connects a listener to a given event name."
  EventDispatcher_method_2:
    sfClass: EventDispatcher
    name: notify
    description: "Notifies all listeners of a given event."

今回はサンプルとして、symfonyのメソッドを検索するフォームを作ります。sfClassはクラス、sfMethodはメソッドです。
symfony 1.2まではこのスキーマとフィクスチャーを読み込んでDBへ反映と各クラスの生成をするタスクはdoctrine:build-all-reloadでした。これ以外にもdoctrine:build-で始まるタスクがいくつもあったためか、 symfony 1.3/1.4からはdoctrine:buildというタスク1つにまとめられ、--allなどのオプションを付けてビルドしたものを色々と指定できるようになりました。

というわけで、コマンドを実行してスキーマとフィクスチャーを読み込ませましょう。databases.ymlは各環境に合わせて設定してください。今回はちょっとしたサンプルなのでSQLiteを使います。

config/databases.yml


all:
  doctrine:
    class: sfDoctrineDatabase
    param:
      dsn: sqlite:///<?php echo realpath(dirname(__FILE__).'/..').'/data/doctrine.db' . "\n" ?>

 


$ symfony doctrine:build --all --and-load
$ symfony cc

細かいオプションなどが知りたい場合は、以下のコマンドを実行してヘルプを参照してください。


$ symfony help doctrine:build

これで、データベースとテーブルの作成、モデル・フォーム・フォームフィルタークラスの作成、フィクスチャーの読み込みが行われました。毎度のことながら楽にできて助かります。
フォームフィルタークラスはlib/filter/doctrineディレクトリ内にあります。BaseFormFilterDoctrine、sfClassFormFilter、sfMethodFormFilterの3つのクラスファイルとbaseディレクトリが含まれており、BaseFormFilterDoctrineは共通の親クラス、sfClassFormFilterとsfMethodFormFilterはそれぞれのモデルに対応したフォームフィルタークラスになります。

じっくりクラスファイルを見てもいいのですが、動かすほうが楽しいと思うので、一気にモジュールとアクションを作成して動かしましょう。


$ symfony generate:app frontend
$ symfony generate:module frontend method

generate:appタスクですが、1.3/1.4からはデフォルトでエスケーピングの有効化とCSRFトークンの指定を行ってくれるようになりました。ですのでこの状態でXSSとCSRF対策はされています。

これでmethodモジュールができたのでindexアクション内で検索ロジックを実装しましょう。

apps/frontend/modules/method/actions/actions.class.php


<?php
class methodActions extends sfActions
{
  public function executeIndex(sfWebRequest $request)
  {
    $this->form_filter = new sfMethodFormFilter();
    $this->methods = array();
    if ($request->hasParameter($this->form_filter->getName()))
    {
      $this->form_filter->bind($request->getParameter($this->form_filter->getName()));
      if ($this->form_filter->isValid())
      {
        // 入力値をもとにクエリ-を作成して、オブジェクトを取得
        $this->methods = $this->form_filter->getQuery()->execute();
      }
    }
  }
}

フォームオブジェクトをご存知の方ならば見慣れたコードでしょう。フォームオブジェクトのインスタンスを作成し、リクエストからパラメータのバインドを行う流れはフォームオブジェクトとほぼ同じです。検索なのでPOSTではなくGETで行うため、値が渡ってきているかどうかで判定しています。
フォームと違うのはフォームフィルターオブジェクトに対してgetQuery()というメソッドを発行しているところでしょう。フォームでは通常、バリデーションをパスした後にsave()メソッドを実行して、入力内容をデータベースに保存します。
フォームフィルターの目的は検索をすることなので、getQuery()メソッドを通じて検索条件が指定された状態のDoctrine_Queryオブジェクトを取得します。なお今回は触れませんが、Propelの場合はQueryがCriteriaになると思ってください。

残るはテンプレートです。早く動かしたいのでできる限り簡潔にします。

apps/frontend/modules/method/templates/indexSuccess.php


<?php echo $form_filter->renderFormTag(url_for('method/index'), array('method' => 'get')) ?>
  <table>
    <tfoot>
      <tr>
        <td colspan="2">
          <input type="submit" value="Search" />
        </td>
      </tr>
    </tfoot>
    <tbody>
      <?php echo $form_filter ?>
    </tbody>
  </table>
</form>
<hr />
<?php if (count($methods)): ?>
<?php foreach ($methods as $method): ?>
  <table style="margin: 10px;">
    <tr>
      <th>メソッド名</th>
      <td>
        <?php echo $method->getName() ?>
      </td>
    </tr>
    <tr>
      <th>説明</th>
      <td>
        <?php echo $method->getRawValue()->getDescription() ?>
      </td>
    </tr>
    <tr>
      <th>クラス</th>
      <td>
        <?php echo $method->getsfClass()->getQualifiedName() ?>
      </td>
    </tr>
  </table>
  <hr />
<?php endforeach; ?>
<?php elseif ($form_filter->isBound()  & & $form_filter->isValid()): ?>
該当するメソッドはありません
<?php endif; ?>

フォームフィルターはフォームを継承しているため、フォームのようにオブジェクト自身をechoすることでウィジェットのレンダリングをまとめて行えます。
後はアクションですでに取得しているメソッドオブジェクトのコレクションを表示するためのものです。
クラス名の表示をしている個所がありますが、今回は名前空間に対応したテーブル定義をおこなっているので、修飾されたクラス名を出すようにしましょう。getQualifiedName()メソッドがそれです。sfClassクラスに実装しましょう。

lib/model/doctrine/sfClass.class.php


<?php
class sfClass extends BasesfClass
{
  public function getQualifiedName()
  {
    $name = $this->getName();
    if (!is_null($this->getNamespace()))
    {
      $name = $this->getNamespace() . '\\' . $this->getName();
    }
    return $name;
  }
  public function __toString()
  {
    return $this->getQualifiedName();
  }
}

__toString()マジックメソッドも、修飾名を返すようにオーバーライドしておきます。さてこれでひとまず実装完了です。
ブラウザからこのアクションを確認してみると、検索フォームが出来上がっていると思います。

◆フォームフィルターの仕組みと拡張方法

フォームフィルターの内部を探るため、フォームとフォームフィルターを見比べてみましょう。
lib/filter/doctrine/base/BasesfMethodFormFilter.class.phpとlib/form/doctrine/base/BasesfMethodForm.class.phpを開いてみてください。

まずはウィジェットとバリデータの定義が違います。入力された値の使い方が違うのでこれは当然ですね。フォームフィルター側のnameとdescriptionのウィジェットがsfWidgetFormFilterInputになっていますが、これはフォームフィルター用のウィジェットです。with_emptyオプションをtrueにすると、値が空であるかどうかを判定するためのチェックボックスが付くようになります。試しにdescriptionに対してwith_emptyをtrueに設定してみると、ブラウザでは以下のようになります。

(今気づいたのですが、sfMethodFormをそのままレンダリングすると、各入力フォームのname属性がsf_method[name]のようになりますね。。。これではルーティングのsf_methodとかぶるので、nameFormatを変更するなりしないといけないですね。classが予約語なのでsfを付けてみたのですが、アダになっちゃいましたね。今回はsfMethodFormは使わないのでこのまま進めます。)

この2つのクラスファイルの中でもっとも異なる部分と言えば、フォームフィルター側にあるgetFields()メソッドの存在でしょう。


<?php
public function getFields()
{
  return array(
    'id'          => 'Number',
    'class_id'    => 'ForeignKey',
    'name'        => 'Text',
    'description' => 'Text',
  );
}

これはそれぞれのフィールドが、どのようにして入力された値を検索条件としてクエリ-に追加するかを指定しています。ここで使われているForeignKey、Textなどは汎用的に使われるもので、sfFormFilterDoctrineクラスにadd{$type}Queryというメソッド名で実装されています。symfony 1.4.0では以下のようなものが実装されています。

◆addForeignKeyQuery
配列での指定の場合はIN句、単一の場合は一致しているか指定

◆addEnumQuery
一致しているか指定

◆addTextQuery
is_emptyにチェックが入っている場合はNULLかどうか、そうでなければ入力値をLIKE '%text%'形式で指定

◆addNumberQuery
is_emptyにチェックが入っている場合はNULLかどうか、そうでなければ一致しているか指定

◆addBooleanQuery
一致しているか指定

◆addDateQuery
fromとtoに対してどちらもis_emptyにチェックが入っている場合はNULLかどうか、それ以外の場合はfromに入力があればそれ以降、toに入力があればそれ以前の日付を指定

なお、TextとNumberの場合は必ずsfWidgetFormFilterInputウィジェットを、Dateの場合はsfWidgetFormFilterDateを使うようにしてください。またそれ以外ではこの2つのウィジェットは使用してはいけません。
is_emptyが入る関係で、method[name][text]に値が、method[name][is_empty]に空かどうかのフラグが入って渡されるので、うまく条件指定ができなくなってしまいます。
また、値が空の場合はそもそもメソッドが呼ばれずに処理が飛ばされます。つまり、空であることに対して処理を追加することはできません。ただし前述の通りsfWidgetFormFilterInputはテキストボックスに何も入力しない場合でも、array('text' => '')となり空とは判定されないので、拡張をする場合には注意が必要です。

ちなみにこれらのデフォルトのタイプがどのように判定されるかは、sfDoctrineFormFilterGenerator::getType($column)に記述されています。
以下のような条件でカラムの型を判定して設定しています。


<?php
public function getType($column)
{
  if ($column->isForeignKey())
  {
    return 'ForeignKey';
  }
  switch ($column->getDoctrineType())
  {
    case 'enum':
      return 'Enum';
    case 'boolean':
      return 'Boolean';
    case 'date':
    case 'datetime':
    case 'timestamp':
      return 'Date';
    case 'integer':
    case 'decimal':
    case 'float':
      return 'Number';
    default:
      return 'Text';
  }
}

ちなみにこれらの汎用的な条件指定メソッドはもちろん自分で追加することができます。自動生成したDoctrineのフォームフィルター全てで使いたい場合はBaseFormFilterDoctrineクラスに、それぞれのクラス内でいくつも使いまわす場合はそのクラス内で、add{$type}Queryメソッドを実装すれば、それらが使えるようになります。以下はTextの前方一致版であるTextPrefixタイプをBaseFormFilterDoctrineに実装する例です。

lib/filter/doctrine/BaseFormFilterDoctrine.class.php


<?php
protected function addTextPrefixQuery(Doctrine_Query $query, $field, $values)
{
  $fieldName = $this->getFieldName($field);
  if (is_array($values)  & & isset($values['is_empty'])  & & $values['is_empty'])
  {
    $query->addWhere(sprintf('%s.%s IS NULL', $query->getRootAlias(), $fieldName));
  }
  else if (is_array($values)  & & isset($values['text'])  & & '' != $values['text'])
  {
    $query->addWhere(sprintf('%s.%s LIKE ?', $query->getRootAlias(), $fieldName), $values['text'].'%');
  }
}

といってもaddTextQuery()をパクってきただけですが。引数として、ベースとなるクエリ-オブジェクト、フィールド名、値が渡されます。これらをもとにしてクエリ-を変更していきます。ここで注意したいのは、クエリ-のルートとなるモデルのみにしか指定ができないことです。つまり、sfMethodFormFilterの内部でこれらのメソッドが呼び出された場合は、sfMethodモデルのフィールドのみにしか設定ができないということです。

では、たとえばsfMethodFormFilterでsfClassのnamespaceを検索対象にするにはどうすればいいのでしょうか。この場合の問題は、(1)どうやってクエリ-オブジェクトにsfClassをJOINするか、(2)どうやってJOINしたsfClassオブジェクトのnamespaceを指定するかの2点になります。

まずは(1)のクエリ-オブジェクトにJOINする方法です。そもそもデフォルトではどのようなクエリ-オブジェクトが作られているのでしょうか?これを作成しているのは、実際にクエリーの構築を行っている、sfFormFilterDoctrine::doBuildQuery()メソッドになります。クエリーを作成している部分をみてみましょう。


<?php
protected function doBuildQuery(array $values)
{
  $query = isset($this->options['query']) ? clone $this->options['query'] : $this->getTable()->createQuery('r');
  if ($method = $this->getTableMethod())
  {
    $tmp = $this->getTable()->$method($query);
    // for backward compatibility
    if ($tmp instanceof Doctrine_Query)
    {
       $query = $tmp;
    }
  }
  // ...
}

オプションにqueryがセットされていればそのクエリーが使われ、デフォルトでは対応するモデルのテーブルからcreateQuery()メソッドでクエリーを作成しています。デフォルトではrというエイリアスが割り当てられています。
さらに、$this->getTableMethod()からメソッド名を取得して、クエリーに処理をフックさせることも可能なようです。getTableMethod()メソッドはオプションにtable_methodがセットされている場合はそれを取得するようです。

オプションの指定はコンストラクタの第2引数になります。new sfMethodFormFilter(array(), array('query' => ..., 'table_method' => ...))のように指定可能です。オプションはsetOption()メソッドでもセット可能ですが、オブジェクト作成後にセットする場合は、setTableMethod()やsetQuery()メソッドを使用したほうがよいでしょう。

今回は外部からではなく、sfMethodFormFilter::configure()メソッド内でクエリーをセットするようにします。


<?php
class sfMethodFormFilter extends BasesfMethodFormFilter
{
  public function configure()
  {
    if (!isset($this->options['query']))
    {
      $query = $this->getTable()->createQuery('r')->leftJoin('r.sfClass c');
      $this->setQuery($query);
    }
  }
}

dev環境でアプリケーションを開き、検索を行った後にWebデバッグツールバーでクエリーを確認すると、ちゃんとsfClassがJOINされていると思います。では名前空間を指定するためのウィジェットとバリデータ、そして検索用のメソッドを追加していきましょう。先ほどの汎用的なタイプを指定する形式では、JOINしたモデルのフィールドを指定することはできません。そこで、namespaceに対する専用のメソッドを用意します。

特定のフィールドに対して独自のメソッドを適応させる場合は、add{$column}ColumnQuery()というメソッドを実装します。ちなみにフィールドとカラムという2つの表現を使っていますが、Doctrineではフィールドはデータベース上の名前(アンダースコア)、カラムはプログラム上で扱う際の名前(キャメルケース)といったような使い分けをしています。Columnといった場合はキャメルケースになる、と覚えておいてください。

では実装をしていきます。特にis_emptyなどは使わないので、sfWidgetFormInputTextウィジェットを使用します。


<?php
class sfMethodFormFilter extends BasesfMethodFormFilter
{
  public function configure()
  {
    if (!isset($this->option['query']))
    {
      $query = $this->getTable()->createQuery('r')->leftJoin('r.sfClass c');
      $this->setQuery($query);
    }
    $this->widgetSchema['namespace'] = new sfWidgetFormInputText();
    $this->validatorSchema['namespace'] = new sfValidatorPass(array('required' => false));
    $this->useFields(array('namespace', 'class_id', 'name'), $orderd = true);
  }
  protected function addNamespaceColumnQuery(Doctrine_Query $query, $field, $value)
  {
    // 完全修飾名の場合は先頭のセパレーターをとる
    $isFullyQualified = false;
    if ('\\' === substr($value, 0, 1))
    {
      $isFullyQualified = true;
      $value = substr($value, 1);
    }
    $value .= '%';
    if (!$isFullyQualified)
    {
      $value = '%' . $value;
    }
    $query->addWhere('c.namespace LIKE ?', $value);
  }
}

まずはnamespace用のウィジェットとバリデーターの定義を追加しました。また、descriptionは検索項目として必要ないので検索対象から除外しました。その処理はuseFields()という、symfony 1.3/1.4で新しく追加されたメソッドで、いままでunset($this['created_at'], $this['updated_at'], ...)とやっていたところを、使いたいものだけ指定することができるメソッドです。このメソッドの第2引数にtrueを渡すと、ウィジェットの順番を指定した通りに並び変えてくれます。

そしてaddNamespaceColumnQuery()メソッドで検索条件の指定を行っています。せっかくなのでちょっとしたギミックを加えています。名前空間を絶対指定された場合でも検索可能にし、絶対指定の場合は前方一致になるようにしてみました。

getFields()メソッドは特に変更はしていませんが、これはあくまでもタイプを設定するためのもので、getFields()にないフィールドでもadd{$column}ColumnQuery()が実装されていれば受け付けてくれます。

以上まででフォームフィルターの説明は終わりです。非常に便利な機能なのでぜひぜひ使っていただければと思います。
あと、More with symfonyもぜひみてください!

※名前空間で思い出したのですが、12月15日にモダンPHP勉強会が開催され、そこで名前空間についての発表を行います。当日はUstreamでも配信予定ですので、よろしければ見てください。