ラベル Model の投稿を表示しています。 すべての投稿を表示
ラベル Model の投稿を表示しています。 すべての投稿を表示

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年5月31日木曜日

beforeFind、afterFindで特定のフィールド全てを加工する

熱とかでダウンしてた為久々のエントリ。今、beforeFindの練習も兼ねて郵便番号が送られたら、小数(decimal型)に加工ということをbeforeFind時に行ってるんだけど、前回のコードだと、全ての郵便番号フィールド(zipcode)を加工してくれず、ただzipcodeと送られた時だけにしか働いてくれない。

ちなみに、前回のコードはこんな感じ。

//Model/PostalCode.php内
   public function beforeFind($queryData){
      if(!empty($queryData['conditions']['zipcode'])
         && preg_match("/^(\d{3})\-?(\d{0,4})$/i",$queryData['conditions']['zipcode'],$match)){
         $zip_len = strlen($zipcode = (int)($match[1].$match[2]));
         if($zip_len < 7){ //数値が7桁未満の場合
            $from_zipcode = (int)($zipcode . "0") / pow(10 , $zip_len + 1);
            $to_zipcode = (int)($zipcode . "9") / pow(10 , $zip_len + 1);
            $queryData['conditions']['zipcode BETWEEN ? AND ?'] = array($from_zipcode   , $to_zipcode);
            unset($queryData['conditions']['zipcode']);
         }else{//それ以外
            $queryData['conditions']['zipcode'] = (int)($match[1].$match[2]) / 10000000;
         }
      }
   }

この場合、find時に単純に'conditons'=>array('zipcode'=>'郵便番号')と送った時はbeforeFindさんが仕事をしてくれる。
でも、cakePHPのfindでは色んな形でこのzipcodeを送る機会がでてくる。

たとえばIN句で送りたい場合、'zipcode'=>array('郵便番号1','郵便番号2',...)と送ることになるようで、そういう場合、上記のだとエラーが起きちゃうと思う。というか、さっき試してみたらやっぱり起きた(._.)最近は公務員だってもうちょっと融通きいてくれるけど、こいつは完全に「zipcode=文字列」しか受け付けない。

そんなわけで、今回はどんなパターンで送ってもちゃんと仕事をしてくれるように作り替えてみようと思います。

検索の仕方のパターンを調べる

とりあえず、サブクエリはcookbookだけを見てもどんなデータの形になるのか全然わからないんで、今回は無視。以下は全部今回のPostalCodeモデルの郵便番号(zipcode)を検索する例として記述する。

<テーブル構成はこちら>

モデル名を指定する場合と指定しない場合がある

通常の場合、zipcodeで検索する場合以下の形でできる。

$this->PostalCode->find('all',
    array(
       'conditions'=>array(
          'zipcode'=>'103-0003'
        )
    )
);

ただ、hasManyなどで複数テーブルをjoinする場合、きちんとテーブル名(モデル名)を指定しないとエラーが起きることがある(と思う)。
その場合は以下のように記載する。(以下、途中は省略します)

'conditions'=>array(
  'PostalCode.zipcode'=>'103-0003'
)
IN句や範囲検索、OR句での検索の場合

上記の場合、SQLではzipcode = 郵便番号として発行される。
等号としてじゃなく、範囲検索などを使いたい場合は以下のようになる。

■IN句を使う場合──値を配列で記載する。

'zipcode'=>array(
   '郵便番号1','郵便番号2',... 
)

(発行) zipcode in (郵便番号1,郵便番号2,...)

■NOT IN句を使う場合──NOTキーで値を配列で記載する。

'NOT'=>array(
   'zipcode' => array('郵便番号1','郵便番号2',... )
)

(発行) zipcode NOT in (郵便番号1,郵便番号2,...)

■OR句を使う場合──"OR"キーでの配列で記載

'OR'=>array(
   'zipcode'=>'郵便番号',
   '他のフィールド'=>'他の条件'
)

(発行) ((zipcode = 郵便番号) OR (他のフィールド = 他の条件))

■> , < , >= , <= , !=での検索──キーの最後にそれらを記載

'zipcode >' => '郵便番号'
(発行) zipcode > 郵便番号

■Betweenでの検索──「キー名 BETWEEN ? AND ?」と記載

'zipcode BETWEEN ? AND ?' => array(値1,値2)
(発行) zipcode BETWEEN 値1 AND 値2

要は、フィールド名のキーの形は検索の仕方でいろいろ変わっちゃったり、前についたり後ろについたり、配列の中に入り込んだり、値も配列だったり色々とするのね。
これをひとつの関数で全部補うというのは面倒だなぁ。

再帰関数を使って実装してみる

うまくいくかわからないけど、とりあえず全てのキーを再帰関数で読み取り、特定のフィールド名が存在したら値を操作するというものを作成してみた。
ぼくの場合、datetime型をintで管理する、ということをよくするし、色々と使いそうだからAppModel内に作成する。

//AppModel内
//特定のフィールドをキーごと加工する
public function changeField(&$queryData , $fieldName = null , $callback = null , $isarray = false){
  if(empty($callback) || empty($fieldName)) return;
  if(is_array($queryData)){
     foreach($queryData as $key => $rows){
        if( 
          preg_match('/'.implode('|',array_map(
             function($a){ 
                return  strtr($a, array("-"=>"\-","."=>"\.","?"=>"\?")) . '$';
             },(array)$fieldName)).'/i',$key , $match)
          && 
          ($isarray || !is_array($rows)))
        {
           if(is_object($callback)){
              $callback(&$queryData[$key] , $key);
           }else{
              if(is_array($callback)){
                 $_callback = array_shift($callback);
                 $arg = array_values($callback);
              }else{
                 $arg = array();
                 $_callback = $callback;
              }
              array_unshift($arg , &$queryData , $key);
              call_user_func_array(array($this , $_callback) , $arg);
           }
        }else{
           self::changeField(&$queryData[$key] , $fieldName , $callback , $isarray);
        }
     }
  }
}
//AppModel内
//特定のフィールドの値全てを加工する
public function changeValue($queryData , $fieldName = null , $callback = null , $recursive = 0, $current = array()){
   if(empty($callback)) return $queryData;
   if(is_array($queryData)){
     $ary = array();
     foreach($queryData as $key => $rows){
       $_current = $current;
       array_push($_current , $key);
       if($recursive > 0 && count($_current) > $recursive) array_shift($_current);
       $ary[$key] = self::changeValue($rows , $fieldName , $callback  , $recursive , $_current);
     }
     return $ary;
   }else{
     if(preg_match("/(".strtr($fieldName , array("-"=>"\-","."=>"\.","?"=>"\?"))."[^\n]*)/i" , implode("\n" , $current),$match)){
       $key = $match[1];
       if(is_object($callback)){
          return $callback($queryData , $key);
       }
       if(is_array($callback)){
          $_callback = array_shift($callback);
          $arg = array_values($callback);
       }else{
          $arg = array();
          $_callback = $callback;
       }
       array_unshift($arg , $queryData , $key);
       return call_user_func_array(array($this , $_callback) , $arg);
     }else{
       return $queryData;
     }
   }
}

多分、基本的に値を変更するだけなら全部changeValueだけでできると思う。ただ、今回ぼくの郵便番号検索の作り方だと値によってはBETWEENに変更したりという(面倒な)ことをするんで、そういう場合はchangeFieldを使う。
使い方は以下の通り。

changeValue(クエリデータ、検索フィールド名、コールバック、階層(どこまで追うか))
changeField(クエリデータ、フィールド名、コールバック、フィールドの値が配列の場合も適応するか)

コールバックは以下のように指定
・文字列(関数名)
・配列(array(関数名,引数1,引数2,...))
・無名関数

コールバック関数(その値、キー、他引数)

ちなみに、キーの名前に改行が入ってたりすると失敗する仕様です。
コールバックは、無名関数でもモデル内の関数でもできるようにしてみた。
これでできるといいんだけど。

前回のを書き直してみる
//PostalCode.php内
 public function beforeFind($queryData){
     $this->changeField($queryData['conditions'] , array('zipcode' , 'zipcode like') , array('setZiptoBetween','zipcode'));
     $queryData = $this->changeValue($queryData , 'zipcode' , 'changeZipCode');
     return $queryData;
 }
 
 protected function setZiptoBetween(&$queryData , $key , $field){
   if(preg_match('/(\d{3})-?(\d{0,4})/' , $queryData[$key] , $match)){
     if(strlen($val = $match[1].$match[2]) < 7){
        unset($queryData[$key]);
        $queryData[$this->alias . '.' . $field . ' BETWEEN ? AND ?'] = array($val.'0',$val.'9');
     }
   }
 }
 
 protected function changeZipCode($data){
   if(preg_match('/(\d{3})-?(\d{0,4})/',$data , $match)){
       $data = (int)$match[1].$match[2];
       return $data / pow(10 , strlen($match[1].$match[2]));
   }else{
       return $data;
   }
 }

 public function afterFind($result){
   return $this->changeValue($result , 'zipcode' , 
     function($data){
       if(preg_match("/^(\d{3})(\d{4})$/i",str_pad($data * 10000000 , 7 , '0' , STR_PAD_LEFT), $match)){
          return $match[1] . '-' . $match[2];
       }else{
          return $data;
       }
     }
   );
 }

できたっぽい…けど、もうちょっとスマートなやり方もある気がするなぁ。
せめて、2つの関数を1つにまとめたいけど、ぼくの実力じゃこれが限界ですた。

というより、こういう関数はcakePHP内で実装されてそうな気もするんだけど、そもそも値を事前に加工するというようなことはしないものなのかな?