2012年6月27日水曜日

cakePHP2.1でjoinーbelongsToを使う

今回はbelongsToに関して。多対一の結合で、正規化を実装するものだからおそらくはhasManyと並んで一番使うアソシエーションになるんじゃないかなぁって気がする。

基本はhasOneとほぼ同じ

belongsTo(LEFT)JOINでの結合を行う。これは、実はhasOneの結合方法と同じで、その動きも返される配列の形も殆ど変わらない。
両者の決定的な違いはbelongsToは外部キーを自身が持ち、hasOneは相手(アソシエーション先)が持つということみたい。

アソシエーション外部キー
hasOne相手が持つ
belongsTo自分が持つ

例えばBlogUserBlogというモデルがあるとして、Blogに外部キーがある場合、以下の設定はほぼ同じ動きをする。

//Model/BlogUser.php
<?php
   class BlogUser extends AppModel{
      var $hasOne = 'Blog';
   }
?>
//上記のと以下の設定は動きがほぼ同じ
//Model/Blog.php
<?php
   class Blog extends AppModel{
      var $belongsTo = 'BlogUser';
   }
?>

なお当然ながら、belongsToを使う場合も自分のと結合テーブル両方のモデルを作成しておく必要がある。

belongsToの宣言

宣言も基本的にhasOneと変わらない。非常に便利なパラメーターがひとつあるだけ。

//app/Model/Blog.php
<?php
  class Models extends AppModel{
     public $hasOne = array(
        'JoinTable'=>array(
            'className' => 'JoinTable',
            'foreignKey'=> 'model_id',
            'type'=>'inner',
            'conditions'=> array('JoinTable.id'=>1),
            'fields' => null ,
            'order'=> null ,
            'dependent'=> true,
            'counterCache' => true ,
            'counterScope' => null
         )  
     );
  }
?>

■キー名(Blog)
この場合、エイリアス名となる。今回はBlogと書いてるけど、当然Bでも何でもいい。

■className
結合するモデル名。上のキー名がモデル名と一致してる場合は省略できる。

■foreignKey
結合先が持ってる外部キー。命名規約に沿ってる場合は省略可能。

■type
結合タイプをleftrightinnercrossの中から選べる。

■conditions
find()で指定する以外に、範囲を制限する場合に使用する。

■fields
抽出するフィールドを選択する。全部を選択する場合、nullにするかこのキー自体を省略する。
逆に全部いらない場合はfalseと設定する。

■order
結合した際にソートする場合に指定する。
hasOneの場合は、find()時でも設定してる場合そのに着く。ASCと書くと昇順DESCと書くと降順となる。

[補足]
あまりSQLに詳しくない人もいると思うので補足すると、前に着くか後に着くかは結構重要になる。
SQLの場合、ソートは記載された順から優先的にされる。
たとえば[BlogUser.id asc]が先に設定されていて、hasOneで[Blog.id desc]と設定した場合、最初にBlogUser.idで昇順ソートされ、BlogUser.idが同じ場合はBlog.idで降順ソートで返される

■dependent
trueとすると、選択モデル(この場合はBlogUser)のフィールドが削除された時それと同じ外部キーを持つ結合モデルのフィールドも削除してくれる。
デフォルトはfalse

便利なcounterCache機能

上記のまではhasOneと同じなんだけど、belongsToでは他にとても便利そうなcounterCacheという機能がサポートされてる。これを使うと、同じ外部キーIDを持つフィールドが何件あるか相手モデル(アソシエーション先)に保存させることができる。

たとえばブログの投稿内容(Blog)とコメント(Comment)を関連付けさせる場合、コメント数が何件あるかをキャッシュさせることができるってことだね。

■使う前の準備
belongsToで結合するアソシエーション先(外部キーがない方)に、以下のようなフィールドを追加する。

選択モデル(アンダースコア型)_count
(例)comment_count int(11) unsigned not null

あとはbelongsTocounterCacheを設定するだけ。

■counterCache
trueとすると、上記の命名規約に沿ったフィールドに、追加、編集、削除の度に数値を増減させてくれる。
また、'counterCache'=>'フィールド名'と設定することもできる。

■counterScope
カウンターキャッシュを増減させる際、このパラメーターで条件を設定できる。
(counterCache版のconditionsみたいなものかな)
たとえば'counterScope'=>array('Model.field' => 1)というような感じで使う。

【簡単な例】
//commentsテーブルにblog_id(外部キー)がある
//blogsテーブルにcomment_countがある
class Comment extends AppModel{
   public $belongsTo = array(
     'Blog'=>array('counterCache' => true)
   );
} 

これだけで、Commentモデルを操作(挿入/編集/削除)するとBlogモデル(アソシエーション先)の「comment_count」が増減するようになるみたい。

これに関して、色々なパターンで検証してみたいからまた別の機会で個別でまとめよかな。ひとまず、色々調べてる時に見つけた紹介されてるサイトをいくつか貼っておきます。

HappyQuality [CakePHP]counterCacheがすごく便利なのでメモ
WapBox [CakePHP]CakePHP - counterCacheについて
半年前の私への教科書 counterCache (HABTMでも)
Ks web Design 手動によるカウンターキャッシュの更新

省略して宣言する場合

belongsToでも、命名規約に沿ってたら当然省略して宣言できます。
あとbindModelももちろん使えるよ。

public $belongsTo = 'Model';
//または
public $hasOne = array('Model1','Model2',...);
複数のテーブルを結合

belongsToは基本的に外部キーを自分が持ってるっていうだけで、あとは「cakePHP2.1でhasOneを使う」と殆ど同じなんで、そこを見てね。

面倒くさがりやさんはこちら


まあ、要はこっち側はhasOneと殆ど同じような感覚で実装できるってことですね。
DBを扱う時、自分が外部キーを持ってる方のテーブルをメインで扱うことが多いから、hasOneよりはこっちの方がよく使うことになりそう。

次回はcakePHPで一番便利そうなhasManyを取り上げてみます。

2012年6月17日日曜日

cakePHP2.1でjoinーhasOneを使う

前回で大雑把にはまとめてみたけど、今回からしばらく、アソシエーションに関して個別で詳しくまとめてみようと思う。多分ここを使いこなせるようにならないと本格的なウェブアプリは作成できないと思うし。

今回は、一番の基礎である(っぽい)hasOneについて。

hasOneのくっつき方

一対一の結合をする。$hasOneの設定があると、cakePHPは(LEFT) JOINを使って結合を行う。

たとえば、以下のようなテーブルがあるとする。

create table blog_users(
  `id` int(11) unsigned not null auto_increment ,
  `name` varchar(30) collate utf8_bin not null ,
  `address` varchar(100) collate utf8_bin not null ,
  primery key (`id`) 
)Engine=Innodb;

create table blogs(
  `id` int(11) unsigned not null auto_increment ,
  `blog_user_id` int(11) unsigned not null ,
  `published` tinyint(1) unsigned not null ,
  `title` varchar(100) collate utf8_bin not null ,
  primary key (`id`)
)Engine=Innodb;

Blogモデル(blogs)blog_user_idというBlogUserモデル(blog_users)の外部キーを持っている。これはcakePHPの命名規約に沿っていて、命名規約に沿ってる限りは色々な設定を簡略化して記述することができるようになる。

[補足]
上記のpublishedはtinyint(1)という設定にしてるんだけど、cakePHPではこの設定の場合はブーリアン値として認識される。findでもsaveでも、Formヘルパーでも0か1以外の入力はできない(それが嫌な場合、tinyint(2)以上にすれば回避できます)。

使う前の準備

アソシエーションを使う前には、全ての使用モデルを作成しておく必要がある。

//app/Model/BlogUser.php
<?php
 class BlogUser extends AppModel{ }
?>

//app/Model/Blog.php
<?php
 class Blog extends AppModel{ }
?>

テーブル名が命名規約に沿っていれば、最初これだけで大丈夫。

外部キーのインデックスを作成する

もうひとつ、事前準備としてMysqlで外部キーのインデックスを作成した方がいい(と思う)。上にも書いたように、hasOneでは(LEFT)joinを使う。例えば上のテーブルの場合、以下のようなSQLが発行される。

Select (中略) from blog_users as BlogUser left join blogs as Blog on Blog.blog_user_id = BlogUser.id Where (略)

ここでblog_user_idにインデックスが張られていない場合、基本的にblogsは毎回フルテーブルスキャンが発行されてしまってパフォーマンスが大きく低下してしまう。テーブルが小さい場合はそこまで問題ないけど、量が多くなると発行に1分以上かかったりするようになっちゃう。

というわけで、以下のようにインデックスを貼っておく。
ちなみに普通のインデックスだけでも劇的にパフォーマンスは改善されるけど、1対1結合で重複しないとわかってるならユニークインデックスを貼る方がいい。

alter table blogs add unique index blog_user_id (blog_user_id);

これで事前準備は完了。

hasOneの宣言

hasOneでは相手側が外部キーを持っているモデルに使用できる。要は上記の場合、BlogUserモデルはBlogモデルとhasOneで結びつけることができるということね。

宣言する際には、以下のようなパラメータが使える。

//app/Model/BlogUser.php
<?php
  class BlogUser extends AppModel{
     public $hasOne = array(
        'Blog'=>array(
            'className' => 'Blog',
            'foreignKey'=> 'blog_user_id',
            'type'=>'left',
            'conditions'=> array('Blog.published'=>1),
            'fields' => array('Blog.*'),
            'order'=> array('Blog.id DESC'),
            'dependent'=> true
         )  
     );
  }
?>

■キー名(Blog)
この場合、エイリアス名となる。今回はBlogと書いてるけど、当然Bでも何でもいい。

■className
結合するモデル名。上のキー名がモデル名と一致してる場合は省略できる。

■foreignKey
結合先が持ってる外部キー。命名規約に沿ってる場合は省略可能。

■type
結合タイプをleftrightinnercrossの中から選べる。

■conditions
find()で指定する以外に、範囲を制限する場合に使用する。

■fields
抽出するフィールドを選択する。全部を選択する場合、nullにするかこのキー自体を省略する。
逆に全部いらない場合はfalseと設定する。

■order
結合した際にソートする場合に指定する。
hasOneの場合は、find()時でも設定してる場合そのに着く。ASCと書くと昇順DESCと書くと降順となる。

[補足]
あまりSQLに詳しくない人もいると思うので補足すると、前に着くか後に着くかは結構重要になる。
SQLの場合、ソートは記載された順から優先的にされる。
たとえば[BlogUser.id asc]が先に設定されていて、hasOneで[Blog.id desc]と設定した場合、最初にBlogUser.idで昇順ソートされ、BlogUser.idが同じ場合はBlog.idで降順ソートで返される

■dependent
trueとすると、選択モデル(この場合はBlogUser)のフィールドが削除された時それと同じ外部キーを持つ結合モデルのフィールドも削除してくれる。
デフォルトはfalse

省略して記載する場合

特に何も設定が必要ない場合(ただ結合すればいいだけなら)、命名規約に沿っていれば以下のように書くことができる。

public $hasOne = 'Blog';
//または
public $hasOne = array('Blog');

ただdependentは(よく削除する場合は)結構便利だから指定した方がいいかもね。その場合もこんな感じに省略してかける。

public $hasOne = array(
  'Blog'=>array('dependent'=>true) 
);

ただ、ぼくが神経質だからかデータベースで行の削除って断片化がおきそうな気がして極力したくないんだけど、そんなの心配するのってぼくだけなのかな。まあいいや。

なお、bindModel(アソシエーションを後から指定する)も同様な記載が可能です。

複数のテーブルを結合

ひとつのモデルに複数のモデルを同階層上にぶら下げる場合、単に配列として複数指定するだけで実装できる。もしもBlogUserBlogContentというような順で結合したい場合、$recursive = 2というように設定をすることで実装できる。

//同じ階層の場合
var $hasOne = array('Blog','Content');

//階層が違う場合
//BlogUser.php
var $hasOne = 'Blog';
var $recursive = 2;

//Blog.php
var $hasOne = 'Content';

[補足]
$recursiveを使った場合、返される配列は階層2のモデルの結果は階層1の中に入り込む。また、どうも(LEFT)JOINは使わずにクエリの分割によって実装するみたい。それが嫌な場合は、ちょっと強引だけど以下のように無理矢理設定することもできる。

//階層が同じ時と同じような配列の返し方をする
//BlogUser.php
var $hasOne = array(
  'Blog',
  'Content' => array(
     'conditions'=>array('Content.id = Blog.id')
   )
);
一対一という意味

cakePHP2.xCookBook(英語版)でも他の色んなサイトでもここでも、hasOneの結合の仕方を「一対一」と書いてるんだけど、これは一つの結果に対して一つの関連付けがされるって意味で、選択モデルの結果が重複しないという意味じゃない(そんなの皆わかってるか)。hasOneの結合は(LEFT)JOINを使っていて、基本はbelongsToと変わらない。

たとえば以下のような場合、結果はこうなる。

//blog_usersテーブル
+----+------+
| id | name |
+----+------+
|  1 | 太郎 |
|  2 | 二郎 |
+----+------+
//blogsテーブル
+----+--------------+-------+
| id | blog_user_id | title |
+----+--------------+-------+
|  1 |             1| テスト|
|  2 |             1| テスト2|
|  3 |             2| テスト3|
+----+--------------+-------+

//find('all')をした場合
Array(
 [0]=>Array(
   [BlogUser] => Array([id] => 1  [name] => 太郎)
   [Blog] => Array([id] => 1 [blog_user_id] => 1 [title] => テスト )
  )
 [1]=>Array(
   [BlogUser] => Array( [id] => 1 [name] => 太郎 )
   [Blog] => Array( [id] => 2 [blog_user_id] => 1 [title] => テスト2 )
  )
 [2]=>Array(
   [BlogUser] => Array([id] => 2 [name] => 二郎 )
   [Blog] => Array([id] => 3 [blog_user_id] => 2 [title] => テスト3 )
  )
);

もちろん、最初からhasOneでしか保存を扱わない、またはBlogに直に挿入したりしない限りこういうことにはならないけどね。ただ、動作はbelongsToと変わらないって考えといた方がいいかも。違いは外部キーを持ってる方で設定/呼び出しすることと、counterCacheという便利そうなのがbelongsToでは扱えるってことくらいかな。

長くなったんで、今回はこれくらいで。次回はhasOneで具体的に何かまた別のものを作ってみます…って思ったんだけど、多分hasOneだけでアソシエーションを構築するってことは皆無そう。だって、1対1の結合なら、普通にデータベースにフィールドを追加すればいいだけだもんねぇ。他のアソシエーションで構築した際の個別の検索や、転置インデックス構築の際には使いそうだからあくまでも補助としてある感じですね。あとは実際運営を始めてデータが膨大に入った後でフィールドを追加する必要に迫られた時とかね。何度かそういう経験あるんだけど。
転置インデックスでの使用はこの前へたくそながらやってみたんで、そこを見てみて下さい。

【参考リンク】
cakePHP2.xCookBook(英語版)

2012年6月15日金曜日

cakePHP2.1でJoinする

今回は単一でのエントリ。hasOneを使ってみる際にテーブル結合の方法など色々と調べたので、それらをいくつかまとめてみる。
(まだ網羅するまでには調べてないから、あくまでも調べた範囲内でだけ。いつかまとめたいな)

テーブルをJoinするには

hasOne、belongsTo、hasMany、hasAndBelongToManyを使うことでテーブル結合を実装できる。
cakePHPでモデル(=DBテーブル)を簡単に結合できる方法としてこれらが用意されてる。

ちなみに、まとめてアソシエーションっていうらしい。直訳すると関連、または連合だね。多分関連という意味だろうけど、連合という言い方の方がかっこよくて中二病心をくすぐってくれるからその意味で使っていこう。観念連合っていう意味でもあるらしい。

アソシエーション結合タイプ外部キー結合方法
hasOne1対1の結合相手が持つ(left)join
belongsTo多対1の結合自分が持つ(left)join
hasMany1対多の結合相手が持つクエリ分割
hasAndBelongsToMany(HABTM)多対多の結合第三者が持つクエリ分割

書いてはみたけど、これだけじゃさっぱり解らないですやね。上の表の外部キーの部分は、マニュアルは非常に分かりづらかったからちょっとでもわかりやすくって思ってこう書いてみた(やっぱり分かりづらいけど)。
自分──hasOneなどを使うモデル
相手──アソシエーション先
第三者──仲介テーブル(使い方は下記に記載)。

hasManyHABTMはクエリが分割されて発行される。その方が余計なメモリを食わない分効率はいい。ただ、その為conditionsの指定によってはエラーが起きる

命名規約などのルール
  • 外部キーは結合モデル_id(アンダースコア型)にする
  • HABTMテーブルは二つのテーブルをアルファベット順&アンダースコア型でくっつける

HABTMのはわかりづらいけど、たとえばusersテーブルtagsテーブルをくっつけるHABTMテーブルを作成する場合、tags_usersという名前で作成する。

他の結合方法

実は、これらを使わなくても普通にfind時にJOINもできる。やり方は簡単で、パラメーターの中に'joins'という配列を入れればいいだけ。
他にもModel::query()という直にSQLをかける関数も用意されてて、その中で普通にjoinを記述することもできる。

アソシエーションを使うメリット

そのまま他の結合方法を使うデメリットにもなるんだけど、

  • 非常に簡単に結合できる
  • クエリを発行する度に書く手間が省ける
  • beforFind、afterFindが自動で適応される ※注
  • 他cakePHPの便利機能をフル活用できる
  • フィールド名とか書かなくてもちゃんと入ってくれる
    (joinsでやると指定しないと入らない)
beforeFind、afterFindに関して

一応joinsを使っても、そのモデルを使った場合はbeforeFind、afterFindは適応される。でも、アソシエーションを使った場合、afterFindはそれぞれのモデルで設定されたもの発行されるみたい。たとえば前回hasOneを使った際、どちらも普通に適応されてた。

afterFindではデータの再フォーマットをする機会が殆どになると思うんだけど、この機能は発行の度に再指定する必要がなくなるから便利ですね。

あ、ただbeforeFindはその選択中のモデルのしか適応されないみたい。

アソシエーションがある場合のfind()の動き方

ついでだから調べてみた。
たとえば1つのテーブルをhasOneでアソシエーションした場合、以下の動きをするみたい。

  1. 選択モデルのbeforeFind()呼び出し
  2. アソシエーションの結合 ※1
  3. クエリデータ作成 & $this->getDataSource()->read()メソッド呼び出し
  4. 結果を返す
  5. アソシエーションモデルのafterFind呼び出し ※2
  6. 選択モデルのafterFind呼び出し ※2

※1 正確には、3と4のあたりでアソシエーション設定を確認して結合される
※2 同じく3と4で結果を作成してる時にそれぞれ呼び出されるみたい。

色々と試してみたんだけど、最初に選択モデルのbeforeFind()が呼び出され、最後に選択モデルのafterFindが呼び出された。アソシエーションモデルのafterFindが先。
また、(5)アソシエーションモデルのafterFindが呼び出される時には、どうも選択モデルの結果は入ってないみたいで、もし選択モデルのフィールドをアソシエーションモデルのafterFindで変更しようとすると失敗する。(6)が呼び出される時には両方セットされた状態でafterFindがかけられる。

あ、選択モデルっていうのは$this->Test->find()みたいにコントローラ内で検索をかける時のモデルのことね。念のため。

実際に結合する場合

非常に簡単だった。モデルのクラスの下に、以下のように変数を設定するだけでcakePHP側で自動的に認識してくれる。
(ただし命名規約にしたがってる必要あり)

【基本型】
var $hasOne = array('結合モデル');
var $hasMany = array('結合モデル');
var $belongsTo = array('結合モデル');
var $hasAndBelongsToMany = array('結合モデル');
【具体例:hasOneを使用する場合】
//app/Model/User.php
//Campanyモデルと結合する場合
//campaniesにはuser_idがある
<?php
  class User extends AppModel{
    var $hasOne = array('Campany');
  }
?>

//app/Controller/UsersController.php
<?php
  class UsersController extends AppController{
     //var $uses = array('User','Campany');
     //↑Userモデルは命名規約で自動的に使え、CampanyもJoinで使うだけなら指定する必要ない
     function index(){
         $result = $this->User->find('all');
         //$resultにはcampaniesも結合された状態で返される
     }
  }
?>

あとは、普通に選択したモデルでfindなどを使って取り出せばいいだけ。

もちろん個別で細かく設定をすることもできる。今回は基本的なところだけまとめておきます。

【他の設定方法】
//hasOneでの設定だけど基本的にHABTM以外は他のも全部同じ。

var $hasOne = array(
   'C' => array(
      'className'=>'Campany',
      'foreignKey'=>'user_id'
    )
);
//この場合、Cはエイリアス名になる。

var $hasOne = array(
  'C'=>array(
     'className'=>'Campany',
     'foreignKey'=>false ,
     'conditions'=>array('C.id = User.id')
   )
);
//この設定の仕方をすれば、複合プライマリキーにも対応できる(かも)
後から指定する/後から解除する

詳しいことは「郵便番号検索を作る─5.住所検索できるようにする(後編)」でも書いてます。
bindModel()で設定、unbindModel()で解除可能。

//例:コントローラ内(当然モデル内でも同様にできる)
//後から設定する
$this->User->bindModel(
   array('hasOne'=>array('Campany'))
   ,false
);
//または
$this->User->bindModel(
   array(
     'hasOne'=>array(
        'C'=>array(
           'className'=>'Campany',
           'foreignKey'=>'user_id'
        )
      )
   )
   ,false
);
//第2引数をtrueに設定すると、1回の検索でのみの適応となる。
//デフォルトはtrueだから、永続的に設定する場合はfalseに指定する必要がある。
//##############################################################
//後から解除する
//第2引数はbindModelと同じ
$this->User->unbindModel(
  array(
    'hasOne'=>array('Campany') 
  )
  ,false
);
hasAndBelongsToManyの設定方法

hasAndBelongsToMany(HABTM)では多対多のいわゆるタグ付けみたいなのが簡単に実装できる。たとえばUserモデル(users)とTagモデル(tags)でHABTMを実装する場合、tags_usersという専用のテーブルを作成する。テーブルの基本構成は以下の通り。

tags_users
id主キー
user_idUserモデルの外部キー
tag_idTagモデルの外部キー

モデル名はTagsUserとなる。app/Model/TagsUser.phpを作成した後、Userモデルに以下のように記載すれば、命名規約に沿ってる場合自動で設定される。

//app/Model/User.php
<?php
  class User extends AppModel{
     var $hasAndBelongsToMany = array('Tag');
  }
?>

非常に簡単でつね。Tagモデルで使用する場合は、上の値は'User'でいい。
使用例はWEBOPIXELさんのトコに詳しく載ってました。
(これはCake1.xのやり方みたいで、2.1で行うには少し変更する必要があるみたい)

もちろん基本はこれでいいんだけど、これだけの設定の場合、保存したりする度に内部的に

  1. 毎回HABTM内の既存のフィールドを削除
  2. 再び新規作成

というような、何かいやらしい動きをする。主キーの値はどんどんと繰り上がっていって気分的に嫌だ。

それを少しでも抑える方法として、パラメーターのuniquekeepExistingと指定すると必要のない箇所だけ削除って方法をとってくれ、多少は主キーの繰り上がりは抑えられる(cakePHP2.1以降?に追加された機能です)。

//app/Model/User.php
<?php
  class User extends AppModel{
     var $hasAndBelongsToMany = array(
          'Tag' => array(
             'unique'=>'keepExisting'
           )
         );
  }
?>

これをデフォルトの書き方にした方がいいかも。

他の方法での結合──joins

上でも書いた通り、アソシエーションを使わなくてもjoinsパラメーターでも結合することはできる。
やり方は以下の通り。

【使用例】
$this->Model->find(
   'all' , array(
      'conditions'=>array('Model.id'=>1),
      'joins'=>array(
          array(
            'table'=>'join_table1',
            'alias'=>'Join1',
            'conditions'=>array('Join1.id = Model.id')
          ),
          array(
           ...
          ),
          ...
      )
   )
);

ただし、この方法だとcakePHPでの機能が制限されるフィールドを指定しないと値を取り出せないなど多少使い勝手が悪くなる。この方法は内部的に結合する時といったように、あくまでも補助として使用する程度に止めた方がいいかも。

アソシエーションを使った保存方法

アソシエーションを使った専用の保存メソッドとしてModel::saveAssociated($data)というのが用意されてる。
これを使えば、View::Formヘルパーなどで作成/投げられたデータをこの中にぶちこむだけでアソシエーションデータも保存してくれる。
(その際、アソシエーション先の主キーが含まれてないと新規作成となる)

簡単に作ってみる

最後に、hasOneで簡単に検索/保存まで実装してみる。

【テーブルの作成】
#Testテーブルを作成
mysql> create table tests (
    ->  id int(11) unsigned not null auto_increment ,
    ->  title varchar(30) collate utf8_bin not null default '',
    ->  primary key (id)
    -> )Engine=Innodb;
Query OK, 0 rows affected (0.27 sec)

#アソシエーションテーブルを作成
mysql> create table joins (
    ->  id int(11) unsigned not null auto_increment ,
    ->  test_id int(11) unsigned not null ,
    ->  comit varchar(30) collate utf8_bin not null default '',
    ->  primary key (id)
    -> )Engine=Innodb;
Query OK, 0 rows affected (0.11 sec)
【作成していく】
//[モデル]
//app/Model/Test.php
<?php
   class Test extends AppModel{
      var $hasOne = array('Join');
   }
>

//app/Model/Join.php
<?php
   class Join extends AppModel { }
>


//[コントローラー]
//app/Controller/TestsController.php
<?php
   class TestsController extends AppController{
      function index($id = null){
         //ポストデータがある場合保存
         if($this->request->data){
            if($this->Test->saveAssociated($this->request->data))
                $this->Session->setFlash('保存しました');
         }
         $this->request->data = $this->Test->read(null , $id);
      }
   }
>

//[ビュー]
//app/View/Tests/index.ctp
<H1>編集</H1>
<?php
   print $this->Form->create('Test');
   print $this->Form->input('Test.id');
   print $this->Form->input('Test.title');
   print $this->Form->input("Join.id");
   print $this->Form->input("Join.comit");
   print $this->Form->end('送信');
?>

ちゃんとトランザクションも使ってくれるんだね。


とりあえず、今回は大まかな点だけまとめてみた。これ以上長くなっても誰一人読んでくれなくなりそうだから、細かい設定とかそういうのはそれぞれ個別に、また気が向いたらまとめようと思います。

【参考リンク】
cakePHP2.xCookBook(英語版)
CakePHPのhasAndBelongsToMany(HABTM)をチェックボックスで関連付ける
【cakePHP】アソシエーションで迷ったらこう考えよう

2012年6月13日水曜日

郵便番号検索を作る─5.住所検索できるようにする(後編)

前回で住所検索を実装する準備が整ったので、今回一気に実装までしてみる。

■作成手順
・郵便番号データを作成
・郵便番号の全文一致でリストを出力
・郵便番号の前方一致でリストを出力
・都道府県、市区町村によるリストを作成
・住所(漢字、カナ、かな)で検索できるように←今回ココ
・郵便番号データに知人/友人を登録できるようにする
・レイアウトをカスタマイズする
どんな動作になるようにするか

できるなら、以下のような動きになるように作っていきたい。

  1:indexページで郵便番号入力フォームに住所を入力したら住所検索となる
  2:転置インデックスを使って高速に部分検索できるようにする
  3:更に、丁目以降の細かい住所を入れた場合も近似の場所がでるようにする

転置インデックスは、hasOneでジョインかな。3を実装する為に、再帰関数を使ってヒットしなかった場合は検索文字列の最後を削って再検索する、という方法を使ってみる。

・「東京都中央区入船3丁目」で検索
・ヒットしなければ次は「東京都中央区入船3丁」で検索
・以下10回まで繰り返す。
10回試行しても見つからなければエラー

何度も無駄な検索をかけるのは効率悪いけど、1検索あたり0.1秒もかからないんと思うから(多分)。同時アクセスが多い場合は他の方法を考えないとダメだけどね。

転置テーブルを結合

転置インデックスでは同一テーブルをいくつもjoinさせ、検索文字列をずらしていきながらそれぞれのjoinテーブルで検索をかけていって精度を高めるって方法をとる。

【発行したいSQL例】東京都中央区入船を検索する場合
select * from postal_codes as P
inner join postal_tips as t0 on P.id = t0.postal_code_id 
inner join postal_tips as t1 on P.id = t1.postal_code_id
inner join postal_tips as t2 on P.id = t2.postal_code_id
inner join postal_tips as t3 on P.id = t3.posatl_code_id
where P.address like '%東京都中央区入船%'
and t0.area = '東京'
and t1.area = '都中'
and t2.area = '央区'
and t3.area = '入船'

沢山inner joinすると効率悪いのではないかって思うかもしれないけど、Mysqlオプティマイザはいい子だからそれらのjoinテーブルから一番件数の少ないものを選びながら他のワードは排除していくっていう効率いい検索をしてくれる。から、問題ない(多分ね)。

これをcakePHPで実装するには、PostalCodeモデルにhasOneを指定するといいみたい。
hasOneを指定すると、外部キーを持つテーブルと一対一のjoinを発行してくれる。

hasOneを指定する

通常だったら、以下のようにPostalCodeモデルに付け加えたら自動的にcakePHPが結合してくれる。

//Model/PostalCode.php内
  class PostalCode extends AppModel{
    public $hasOne = array(
        'PostalTip'
    );
    //あるいは

  class PostalCode extends AppModel{
    public $hasOne = array(
        'T1'=>array(
          'className'=>'PostalTip',
          //'foreignKey'=>'postal_code_id',
          //'type'=>'inner'
        )
    );
   //コメントアウトしてる部分は、あってもなくてもいい

foreignKeyは外部キー名を指定するんだけど、cakePHPの命名規約に沿って作ってる場合は特に指定する必要がない。
あとcakePHPのjoinにはhasOneの他にhasManybelongsTohasAndBelongsToManyがあるみたいで、最後の以外は基本的に構文は同じ。
(多少はそれぞれで違うけど、基本は下記のでできる)

宣言 $結合方法 = array('結合モデル1','結合モデル2',...);
#または
宣言 $結合方法 = array(
     'エイリアス名' => array(
          'className'=>'結合モデル',
          'foreignKey'=>'外部キー名',
          'type'=>'inner / left(結合タイプ)',
          'conditions'=>array(基本の検索条件),
          'fields'=>array(基本の抽出フィールド),
          'order' =>array(ソート方法),
          'dependent'=> false 
      (DELETEを発行した場合同時削除するかどうか true:削除する)
        ),
      .
      .
      .
    );
後から指定する場合

基本は上のようににクラス名の最初の方で宣言して使用するんだけど、そうしちゃうと、PostalCodeモデルを使って発行した全てのクエリにPostalTipモデルがもれなくくっついてきてしまう。

だけど、今回はあくまでも住所検索時だけしか使いたくない。それにこの検索は発行されるクエリによって結合する数が変わる為、そもそも最初に宣言するという方法は使えない。というわけで、今回は住所検索時にだけ結合を宣言するという方法を使う。

そのやり方としてcakePHPでは色々な関数が用意されてるみたいで、今回はbindModelを使ってそれを実装する。

Model::bindModel($params , $reset = true);
【例外的に結合を設定する】
$params = array('結合方法(hasOneなど)'=>array(上の指定方法と同じ));
$reset = trueの場合適応は1回限り。falseの場合処理が終了するまで永続的に適応
//コントローラ内で使う場合は$this->モデル名->bindModel()となる

なお、これと反対で例外的に結合を解除するunbindModel()というのもある。使い方はbindModelとほぼ同じ。

というわけで、作っていく

結合の仕方なども決まったので、あとは一気に作成していく。

//Model/PostalCode.php内
//PostalTipを使って転置インデックスを行う
public function useTips($area = ''){
   if(!strlen($area)) return false;
   //既に設定されてる可能性もあるからそれを一時的にリセット
   if(count($this->hasOne)){
     $this->unbindModel(array('hasOne'=>array_keys($this->hasOne)) , false);
   }
   $area = mb_convert_kana($area , 'C' , 'utf8');
   $count = mb_strlen($area);
   //文字数に応じてどんどん結合していく
   for($i = 0; $i * 2 < $count; $i++){
     $_area = mb_substr($area , $i * 2 , 2 , 'utf8');
     if(mb_strlen($_area) === 2){
        $conditions['T'.$i.'.area'] = $_area;
        $hasOne['hasOne']['T'.$i] = array('className'=>'PostalTip' , 'type'=>'inner');
      }else{
        break;
     }
   }
  //結合(ページネーションを使う為第二引数はfalseにする)
   $this->bindModel($hasOne , false);
   //ついでに作成した検索条件を返す
   $conditions[$this->alias.'.address like'] = '%' . $area . '%';
   return $conditions;
}
//Controller/PostalCodesController.php内
//findsアクションを作成
public function finds($area = 'チュウオウクイリフネ'){
   //検索をする
   $result = $this->_findArea($area);
   $this->set(compact('result'));
   //結果をaddress.ctpへと投げる
   $this->render('address');
}

//見つかるまで10回だけ検索しなおす
public function _findArea($area , $count = 0){
   $mb_strlen = mb_strlen($area , 'utf8');
   //2文字以下または10回試行したらエラー
   if($count >= 10 || $mb_strlen <= 2){
      $this->Session->setFlash('その住所では見つかりませんでした。もう少し広範囲になるように設定しなおすか別の検索しなおして下さい');
      $this->redirect('index');
   }
   $conditions = $this->PostalCode->useTips($area);
   //ページネーションに設定
   $this->paginate = array('conditions'=>$conditions,'limit'=>10);
   $result = $this->paginate('PostalCode' , array() , array());
   if(!$result){
      //結果がなかった場合、カウントを繰り上げ検索文字を後ろから1文字減らして再検索
      $result = $this->_findArea(mb_substr($area , 0 , --$mb_strlen),++$count);
   }else{
      //存在したらその検索した文字列をセット
      $this->set('area',$area);
   }
   return $result;
}

//indexアクションを変更
public function index(){
   if($this->request->is('post')){
     $data = $this->request->data('PostalCode.zipcode');
     if(preg_match("/([0-9]{3})\-?([0-9]{0,4})/i" , $data)){
        //郵便番号の形の場合郵便番号検索
        $this->redirect(array('action'=>'address',$data));
     }else{
        //そうでない場合住所検索
        $this->redirect(array('action'=>'finds',$data));
     }
   }
}

今回は既に過去に使ったことのある関数しか使ってないから特に解説しない。
ただ注意として、ページネーションを使う場合、bindModelの第二引数はfalse(永続設定)にするというのを忘れちゃいけないみたい。
というのも第二引数のデフォルトはtrueで、1回の検索で設定がリセットされるようになっている。 一方ページネーションを使うと件数をカウントするクエリが自動で発行されて、2回クエリを投げることになる為に1回の検索でリセットされちゃうとうまくいかないのだ。

あと、今回再帰関数でbindModelを何回も発行する可能性があるんで、最初にunbindModelでリセットしないと大変なことになる。
というのも、今回の作り方だとちゃんとリセットしないと

【8文字で検索した】→【4つの結合とそれぞれの検索条件を発行】→【結果がなかった】→【1文字減らして再検索】→【3つの結合とそれぞれの検索条件を発行】→【でも、実際は4つ結合がされてる(リセットされない設定にしてる)】→【1つの結合には検索条件が指定されない】→【その結合は全データを舐める】→【357万件を読み込みにいく】→→→
【(((゚Д゚)))】

こんなbindModelの発行の仕方は特殊と思うから滅多にならないことだと思うけど、とりあえずこれで10分くらいハマった。。再帰関数を使う場合はそういう設定は毎回リセットするように心がけた方がいいね。

実際にチェック

あとは、address.ctpにセットした$areaを上部に表示するようちょこちょこっといじったり(郵便番号検索ではセットされてないからある場合のみ表示するよう条件分岐してね)、index.ctpの表示をちょっといじった(1行程度の変更だから割愛)。

実際に「東京都中央区入船3丁目」と入力して動作チェックしてみる。ちなみに、郵便番号では「東京都中央区入船」しかない。

下のクエリ発行時間を見ると、ページネーションのカウンタも含め合計8回クエリを発行してて、合計の発行クエリ時間は8ミリ秒だった。

ついでに「東京都中央区日本橋」でも検索してみる。

こっちもやたらと早い。3ミリ秒。ある程度成功と言えそう。

ひらがなでの検索もちゃんとできた。


ここまで、ちょっとずつ段階を踏んで郵便番号検索を作ってきて何とか住所もラクラク検索できるように設定できた。「東京都日本橋」で検索したら「東京都日」で検索されちゃったりする点ではgoo郵便番号検索には劣っちゃうけど、ひらがなやカタカナでも検索できる点は勝ってる(気がする)。前者のもコードを書き換えたら実装できそうだけど、面倒だからいいや。

次回から、ちょっと一休みしてhasOneとかbelongsToとかの結合方法について色々調べたことをまとめてみようかと思います。

【参考リンク】
cakePHP2.xCookBook(英語版)
lvystar:[CakePHP] belongsTo と hasMeny の関係図
PROGRAMMER BLOG:CakePHPのbindModelとunbindModel

2012年6月12日火曜日

郵便番号検索を作る─5.住所検索できるようにする(前編)

今回からやっと住所による郵便番号検索を実装してみる。住所を入力して検索っていうようにしないと使いづらいしね。

■作成手順
・郵便番号データを作成
・郵便番号の全文一致でリストを出力
・郵便番号の前方一致でリストを出力
・都道府県、市区町村によるリストを作成
・住所(漢字、カナ、かな)で検索できるように←今回ココ
・郵便番号データに知人/友人を登録できるようにする
・レイアウトをカスタマイズする

やったこと自体は大したことないんだけど、結構長くなるから2回に分けて記述していきます。今回は作り方の方針を決めたりテーブルを作成したりするだけ。

どうやって実装するか

住所みたいに文字列での検索を実装する際は、likeでの部分一致で検索していくことになるわけだけど、MysqlではMyISAM+Sennaとか使わない限りはインデックスが働かない(Mysql5.6.3ではinnoDB全文検索ができるようになるらしいけど、そっちも日本語はまだ対応してないっぽいし)。

というわけで、SH2さんが書いてる転置インデックスのやり方で検索用テーブルを作って無理矢理インデックスを適応させてみる。ついでにそれをcakePHP内でjoinしてみることにする。
このやり方はInsertやUpdateする場合は向かないけど、これ以上書き換える必要のないテーブルなら問題なさげ。

あと今のテーブル構成だと検索しずらいから、住所フィールドを全部ひとつにまとめたaddressフィールドを作成する。

というわけで、Mysqlの操作
#addressフィールドを作る
mysql> alter table postal_codes add address varchar(100) collate utf8_bin not null default '';
Query OK, 124650 rows affected (12.32 sec)
Records: 124650  Duplicates: 0  Warnings: 0

#データを入れる
mysql> update postal_codes set address = concat(state , city , street , state_kana , city_kana , street_kana);
Query OK, 0 rows affected (5.36 sec)
Rows matched: 124650  Changed: 0  Warnings: 0

#転置インデックス用のテーブルを作成
mysql> create table if not exists postal_tips (
    -> area varchar(2) collate utf8_bin not null default '',
    -> postal_code_id int(11) unsigned not null ,
    -> primary key (area , postal_code_id)
    -> )Engine=Innodb default character set utf8 collate utf8_bin;
Query OK, 0 rows affected (0.41 sec)
解説:外部キーの命名規約】

なお、このpostal_code_idというのが命名規約にそった外部キーの書き方になるみたい。

【外部キー】joinテーブルの名前(アンダースコア型)_id

この命名規約に沿ってると、そのモデルで$hasMany、$belongsTo、$hasOneなどを最初からセットしておく際、外部キーをわざわざ指定しなくてもJOINしてくれるようになる。
(上記のが機能するのはあくまでモデルに最初から変数としてセットした場合か、bindModelを適応した時だけみたい。詳しいことはhasManyなどをまとめる時に書きます。とりあえず、わかりやすいから命名規約には沿って作った方がいいよね)

データを挿入していく

どうやって入れようか迷ったけど、すんごい時間がかかることを覚悟でストアドプロシージャを使って入れていくことにした。
どうせ1回だけだしね。
なお、蛇足ですがinnoDBの場合auto_incrementみたいに順繰りに繰り上がっていく数値以外を主キーにした場合、大量に挿入するとデータが断片化されてクラスタ化が機能しない。
だから、こういう形のテーブルで大量に挿入したら最後Optimize Tableで最適化しないといけない。

#ストアドプロシージャを作成
mysql> delimiter //
mysql> create procedure func()
    -> begin
    ->    declare textlength int;
    ->    declare ins_id int;
    ->    set @now = 1;
    ->    select max(char_length(address)) into textlength from postal_codes;
    ->    
    ->    while textlength > @now do
    ->       insert ignore into postal_tips (area , postal_code_id) 
    ->       select substring(address , @now , 2) as area , id from postal_codes where char_length(address) >= @now;
    ->       set @now = @now + 1;
    ->    end while;
    -> end;
    -> //
Query OK, 0 rows affected (0.07 sec)
mysql> delimiter ;
#呼び出す
mysql> call func();
.
.
.
Query OK, 0 rows affected (47min 36.36 sec)
#この部分保存しわすれてたから少し表記が違うかも。。ただうんこなくらい時間かかった。
#どれくらい挿入されたか確認
mysql> select count(*) as cnt from postal_tips;
+---------+
| cnt     |
+---------+
| 3570206 |
+---------+
1 row in set (3.64 sec)

#もういらないから消す
mysql> drop procedure func;
Query OK, 0 rows affected (0.05 sec)

#テーブルの最適化
mysql> optimize table postal_tips;
------------------+----------+----------+-------------------------------------------------------------------+
| Table            | Op       | Msg_type | Msg_text                                                          |
+------------------+----------+----------+-------------------------------------------------------------------+
| cake.postal_tips | optimize | note     | Table does not support optimize, doing recreate + analyze instead |
| cake.postal_tips | optimize | status   | OK                                                                |
+------------------+----------+----------+-------------------------------------------------------------------+
2 rows in set (18 min 45.28 sec)
PostalTipモデルを作成

単に作成しておくだけで、今回はこっちでは特に何も設定はしない。

//app/Model/PostalTip.php
<?php
    class PostalTip extends AppModel{
    }
?>

とりあえず、今回は時間がかかりすぎて心折れたからここまでにしました。
次回からcakePHPをもうちょっといじって実装していきます。

【参考リンク】
SH2の日記─MySQL InnoDBだけで全文検索

2012年6月6日水曜日

郵便番号検索を作る─4.都道府県/市区町村リストを作成

今まで、最初ということで郵便番号から住所リストを出すっていうように作ってたけど、それだと郵便番号検索としては意味をなさないんで、これからは住所から郵便番号を検索できる機能を作っていく。

■作成手順
・郵便番号データを作成
・郵便番号の全文一致でリストを出力
・郵便番号の前方一致でリストを出力
・都道府県、市区町村によるリストを作成←今回ココ
・住所(漢字、カナ、かな)で検索できるように
・郵便番号データに知人/友人を登録できるようにする
・レイアウトをカスタマイズする
どんな感じで作るか

別にぼくしか使わないからMysqlでフルテーブルスキャンさせてリストを作成する、というのでもいいんだけど、あんまり勉強にはならないんで以下の方法でリストを作成するようにする。

JISコードを利用する
今回JISコードを文字列型で登録した。このJISコードは、左の2桁が都道府県、残りの3桁で市区町村を表してるらしいので、ここにインデックスを張ってリストを作る。

都道府県は新しいテーブルを作成
stateフィールドにインデックスを張った方が断然やりやすいし管理も簡単だけど、他のテーブルを使用するってことをやりたいから(あえて面倒な)この方法で都道府県リストは作成します。

なお、今回はテーブルは作成してもhasManybelongsTohasOneなどのjoin系は使わない。 それはまた別の機会に。

postal_codesテーブルにインデックスを張る

実のところ、こういう場合はstatecity複合インデックスを張る方が、データは膨らんでもカバリングインデックスが使える分数倍早くなるんだけどね、多分。でも今回は他のテーブルも使うということで。

mysql> alter table postal_codes add index jiscode (jiscode);
Query OK, 124650 rows affected (9.73 sec)
Records: 124650  Duplicates: 0  Warnings: 0

続いて、都道府県だけのテーブルを作成する。別にjoinもしないテーブルだし、毎回フルテーブルスキャンになってInnodbのメリットがそんなにないからMyiSamで作成する。

mysql> create table if not exists postal_code_states ( 
    -> id varchar(2) collate utf8_bin not null ,
    -> state varchar(10) collate utf8_bin not null ,
    -> primary key (id)
    -> )Engine=Myisam default character set utf8 collate utf8_bin;
Query OK, 0 rows affected (0.12 sec)

#データを挿入
mysql> insert into postal_code_states (id , state) select left(jiscode , 2) as id , state from postal_codes group by jis ;
Query OK, 47 rows affected (0.44 sec)
Records: 47  Duplicates: 0  Warnings: 0

#データを確認
mysql> select * from postal_code_states limit 2;
+-----+-----------+
| id  | state     |
+-----+-----------+
| 01  | 北海道 |
| 02  | 青森県 |
+-----+-----------+
2 rows in set (0.00 sec)

うん、多分うまくできてる気がする。

モデルを作成

これはとても簡単。命名規約で単数形にするのだけ忘れずに。

//Model/PostalCodeState.php
<?php
    class PostalCodeState extends AppModel{ }
?>
実装していく

作成手順としては

1:listsアクションに引数が何もなければ都道府県リストを出す
2:listsアクションに引数1があれば市区町村リストを出す
3:listsアクションに引数1、2があれば詳細リストを出す
4:詳細リストからはaddressへとリンクを飛ばす

という感じで実装します。

コントローラーでPostalCodeStateモデルを使用できるように

命名規約からはずれるモデル、あるいは複数モデルを使用する場合、$uses変数内にモデルを指定する。
(なお、モデルを指定した場合は命名規約に沿ってるモデル名も書かないといけなくなるみたい)

//Controller/PostalCodesController.php内
 class PostalCodesController extends AppController {
    //使用モデルを指定する
    public $uses = array('PostalCode','PostalCodeState');
 }

これで使用できるようになる。

listsアクションを作成する

ちなみに、listという名前でアクションをつくろうとすると、PHP関数の予約語とかぶるから当然エラーになる(間違えて最初しちゃった。list()もそうだったんだね)。

//Controller/PostalCodesController.php内
  public function lists($state = null , $jis = null){
    //パン屑リストを生成する為の
    $crumb[] = array('インデックス',array('action'=>'index'));
    //$stateがなければ都道府県リストを出す
    if(!isset($state)){
      $result = $this->PostalCodeState->find('all',array('order'=>'id'));
      $crumb[] = array('都道府県リスト',null);
      $this->set(compact('result','crumb','state'));
      $this->render("lists");
    }
  }

次いで、lists.ctpを作成する。

//View/PostalCodes/lists.ctp内
<?php
    foreach($crumb as $row){
    $this->Html->addCrumb($row[0] , $row[1]);
    }
    print $this->Html->getCrumbs('>');
?>
<hr>
<ul>
<?php foreach($result as $row): ?>
<?php if(empty($state)): ?>
<li><?php print $this->Html->link($row['PostalCodeState']['state'] , array($row['PostalCodeState']['id'])); ?></li>
<?php endif; ?>
<?php endforeach; ?>
</ul>

ここまでは簡単ですね。

同じような感じでどんどん作成

あとは作り方は殆ど同じだから、全部作っちゃいます。なお、下記みたいにアクション名の左側にアンダーバー(_)を入れると、URLからダイレクトにアクセスできなくできる。
内部処理だけで使うメソッドにはそうした方がいいかも。

//Controller/PostalCodesController.php内
   public function lists($state = null , $jis = null){
       $crumb[] = array('インデックス',array('action'=>'index'));
       if(!isset($state)){
         $result = $this->PostalCodeState->find('all',array('order'=>'id'));
         $crumb[] = array('都道府県リスト',null);
         $this->set(compact('result','crumb','state'));
         $this->render('lists');
       }else{
         $crumb[] = array('都道府県リスト',array('action'=>'lists'));
         $this->_lists2($state , $jis , $crumb);
       }
   }
   //市区町村リスト
   public function _lists2($state , $jis , $crumb){
       $states = $this->PostalCodeState->read('state',$state);
       if(empty($states)){
         $this->Session->setFlash('そんな都道府県は存在しません');
         $this->redirect('lists');
       }
       if(!isset($jis)){
         $crumb[] = array($states['PostalCodeState']['state'],null);
         $result = $this->PostalCode->find('all',array('conditions'=>array('jiscode like'=>$state . '%') , 'order'=>'jiscode' , 'group'=>'jiscode'));
         $this->set(compact('result','crumb','state','jis'));
         $this->render('lists');
       }else{
         $crumb[] = array($states['PostalCodeState']['state'],array('action'=>'lists',$state));
         $this->_lists3($state , $jis , $crumb);
       }
   }
   
   //詳細リスト
   public function _lists3($state , $jis , $crumb){
       $cities = $this->PostalCode->findByJiscode($jis);
       if(empty($cities)){
         $this->Session->setFlash('そんな市区町村はありません');
         $this->redirect(array('action'=>'lists' , $state));
       }
       $crumb[] = array($cities['PostalCode']['city'],null);
       $result = $this->PostalCode->find('all',array('conditions'=>array('jiscode'=>$jis)));
       $this->set(compact('result','crumb','state','jis'));
       $this->render('lists');
   }
//View/PostalCode/lists.ctp内
<?php
    foreach($crumb as $row){
    $this->Html->addCrumb($row[0] , $row[1]);
    }
    print $this->Html->getCrumbs('>');
?>
<hr>
<ul>
<?php foreach($result as $row): ?>
<?php if(empty($state)): ?>
<li><?php print $this->Html->link($row['PostalCodeState']['state'] , array($row['PostalCodeState']['id'])); ?></li>
<?php elseif(empty($jis)): ?>
<li><?php print $this->Html->link($row['PostalCode']['city'] , array($state , $row['PostalCode']['jiscode'])); ?></li>
<?php else: ?>
<li><?php print $this->Html->link($row['PostalCode']['city'].$row['PostalCode']['street'] , array('action'=>'address' , $row['PostalCode']['zipcode'])); ?>
(<?php print $row['PostalCode']['zipcode']; ?>)</li>
<?php endif; ?>
<?php endforeach; ?>
</ul>

簡単ですね。
最後に、今回初めて使った$this->render()は、アクション名を指定することでそのアクションに合ったctpファイルをレンダリングしてくれる。(今回の例だと、どれもlists.ctpビューを描写する)

$this->render('アクション名');

今回は全部をlists.ctpで描写するようにしたけど、これを使うことでURLは変更せずにビューページを分けたりすることだってできちゃう。

2012年6月3日日曜日

ページネーションを利用する

cakePHPにはページネーションという便利な機能が搭載されてるらしい。
それを使うと、簡単にページの分割やリンクによるソートなどができるようになるそうな。というわけで、今回はそのページネーションについてまとめる。

使い方

主にコントローラー内ビュー内で使用する。

コントローラー内での操作

基本の使い方は以下の通り。

$data = $this->paginate()

ずいぶんと簡単でございますね。上記の場合、命名規約により自動的にコントローラーに対応するモデルが選ばれ、$this->Model->find('all')と同じリクエストが発行されてデータが吐き出される。
なお、デフォルトでは20件のデータが呼び出されるみたい。あと第一引数にモデル名を指定することもできる。

【下記の結果は事実上同じ】
//PostalCodesController内
$result = $this->PostalCode->find('all',array('limit'=>20));
$result = $this->paginate();
$result = $this->paginate('PostalCode');

ちなみに、最初の結果は同じとしてもpaginateを使用するとページ分割の為のツールがビューページで使用できるようになる。

検索条件などを指定する

条件は直接指定する方法と、$this->paginateにてconditionsを指定する方法とがある。

【下記の結果は事実上同じ】
//PostalCodesController内
public $paginate = array(
   'conditions' = array('zipcode'=>'郵便番号')
);
public function address(){
    $this->paginate();
}
//または
public function address($zipcode){
    $this->paginate = array(
       'conditions' = array('zipcode'=>$zipcode)
    );
    $result = $this->paginate();
}
//または
public function address($zipcode){
    $result = $this->pagenate('PostalCode' , array('zipcode'=>$zipcode));
}
Limit値やその他の設定

これは、$paginate内にて行えばいいだけ。
同様に、抽出するフィールドやソートなどもその中で設定できる。
もしも同じコントローラ内で複数のページネーションにて個別の設定が必要な場合、モデル名をキーにすれば実装できる。

//PostalCodesController内
public $paginate = array(
    //PostalCodeモデルの設定
    'PostalCode'=>array(
       'field' => array('zipcode' , 'address') ,
       'limit' => 30 ,
       'order' => array('zipcode' => 'desc')
    ),
    //Exampleモデルの設定
    'Example'=>array(
      //他の設定
    ),
    ...
);

また、モデル内でもpaginate使用時に呼び出されるトリガー関数も設定できるみたい。
ただ今のところ使うことはなさそうだから割愛(引数が多すぎて面倒だから使いたくない)。
詳細はCookBook2.x(英語版)Paginationにざっくりと、ギリギリわかる程度の解説がされてます。

ビューページでの使い方

コントローラ内でページネーションを使えば、ビューページでも同じように使えるようになるみたい。
基本はfindを使った時と同じだからデータの扱い方は変わらない。
(追記:2012/06/17 $hasManyなどのアソシエーションを使ってる場合もちゃんと対応してくれる。使用例はこちら)

また、通常のfindにプラスして$this->Paginatorヘルパーが使えるようになる。

[補足]
ページネーションは、$helpers及び$componentsなどでわざわざ指定する必要はないみたい。デフォルトで使えるようになってます。

sortメソッド
$this->Paginator->sort($キーの名前 , $タイトル , $オプション);

簡単にソートできるリンクを作成してくれる。キーはソートが可能なフィールド名を指定。タイトルを割愛すれば、キーの名前がちゃんと先頭が大文字になって表示される。
オプションには
・'escape' => boolean(true)
・'model' => モデル名
・'direction' => 'asc/desc'

が指定可。指定しなければ、タイトルなどはタグがエスケープされるし、モデルはデフォルトモデルが指定される。directionは最初のソートの方向を指定。

$this->Paginator->sortDir($モデル名,$オプション);
$this->Paginator->sortKey($モデル名,$オプション)

現在ソートされてる方向とソートに使ってるキーを取得するみたい。モデル名はわかるけど、オプションで何を指定するかはCookBookにも書いてないっぽくてよくわからない。

ソートできるカラムを限定する

なお、実際にソートリンクを使ったらわかるけど、リンクを押すと「sort:カラム名」というnamedパラメータが渡される。

http://localhost/cakePHP/postal_codes/sort:zipcode/page:1/direction:desc/

これは、そこをいじればどんなカラムでもソートできてしまうということでもあるよね。

その「どんなカラムでもソートできる」状態を防ぐには、コントローラー内で$this->paginate()の第三引数にてソートできるフィールドを指定する。

//PostalCodesController内
//ソートはzipcodeのみ許可。
$this->paginate(null , array() , array('zipcode'));
numbersメソッド

$this->Paginator->numbers()メソッドを使うことで、「1|2|...10|11|12|13|...|25|26」というようなナンバーリンクを簡単に実装できる。
使い方は以下の通り。

print $this->Paginator->numbers($オプション);
オプションの種類
before/afterナンバーリンクの先頭/最後につける文字列
modelナンバーリンクを使うモデル。
デフォルトは命名規約でのデフォルトモデル
modulusナンバーリンクをいくつ表示するか。デフォルトは8
separator数の間に入れる文字列。デフォルトは「|」
tag文字を囲むタグ。デフォルトは<span>
classtagのクラスを指定
currentClass選択中のナンバーのクラス。
(cakePHP2.1から追加されたっぽい)
firstページナンバーが沢山ある場合modulusによって途中端折られるが、
これを指定することで先頭だけは出したりできる。
数値で指定すると何ページ分出すか指定でき、
文字列で指定するとその文字列のリンクに置き換わる。
lastfirstの最後版。
ellipsis端折られる際に出す文字列。デフォルトは「...」
【例(わかりやすく変な設定で)】
//ビュー内
print $this->Paginator->numbers(array(
    'before'=>'←←←',
    'after'=>'→→→',
    'separator'=>'~',
    'currentClass'=>'redback',
    'class'=>'block',
    'modulus'=>6,
    'first'=>5,
    'last'=>2,
    'tag'=>'s',
    'ellipsis' => '...'
));
//css内
s.block{
    border:1px solid #cccccc;
    padding:2px 5px;
}
s.redback{
    background:red;
    border:2px solid #000000;
    color:white;
}
ジャンプリンク
$this->Paginator->prev($title , $options = array() , $disabledTitle = null , $disabledOptions = array());
$this->Paginator->next($title , $options = array() , $disabledTitle = null , $disabledOptions = array());

いわゆる「前のページへ」「次のページへ」リンクを作成する。
$options及び$disabledOptionsでは「tag」「class」「model」「escape」などが指定できる。多分わかると思うから割愛。
$disabledTitleは、ページ番号が1番ないし最後だったりした場合に代替で表示する文字。nullの場合、リンクが外れたのが表示される。
(ページが1や後尾の時表示したくなければ、空白を指定したら消えてくれた。ちょっとかっこわるいけど)


$this->Paginator->first($title , $options = array());
$this->Paginator->last($title , $options = array());

「最初」「最後」のジャンプリンク作成。動作はnumbersfirstlastとほぼ同じ。
$titleを文字列で指定すればその文字列がでるし、数字を入れたらその数字分のナンバーリンクが表示される。先頭及び後尾の場合表示されない。
オプションは「tag」「after」「model」「separator」「ellipsis」が指定できる。

ページカウンター
$this->Paginator->counter($options = array())

現在のページネーションの情報を簡単に出すことができる。
オプションは
format
rangepagescustomから選べる。$optionsは文字列としても指定でき、文字列で書けば自動的にこのformatが指定される。

range
表示してるレコード番号を出力(例: 11 - 15 of 135)

pages
表示してるページ番号を出力(例: 3 of 27)

custom
専用のタグを使って自分で表示するのをカスタマイズできる。
【タグ一覧】
{:page}現在のページ
{:pages}全ページ数
{:current}表示件数
{:count}全レコード数
{:start}表示開始レコード番号
{:end}表示終了レコード番号
{:model}モデル名

separator
formatを'pages'または'range'にした場合、ofの代わりに何を表示させるか指定できる。

model
使用するモデルを指定。デフォルトは命名規約でのデフォルトモデル。

情報メソッド
$this->Paginator->hasNext($model = null)
$this->Paginator->hasPrev($model = null)
$this->Paginator->hasPage($model = null , $page = 1)
$this->Paginator->current($model = null)

それぞれ「次のページがあるか」「前のページがあるか」「$pageがあるか」「現在のページ番号」を返す。


他にも色々とあるけど、多分基本はこれくらいかな?
他の詳しいことは、cookBook2.xのPaginationComponentPaginatorHelperに載ってます。

これらを踏まえ、郵便番号検索を作る─2.簡単なページを作成 で作ったPostalCodesController.phpaddress.ctpを書き直してみる。

//PostalCodesContoller.php
   public function address($zipcode = 0){
      $this->paginate = array(
          'conditions'=>array('zipcode'=>$zipcode),
          'limit'=>10
      );
      if(!$result = $this->paginate(null , array() , array('zipcode'))){
          $this->Session->setFlash("ヒットしませんでした");
          $this->redirect($this->referer(array('action'=>'index')));
      }else{
          $this->set(compact('result'));
      }
   }


//PostalCodes/address.ctp
<?php 
    $count = 0; 
    $cause = array(0=>'変更なし',1=>'市政・区政・町政・分区・政令指定都市施行',2=>'住居表示の実施',3=>'区画整理',4=>'郵便区調整等',5=>'訂正',6=>'廃止');
    $this->Html->addCrumb('検索',array('action'=>'index'));
    $this->Html->addCrumb('検索結果');
    $this->Html->css('mypage',null,array('inline'=>false));
?>
<?php print $this->Html->getCrumbs(">"); ?>
<hr>
<H1>検索結果</H1>
<?php
    print $this->Paginator->counter('{:count}件中{:start}-{:end}件({:pages}ページ中{:page}ページ)<br>');
    if($this->Paginator->hasPrev()) print $this->Paginator->prev('≪' , array('class'=>'block'));
    print $this->Paginator->numbers(array(
    'class'=>'block',
    'modules' => 6 ,
    'first'=>2,
    'last'=>2,
    'currentClass'=>'red',
    'separator'=>null
    ));
    if($this->Paginator->hasNext()) print $this->Paginator->next(' ≫' , array('class'=>'block'));
?>
<hr>
<?php foreach($result as $rows): ?>
【<?php print ++$count; ?>】
<table border='1' style='margin-bottom:20px; width:700px;'>
    <tr><th width='200'>郵便番号</th><td><b style="color:#292999;"><?php print $rows['PostalCode']['zipcode']; ?></b></td></tr>
    <tr><th width='200'>JISコード</th><td><?php print $rows['PostalCode']['jiscode']; ?></td></tr>
    <tr><th width='200'>都道府県</th><td><?php print $rows['PostalCode']['state']; ?>(<?php print $rows['PostalCode']['state_kana']; ?>)</td></tr>
    <tr><th width='200'>市区町村</th><td><?php print $rows['PostalCode']['city']; ?>(<?php print $rows['PostalCode']['city_kana']; ?>)</td></tr>
    <tr><th width='200'>町域名</th><td><?php print $rows['PostalCode']['street']; ?>(<?php print $rows['PostalCode']['street_kana']; ?>)</td></tr>
    <tr><th width='200'>変更</th><td><?php print $cause[$rows['PostalCode']['cause']]; ?></td></tr>
</table>
<?php endforeach; ?>
<hr>
<?php print $this->Html->link('戻る',array('action'=>'index')); ?><br/>

//webroot/css/mypage.css
.block{
    border:1px solid #cccccc;
    padding:2px 5px;
}
.redback{
    background:red;
    border:2px solid #000000;
    color:white;
}

だいぶ「郵便番号検索」からはそれちゃったけど、こういうのもあるんだということで今回まとめてみました。
次回からは作成手順どおりに都道府県リストなどを作っていこうかなって思います。

それにしても便利だよね。

【参考リンク】
cakePHP2.x(英語版)PaginationComponent
cakePHP2.x(英語版)PaginatorHelper