あの日見たHABTMの中間テーブルの使い方を僕たちはまだ知らない

この記事はだいぶ前に書かれたものなので情報が古いかもしれません
CakePHPを使っていると、モデルでHABTMを定義することがありますよね。

HABTMが何なのかってのは、まあCookBookとかを見てもらうとして、実際HABTMを使う場合、データベースのテーブルには中間テーブルと呼ばれるものを作ります。

今回はその中間テーブルに関して、ちょっとだけ僕が引っかかったところがありました。

何か読み込まれてないんですよね。中間テーブル用のモデルが。中間テーブルのモデルに書いたメソッドをコントローラーから呼ぼうとすると、そんなメソッドないって怒られる。

正直たいした話ではないんですけど、今回引っかかったときに何か強烈なデジャヴに襲われたので、たぶん以前にも同じことがあったと思うんです。だから二度あるつまづきを三度は起こさないために、ここに記しておきたい所存です。

ちなみにCake1.3で起こったことなので、もしかしたら2.0の方では起こらないかもしれない。そこは未確認なのでご勘弁。



CakePHPでHABTMを定義する

例えば、Wordpressなんか使っててもそうですけど、ブログ記事にはカテゴリやタグってものを紐づけることがよくありますね。このカテゴリやタグは、一つの記事に一つ、あるいは複数のカテゴリやタグが紐づくのではなく、複数の記事に複数のカテゴリやタグが紐づくのが一般的です。

じゃあここで、仮に記事とタグのテーブルを作り、CakePHPでそのモデルを作ったら、こんな感じになる。ここでは記事テーブルをposts、タグテーブルをtagsとし、それぞれのモデルをpost.php、tag.phpに作るとする。ついでに、両者はHABTMで紐づける。

//post.php
class Post extends AppModel {
  public $name = 'Post';

  public $hasAndBelongsToMany = array(
    'Tag' => array(
      'className' => 'Tag',
      'join_table' => 'posts_tags',
      'foreignKey' => 'post_id',
      'associationForeignKey' => 'tag_id',
    ),
  );
}

//tag.php
class Tag extends AppModel {
  public $name = 'Tag';

  public $hasAndBelongsToMany = array(
    'Post' => array(
      'className' => 'Post',
      'join_table' => 'posts_tags',
      'foreignKey' => 'tag_id',
      'associationForeignKey' => 'post_id',
    ),
  );
}

これでデータをfindすれば、記事を取得する際にはそれに紐づくタグが、タグを取得する際にはそれに紐づく記事が取得できます。join_tableとかforeignKeyがどういうものかは、CookBook見れば懇切丁寧に書いてあるから大丈夫。問題ない。だから今回は手抜き……じゃなくって、省略させていただきやす。



で、このとき、データベースのテーブルには、postsテーブルとtagsテーブルの他に、両者の紐付けを行っている中間テーブルってやつがあるはずです。CakePHPの命名規約に従うと、中間テーブルは二つのテーブル(ここではpostsとtags)の名前をアンダーバーでつなげたものになります。

ああ、あとつなげる順番は、アルファベット順にするのが、一応の規約みたいです。

だから今回の場合だと、posts_tagsという名前になる。tよりもpの方がアルファベット順だと先だからね。postsテーブルとcategoriesテーブルをHABTMにするなら、中間テーブルの名前はcategories_postsになるってことっすね。pよりもcの方が先だからね。



あまり変化球な使い方をしないなら、この二つのモデルだけ作って、あとはコントローラー側でごちゃごちゃやればそれだけで事は足ります。

ただ、もしも中間テーブルに対して直接何かをしたい場合。つまり中間テーブルのモデルを使う場合は、場合によっては気をつけないといけないことがある。



モデルのインスタンスの話

中間テーブルのモデルを作るなら、こんな感じになるでしょう。

ファイル名は、これも命名規約に従うと、アンダーバーの後ろの方だけ単数系にすれば良いので、posts_tagsテーブルに対してはposts_tag.phpになる。post_tag.phpじゃないところに注意ね。postsの方は複数形のままだから。

//posts_tag.php
class PostsTag extends AppModel {
  public $name = 'PostsTag';

  public $belongsTo = array(
    'Post' => array(
      'className' => 'Post',
      'foreignKey' => 'post_id',
    ),

    'Tag' => array(
      'className' => 'Tag',
      'foreignKey' => 'tag_id',
    ),
  );

  function test() {
    return $this->find('all');
  }
}

何の説明もなくbelongsToとか書いてしまいましたが、たぶん中間テーブルに直接手を出す場合は、belongsToを使う場合がほとんどでしょう。

あと何の説明もなくいきなりtest()とかいう適当なメソッドを書いてしまいましたが、これは後で使うのでご安心を。



さて、じゃあこれで、コントーラー側でデータをfindしてみましょうか。

class SampleController extends AppController {
  public $name = 'Sample';
  
  public $uses = array('Post', 'Tag', 'PostsTag');

  function index() {
    //posts_tagsテーブルのデータしか取れない
    $data = $this->PostsTag->find('all');

    //warningエラーが出る
    $data = $this->PostsTag->test();
  }
}

実際にこんなようなファイルを作ってやってみれば分かるんですけど、これだと、PostTagモデルで定義しているbelongsToが無視されています。

それどころか、test()メソッドの方は呼ばれもせずに、あの日見たtest()メソッドなんて僕は知らないと言わんばかりにwarningエラーが出ます。

これは何でなのか。



今度こそ本当にモデルのインスタンスの話

さっきモデルのインスタンスの話とか見出しに書いといて、全くその話に触れませんでしたが……。

CakePHPは、ページが読み込まれたときにモデルのインスタンスを生成します。まあようするにクラスの読み込みですね。フレームワークを使わずにPHPをベタ書きするときなんかの『$class = new NewClass()』みたいなことをやっているわけです、たぶん。

このインスタンス生成なんですが、CakePHPってのはわりと気を利かせるやつでして、インスタンスを生成したクラスがHABTMを持っていたりすると、ご丁寧にその中間テーブル用のクラスまでその場で作ってくれちゃうんですね。

ただしそのときは、AppModelオブジェクトになってしまうみたいなんです。

んーと、どういうことかと言いますと……相変わらず僕もよく分かってないんで説明があれな感じになってしまいますが……。

CakePHPって、別に必要がなければモデル用のPHPファイルを作らなくても動きますよね。HABTMとかそういうの一切いらないって場合なら、post.phpやtag.phpを作らなくても、コントローラーでPostクラスやTagクラスを使うことはできます。

ただ自分で作ったpost.phpやtag.phpはないので、代わりにAppModelを使ってクラスを生成しようとします。

それと同じことが、HABTMの中間テーブルでは起こっていると考えれば、ちょっとは分かる……かしら。

え、分からない? そうか、ごめん。でも、俺めげないぜ。気にせず続けちゃう。

つまり上記の例だとPostクラスとかをインスタンス化する際に同時にPostTagクラスも生成してくれるんだけど、そのときはAppModelを使ってクラスを生成するから、posts_tag.phpというファイルはないものとして、クラスを作ってしまうわけなんです。posts_tag.phpをちゃんと作ってあっても、某海パン芸人なみに「そんなの関係ねえ!」って言って、勝手にAppModelオブジェクトにしてしまいます。

だからtest()メソッドとかを呼ぶことができない。



さあ困った。どーしても中間テーブルを使わなきゃいけないのに……どげんかせんといかん。

と思ったそこのあなた。

大丈夫。解決方法は実はそんなに難しくない。ぶっちゃけ小島よしおのネタを一つ見ている間に余裕で解決できる。



その解決方法とは?

post.phpやtag.phpのHABTMを定義しているコードの中に、一行加えるだけ。

//post.php
class Post extends AppModel {
  public $name = 'Post';

  public $hasAndBelongsToMany = array(
    'Tag' => array(
      'className' => 'Tag',
      'join_table' => 'posts_tags',
      'foreignKey' => 'post_id',
      'associationForeignKey' => 'tag_id',
      'with' => 'PostsTag', //これを加える
    ),
  );
}

これで終わりです。料理に適量の塩を加えるよりも圧倒的に簡単だべ?

このwithってのは結合テーブルのモデル名を定義するものみたいで、これを書いておくと、中間テーブル用のモデルファイル(posts_tag.php)を作っていれば、他の場合と同じようにインスタンス化してくれるみたいです。

このwithは、nullじゃなければインスタンス化はしてくれるらしいので、命名規約にちゃんと従っていれば空文字でも良いって参考文献には書いてあったんですけど、僕がやったら上手くいかなかったので、まあとりあえずちゃんと書けばいずれにしても問題ないってことですね。

HABTMを定義しつつ中間テーブルを直接的に使う場合には、これ書いといた方が良いと思います。



もう一つの冴えないやり方

これは、やってみたらとりあえず動いたってだけで特に何も検証してないんで、まあさらっと流してもらっても良いし、ここでページを離脱しても良いです。

コントローラーで$usesの中身を少し変えたら、普通に動いた。

//変更前
$uses = array('Post', 'Tag', 'PostsTag');

//変更後
$uses = array('PostsTag', 'Post', 'Tag');

つまり、中間テーブルのクラスを配列の先頭に持って来たら、何かエラーが出なかったし、test()メソッドとかも呼べた。

たぶん、配列の先頭に持って来ることによって、PostクラスやTagクラスがインスタンス化するより先にPostsTagクラスが作られたからってことなんじゃないかと思うんだけど……真相は分かりません。






冒頭でも言いましたけど、これはCake1.3でのお話です。

Cake2.0ではモデルのインスタンス化のところの処理がいろいろ変わっているって話を聞いたことがあるような気がするから、こんなエラーは起こらないかもしれない。

まあ僕もそのうち検証してみます。

ところで、僕、未だに「HABTM」の読み方にいまいち自信を持てないでいるんだけど……これって何て読むのが正解なの?

ハブテム? ← 俺これ。

ハブトム?

ハブトゥム?

アブドゥル?

ハムナプトラ?
 もしかしたら何か関連しているかも? 
 みんなからのコメント 
2014年02月14日 01:09:04
[...] [...]
2014年09月03日 14:57:06
参考になりました。私はハビタムだと思っていました。私の知り合いもみなそう呼びます。

ハビタム以外の呼び方を知らないです・・
2014年09月04日 01:15:58
まっち~(管理人)
>kaasanさん
ありがとうございます。
kaasanさんはハビタムですか……ううむ、なるほど。もしかしたらそれが正解なのかも。
僕の周りは(といってもたった数人ですが)みんなハブテムって言ってたと思うのですが、もしかしたらハビタムって言ってたのを僕がハブテムって聞き間違えてただけの可能性もありますしね。