GANCHIKU.com

mergeFormかembedFormか

フォームオブジェクトを使う際にはどちらを使うのがいいのかな、と私も瞬間悩んだけど、sfForm.class.phpやsfPropelForm.class.phpを読んでみると、結構動作が違うね。embedFormForEachはembedFormをかぶせただけなので、それほど違いはないとは思う。未だにバグがあると私は思っているので、使う予定はないけども。

mergeFormの場合

  • 単純にフォームをくっつけたいときに使う
  • 具体的には、FormオブジェクトのDefault値とwidgetSchema、widgetValidatorのみmergeをしている
  • mergeをしているという名前のごとく、widgetのfield値が同じであった場合には、mergeされてしまうので、複数追加することができない
  • mergeされたFormオブジェクトの更新系のメソッド(updateObjectとかdoSave)が呼ばれることはないので、手元のフォームで呼んであげないといけないupdateDefaultFromObjectは、呼ばれる。コンストラクタ内なので

embedFormの場合

  • フォームを入れ子にしたいときに使う
  • embedされたFormオブジェクトは、sfFormクラスのインスタンスオブジェクト$embedFormsに格納される
  • 新しくfieldを指定して、その中にembedするので、embedFormの中のwidgetのfield値が同じであっても構わない(新しいfieldはユニークでなければいけないが)
  • embedされたFormオブジェクトのupdateObject, doSaveが呼ばれる

パッと見た感じ、似たようなもんかなー、と思っていたら、データの保存とかが結構違うのでmergeされたFormクラスのupdateObjectなどを独自で書いている際には、明示してあげないと呼ばれないので、ちょっとビックリする。呼ばれることを前提として書いていたので。。。

あと、sfGuardPluginのsfGuardUserAdminForm.class.phpには、mergeFormが出てくるけど、このupdateObjectってトランザクションを持たずにsaveメソッドを呼んでいるような気がするのだが。。。

というわけでちょっとした自分用のメモ。

symfony1.2 embedFormでファイルアップロード

symfonyでFormを使うときって、mergeFormやらembedFormって使っていますか?

私は、最近までは別々のFormクラスを作って、アクションクラスで使うFormクラスをtemplateにrenderしていました。はい。力業力業。よくないねぇ。

最近は、その辺を激しくリファクタリングして、前から気になっていたmergeFormやembedForm使ってみました。で、いろいろ使ってみると、これいいじゃない?というわけで、そのことをブログに書いてみることにします。

環境は、1.2.5-dev。まぁ、1.2.4でも動きます。embedFormForEachというのもあって、複数のフォームを使いたいときは、そちらを使う方がスマートのような感じがしますが、バグがあるせいか、どうもうまくいかないので、embedFormをループで回して使ってみることにしました。今回は、複数個アップロードするということで、mergeFormは扱わずに説明します。まぁ、いつか説明してもいいけど、こちらはすでにいくつもサンプルがあるので、そちらを勝手に調べてくださいな。私は、embedFormで画像のアップロードを扱うことにします。

超簡単なサンプルということで、スキーマはこんな感じでどうかしら?

propel:
  posts:
    _attributes: { phpName: Post }
    id:
    title:
      type: varchar(128)
      required: true
    description:
      type: longvarchar
      required: true
    created_at:
    updated_at:
  pictures:
    _attributes: { phpName: Picture }
    id:
    post_id:
      type: integer
      required: true
      foreignTable: posts
      foreignReference: id
      onDelete: cascade
      onUpdate: cascade
    filename:
      type: varchar(255)
      required: true
    caption:
      type: varchar(128)
      required: true

相変わらずpropel使っています。説明はいらないと思うけども、一応。

postsというテーブルと、picturesというテーブルを用意して、picturesにはpost_idというカラムを持たせて、postsテーブルにリレーションをさせます。まぁ、そんだけ。つまり、postsとpicturesは、1-N関係なのですね。

databases.ymlなどの設定をしておいて、propel:build-allをするとformクラスやmodelクラスの雛形を作成してくれますね。モジュールも手で作るのが面倒なのと今回の説明には大事なところではないので、propel:generate-module post Postで作っておきます。post/templates/_form.phpは、現在のPostFormオブジェクトによって、作成されるので、echo $formだけで超シンプルにしておきます。つまり、以下のようにしておいてください。

getObject()->isNew()): ?>


renderGlobalErrors() ?>
      
renderHiddenFields() ?>  Cancel getObject()->isNew()): ?>  getObject()->getId(), array('method' => 'delete', 'confirm' => 'Are you sure?')) ?>

では、まずPostフォームを用意します。propel:build-allをするとbuild-formもついでにしてくれるので、lib/form/PostForm.class.phpができていると思います。これをちょいといじってみます。

  public function configure() {
    unset($this['created_at'], $this['updated_at']);
    $this->setWidgets(array(
      'id' => new sfWidgetFormInputHidden(),
      'title' => new sfWidgetFormInput(array(
        'label' => 'タイトル',
      )),
      'description' => new sfWidgetFormTextarea(array(
        'label' => '説明',
      )),
    ));

    $this->setValidators(array(
      'id' => new sfValidatorPropelChoice(
        array('model' => 'Post', 'column' => 'id', 'required' => false)
      ),
      'title' => new sfValidatorString(
        array('max_length' => 128, 'min_length' => 3),
        array(
          'max_length' => 'タイトルは128文字以内でお願いします。',
          'min_length' => 'タイトルは3文字以上でお願いします。'
        )
      ),

      'description' => new sfValidatorString(
        array('max_length' => 2048, 'min_length' => 3),
        array(
          'max_length' => '説明本文は2000文字以内でお願いします。',
          'min_length' => '説明本文は3文字以上でお願いします。'
        )
      ),
    ));
    $this->widgetSchema->setNameFormat('post[%s]');
  }
}

左の画像のようになりますね。まぁ、こんなもんでしょう。titleとdescriptionだけを入力する単純なフォームですね。バリデーションは適当に書いてみました。ごくごく初歩的なsfPropelFormの使い方だと思います。説明はいらないですね。

では、PictureFormも書いてみます。

class PictureForm extends BasePictureForm
{
  public function configure()
  {
    unset($this['post_id']);

    $captions = array('A', 'B', 'C', 'D');
    $this->setWidgets(array(
      'id' => new sfWidgetFormInputHidden(),
      'filename' => new sfWidgetFormInputFileEditable(array(
        'label' => false,
        'delete_label' => 'ファイルを削除する',
        'file_src' => '/uploads/' . $this->getObject()->getFilename(),
        'is_image' => true,
        'edit_mode' => !$this->isNew(),
        'template' => '
%file%%input%%delete% %delete_label%
', )), 'caption' => new sfWidgetFormSelect( array( 'label' => '説明', 'choices' => array_combine($captions, $captions) ) ) )); $this->setValidators(array( 'id' => new sfValidatorPropelChoice(array( 'model' => 'Picture', 'column' => 'id', 'required' => false )), 'filename' => new sfValidatorFile( array('required' => false, 'path' => sfConfig::get('sf_upload_dir')), array( 'max_size' => 'ファイルサイズが大きすぎます。', 'mime_types' => '投稿できる画像フォーマットではありません。', 'partial' => 'ファイルアップロードに失敗しました。もう一度、投稿してください 。', 'no_tmp_dir' => 'システムエラーです。管理者にお伝えください。', 'cant_write' => 'システムエラーです。管理者にお伝えください。', 'extension' => 'システムエラーです。管理者にお伝えください。' )), 'caption' => new sfValidatorString(array( 'max_length' => 255, 'required' => false )), 'filename_delete' => new sfValidatorPass() )); $this->widgetSchema->setFormFormatterName('list'); } }

これも説明の必要はないかな。symfony1.2からsfWidgetFormInputFileEditableというwidgetが追加されて、これがファイルアップロード関係でいろいろやってくれるのですね。便利になったものです。使い方はこのソースを読んでもいいですし、jobeetのadmin-generatorの章にも書いてありますので、それを参照してください。ここでは、画像だけをアップロードしてもいいのですが、せっかくなので、画像の説明としてcaptionというカラムもpictureテーブルに持たせてみることにします。A,B,C,Dというのはいいアイデアがなかったのとサンプルなので、まぁ、適当に。

これで、二つの独立したテーブルができましたね。しかし、postの投稿フォームにpictureも一緒に投稿させたいじゃないですか。さらに、pictureが複数投稿できたら尚良さそうですよね。ということで、PostFormにPictureFormをembedすることにします。しかも、複数。ここは決め打ちで3つとします。まぁ、サンプルなので。
PostFormのバリデーションの記述の後にでも、次のコードを追記してみましょう。

    $pictures = ($this->getObject()->isNew()) ? null: $this->getObject()->getPictures();
    for ($i = 0; $i < 3; $i++) {
      $picture = (isset($pictures) and isset($pictures[$i])) ? $pictures[$i] : null;
      $pictureForm = new PictureForm($picture);
      $this->embedForm('picture_' . $i, $pictureForm, '%content%');
      $this->widgetSchema['picture_' . $i]->setLabel('画像');
    }

少し説明しますと、Postが新規投稿であれば、何もしないですが、編集の際には、PictureFormに初期値を設定させて、それをembedFormしていきます。embedFormをする際には、picture_0, picture_1, picture_2というnameを与えて指定してみることにします。また、embedですから入れ子になりますし、ちょっと見た目がダサくなるのでデコレーションは、%content%にしておきます。ついでにlabelも指定しておきます。Formとして表示させるには、これだけ追加するだけで、左の画像のように、title, descriptionだけではなく、3つのPictureFormつまり、filename, captionが3つあるフォームができあがります。

ただ、これだけでは、このpictureがこのpostに関連づけられているかどうかがわからないので、PostFormのupdateObjectでsetPostしてあげます。

  public function updateObject($values = null)
  {
    $object = parent::updateObject($values);
    $values = $this->getValues();

    foreach ($this->embeddedForms as $i => $picture) {
      if ($picture->getObject()) {
        if ($picture->getObject()->getFilename() == '') {
          unset($this->embeddedForms[$i]);
        } else {
          $picture->getObject()->setPost($object);
        }
      }
    }
  }

ここは少し説明がいりますね。まず、PostのupdateObjectはそのまま親のメソッド呼んで、設定させておきます。embedFormでembedしたFormオブジェクトは、このPostFormクラスのembedFormsフィールドに配列で保持されているので(sfForm.class.php参照)、これをループで回して、どのpostと関連つけているかを指定してあげます。ついでにファイルがアップロードされない場合も保存されてしまいますので、getFilename()が空文字列の場合は、embedFormsからその要素をunsetしておきます。これで、ファイルが入っていたときのみ、保存されることになります。

これで新規投稿に関してはファイルがアップロードがちゃんとできるようになりました。また、編集時には、ちゃんとそのフォームに画像が表示されます。
しかし、このままでは実は編集時に使うfilename_deleteを使うことができないのですね。このままですと、そのpictureオブジェクトを削除せずにpictureオブジェクトのfilenameのカラムを空にしてくれるだけです。まぁ、当たり前と言えば当たり前か。ということで、ファイルを削除できるようにしてみます。

チェックボックスには、filename_deleteという名前を与えているので、フォームから渡ってきた値にfilename_deleteがあった場合には、そのPictureのオブジェクトをインスタンス変数のremovePicturesという配列に格納しておくことにします。そして、doSaveでDBをいじるときに一緒にremovePicturesにあるPictureオブジェクトを削除することにします。ということで、最終的にできあがったPostFormは以下のようになります。

class PostForm extends BasePostForm
{
  private $removePictures = array();
  public function configure() {
    unset($this['created_at'], $this['updated_at']);

    $this->setWidgets(array(
      'id' => new sfWidgetFormInputHidden(),
      'title' => new sfWidgetFormInput(array(
        'label' => 'タイトル',
      )),
      'description' => new sfWidgetFormTextarea(array(
        'label' => '説明',
      )),
    ));

    $this->setValidators(array(
      'id' => new sfValidatorPropelChoice(
        array('model' => 'Post', 'column' => 'id', 'required' => false)
      ),
      'title' => new sfValidatorString(
        array('max_length' => 128, 'min_length' => 3),
        array(
          'max_length' => 'タイトルは128文字以内でお願いします。',
          'min_length' => 'タイトルは3文字以上でお願いします。'
        )
      ),

      'description' => new sfValidatorString(
        array('max_length' => 2048, 'min_length' => 3),
        array(
          'max_length' => '説明本文は2000文字以内でお願いします。',
          'min_length' => '説明本文は3文字以上でお願いします。'
        )
      ),
    ));

    $pictures = ($this->getObject()->isNew()) ? null: $this->getObject()->getPictures();
    for ($i = 0; $i < 3; $i++) {
      $picture = (isset($pictures) and isset($pictures[$i])) ? $pictures[$i] : null;
      $pictureForm = new PictureForm($picture);
      $this->embedForm('picture_' . $i, $pictureForm, '%content%');
      $this->widgetSchema['picture_' . $i]->setLabel('画像');
    }

    $this->widgetSchema->setNameFormat('post[%s]');
  }

  public function updateObject($values = null)
  {
    $object = parent::updateObject($values);
    $values = $this->getValues();

    foreach ($this->embeddedForms as $i => $picture) {
      if (isset($values[$i]['filename_delete'])) {
        $this->removePictures[] = $picture->getObject();
      } else if ($picture->getObject()->getFilename() == '') {
        unset($this->embeddedForms[$i]);
      } else {
        $picture->getObject()->setPost($object);
      }
    }
  }

  public function doSave($con = null)
  {
    parent::doSave($con);
    foreach ($this->removePictures as $p) {
      $p->delete($con);
    }
  }
}

symfony1.2を使いこなせるかどうかのポイントの一つはsfFormがちゃんと使えるかどうかになると思います。今後もsfFormを追いかけてみようと思います。
propel:generate-module post Postで生成されたindexSuccess.phpも少しだけ修正して、ちゃんと一覧画面に表示させるようにてみますか。

Post List

Id Title Description Created at Pictures
getId() ?> getTitle() ?> getDescription() ?> getCreatedAt() ?> getPictures() as $picture): ?> <?php echo $picture->getCaption() ?>
New

本当は、編集フォームページにあるDeleteボタンを押すと、ちゃんとデータベースからはPostとそれに関連するPictureが削除されるのですが、実際の画像はunlinkされないので、そこの修正も必要になりますね。編集フォームページで画像だけを削除する際には、sfPropelFormにあるremoveFileが呼ばれますので、unlinkされるのですが、単純にdeleteアクションだけを呼んだ場合には、formクラスとは別のロジックになりますので、ファイルは消えませんので、Pictureモデルのdeleteにでも書いて置かないといけなさそうですね。ちょっとredundantなので、微妙だなぁーと思って今回は、それは載せませんでした。つーか、そもそも削除させないとかw

今回は、サンプルでしたので、書きませんでしたが、画像をconvertしたりする必要があるかもしれないですね。私が作っている本番用では、同時にサムネイルを作ったり、もっとformが複雑だったり、キャプションの与え方が動的だったりします。しかし、embedFormのやり方はこのままですので、これを元にすれば、いろんなところに適応ができそうです。

今回は、symfony1.2を用いて、複数の画像投稿を実現するために、embedFormを用いて実装する方法を説明しました。確かにその複数分ループで回せばいいのですが、embedFormForEachがちゃんと動けば、そちらの方がスマートな気がします。それができるまでは、このembedFormで実現しましょう。

間違いがありましたら、教えてくださいな。

Shin Ohno 2003-2012