cakephperの日記(CakePHP, Laravel, PHP)


継続的WebセキュリティテストサービスVAddyを始めました!

2010-08-26

HABTMの中間テーブルがAppModelオブジェクトになってしまう問題の対応

今回の話はCake1.2で確認しましたが、1.3でも同じだと思います。


CakeのHABTMは中間テーブルのモデル(例えばDivisionsUserモデルなど)を作らなくても動いてくれて便利なのですが、意外とはまりポイントがあるので書きます。

本解決策により、下記の方のような悲鳴もなくなるでしょう。

http://d.hatena.ne.jp/tsugehara/20100213/1266071529

今回説明で利用するサンプルコードは、上記のブログの例を元にしています。

中間テーブルにid以外のカラムを追加したりして(例えばアクティブフラグとか)、中間テーブルのモデルも別途作り、あえてそのモデルをusesやClassRegistry::init()で読み込んだ場合、そのモデルで指定したプロパティなどが全てなくなってしまいます。バリデーションやリレーション設定全てがなくなるので泣きたくなるでしょう。

理由は、モデルのリンクを自動で生成する際に、Cakeが中間テーブルモデルの生成時にnew AppModel()をして入れてしまうから、つまりControllerで使う$this->DivisionsUserの中身がAppModelオブジェクトになってしまう。

今回は、ここに正しくDivisionsUserモデルのオブジェクトを入れる方法を説明します。



まずはDivisionsUserモデルファイルを作っておきます(作らないとCakeがAppModelオブジェクトとしてクラスを生成してしまう)

<?php
class DivisionsUser extends AppModel {
        var $belongsTo = array(
                'Division' => array(
                        'className' => 'Division',
                        'foreignKey' => 'division_id',
                        'conditions' => '',
                        'fields' => '',
                        'order' => ''
                ),
                'User' => array(
                        'className' => 'User',
                        'foreignKey' => 'user_id',
                        'conditions' => '',
                        'fields' => '',
                        'order' => ''
                )
        );
}



あとは、ここのURL(http://d.hatena.ne.jp/tsugehara/20100213/1266071529)にあるとおり、Model::hasAndBelongsToManyの中でwithキーを指定します。

<?php
class Division extends AppModel {
  var $hasAndBelongsToMany = array(
    'User' => array(
      'className' => 'User',
      'joinTable' => 'divisions_users',
      'foreignKey' => 'division_id',
      'associationForeignKey' => 'user_id',
      'unique' => true,
      'conditions' => '',
      'fields' => '',
      'with' => 'DivisionsUser', //ここが重要 値はnullじゃなければ空文字でもOK. 
      'order' => '',
      'limit' => '',
      'offset' => '',
      'finderQuery' => '',
      'deleteQuery' => '',
      'insertQuery' => ''
    )
  );
}

これだけです!!! 極力はまるポイントを避けるために、毎回withキーは空文字でも良いので指定しておいたほうがよいと思います。

[追記] withキーが空文字の場合は、中間テーブルのモデルファイルが存在しない場合はAppModelオブジェクトが使われ、中間テーブルのモデルファイルが存在すればそれをインスタンス化してくれます。


ここから、詳細を解説します。

CakeのCoreコードのcake/libs/model/model.phpが重要で、__generateAssociationメソッドの中にすべての問題があります。

このメソッドの中で、(ポイント1)アソシエーション情報を見て、定義されたキー(例えばwithやjoinTableなど)の存在をチェックし、キーが存在しない・もしくはキーの値がnull指定の場合は、(ポイント2)switch文の中で必要な情報をセットします。そこでwithの場合は、$dynamicWith = true;がセットされてしまうため、(ポイント3)その後のAppModelインスタンスを生成するかどうかの判定で常にnew AppModel()される処理に入ってしまいます(ポイント4)。

<?php

  function __generateAssociation($type) {
    foreach ($this->{$type} as $assocKey => $assocData) {
      $class = $assocKey;
      $dynamicWith = false;

      foreach ($this->__associationKeys[$type] as $key) {

        // ポイント1
        if (!isset($this->{$type}[$assocKey][$key]) || $this->{$type}[$assocKey][$key] === null) {
          $data = '';

          switch ($key) {

            ///// 省略
			
            // ポイント2
            case 'with':
              $data = Inflector::camelize(Inflector::singularize($this->{$type}[$assocKey]['joinTable']));
              $dynamicWith = true;
            break;

            ///// 省略
			
          }

        }
      }

      if (!empty($this->{$type}[$assocKey]['with'])) {
         ///// 省略
	  
        // ポイント3
        if (!ClassRegistry::isKeySet($joinClass) && $dynamicWith === true) {

          // ポイント4
          $this->{$joinClass} = new AppModel(array(
            'name' => $joinClass,
            'table' => $this->{$type}[$assocKey]['joinTable'],
            'ds' => $this->useDbConfig
          ));

        } else {
		
          ///// 省略
		

つまり、$dynamicWith = true;になってしまうswitch文の中が問題なので、ここを回避するためにwithキーを指定すれば良いということです。


HABTMのCakeBookにもWithの説明があります。

http://book.cakephp.org/ja/view/83/hasAndBelongsToMany-HABTM

2009-12-21

save前のcreateの使い方

Cakephp1.2.5を使ってます。

たまにごっちゃになる、model::create()の話。APIマニュアルにもあるとおり、Insert文が発行されるsave()の前にだけ使います。Update文の時はcreate()は使わない方が良いです。bakeしたコントローラでも、addアクションではcreate()を使っていて、editアクションではcreate()を使ってないです。

http://api.cakephp.org/class/model#method-Modelcreate

create()では、保持している$this->idを削除していると同時に、save時のデータにフィールドが存在しない場合は、DBのフィールドからデフォルト値を取得して自動でSaveデータにセットしてしまうため、更新対象じゃないカラムに初期値が上書きされます。。。saveメソッドの引数で保存対象のフィールドを絞っていれば問題ないですが、そうじゃないと大変。。

update時のsaveで与えるデータにhogeフィールドのデータが存在しない場合は、たとえDBhogeフィールドに値が入っていても、DBで定義したデフォルトバリューで上書きされます。

http://yashio.wordpress.com/2009/12/19/model%e3%81%aecreate%e3%81%a8save%e6%99%82%e3%81%ab%e8%b5%b7%e3%81%93%e3%82%8b%e3%80%81%e3%83%87%e3%83%95%e3%82%a9%e3%83%ab%e3%83%88%e5%80%a4%e8%a8%ad%e5%ae%9a%e3%81%ab%e3%81%a4%e3%81%84%e3%81%a6/



Model::create()の使いどころとして、下記のように覚えておけばOK.

insertを発行するSaveメソッドが複数回呼ばれる時に、毎回create()を実行し、Saveする。

それ以外のSave時は特に事前にcreate()する必要なし(Insert/Updateに関わらず)

SaveメソッドがInsertなのかUpdateなのか事前に判断するのは嫌だという場合(例えばaddとeditアクションを共通化するような場合)は、Save前にコントローラ

$this->Model->id = false;

を指定し、create()は使わない(ループ処理があるなら、そのループの中でidにfalseをセットする)。この時、セーブするPostデータにプライマリキーのIDをセットしなければInsert, セットしておけばUpdateになります。

Modelという文字列は、利用するモデル名に変更してください。PostモデルでSaveしたい場合は、$this->Post->id = false;になります。

追記2:

ZistaさんからTwitterでコメントを頂きました。ありがとうございます。

saveメソッドでInsertになるかUpdateになるかの判断として、UPDATEになる条件は、セーブするPostデータに、プライマリキーの値があるか、もしくは、モデルのプロパティのid($this->Model->id)に値がセットされているか、そしてDBに該当レコードが存在する場合です。それ以外はInsertになります。

必ずInsertさせたい場合は、$this->data[Model]['id']=nullにして、$this->Model->id=falseにすればOK. 

プライマリIDの値をセットしてSaveすると、select count(*) from hoge where id=50;みたいにして該当レコードがあるかチェックします。存在すればUpdateをかけて、存在しなければセットしたIDの値でInsertをします。

2009-07-27

バリデーションメッセージをDryにしつつ国際化

CakePHP1.2.3を利用しています。

CakePHPのバリデーションエラーメッセージは、各モデルに書いたりしますが、ここではgettextの__()を使った国際化の記述ができません。CakeBookにそのための回避策が一応書いてありました。

http://book.cakephp.org/ja/view/163/Localization-in-CakePHP

下記の記述をapp_model.phpに入れとけば、エラーメッセージ出力時に__()を付けてくれるので、言語ごとにエラーメッセージが切り替わります。

function invalidate($field, $value = true) {
	return parent::invalidate($field, __($value, true));
}

上記が一番楽なパターンではあるのですが、国際化対応する箇所をコマンド一発で抽出してくれる"cake i18n"コマンドだと、バリデーションエラーメッセージは抽出されません(エラーメッセージ自体は__()で記述されていないので)

ちなみにcake i18nの使い方は下記が参考になります。

http://cakephp.seesaa.net/article/87269708.html


cake i18nコマンドでもエラーメッセージが抽出できる方法はないかとIRCでK1LoWさんと話してて、それを実現する簡単なapp_modelを作ったので公開します。

ダウンロードはこちらから

http://cake.eizoku.com/source/validation_i18n.zip


機能は下記の通りです(最初の2つはvalidationのメッセージ出力をDRYにしてみる by Writing Some Codeなどで実現されています)

  • 各モデルのバリデーションエラー定義を一箇所で管理可能
  • メッセージ中の数値などは、printf系の変換指定が可能(%dなど)
  • エラーメッセージは定義箇所に__()を使って、国際化の定義が可能(cake i18nで取得対象となる)
  • オプション:app_modelにまとめたエラーメッセージ定義を各モデルで上書き可能
  • オプション:エラーメッセージにフィールド名を自動付与

最後のエラーメッセージにフィールド名は、画面の上部にエラーメッセージをまとめて出力したい場合に、フィールド名がないと、どの項目のエラーか分からないので、そのための機能です。フィールド名も国際化して出力します。


ソースコード

基本の流れは、_getDefaultErrorMessagesI18n()メソッド内で連想配列にエラーメッセージをgettext形式で定義し、それをbeforeValidate()時にモデルのバリデーションエラーメッセージにセットしているだけです。

追記

この記事で説明するコードは色々と機能を付け足した後のやつで、もっとシンプルな初期段階のソースコードはここにあります。こっちの方が理解しやすいかもしれません。

<?php 

class AppModel extends Model {


	/**
	 * Concatenate a field name with each validation error message in replaceValidationErrorMessagesI18n().
	 * Field name is set with gettext __()
	 *   true: Concatenate
	 *   false: not Concatenate
	 *
	 * @var boolean
	 * @access protected
	 */
	var $_withFieldName = false;


	/**
	 * Error messages
	 *
	 * @var array
	 * @access protected
	 */
	var $_error_messages = array();


	/**
	 * Define default validation error messages
	 * $default_error_messages can include gettext __() value.
	 *
	 * @return array
	 * @access protected
	 */
	function _getDefaultErrorMessagesI18n(){
		//Write Default Error Message
		$default_error_messages = array(
			'require' 	=> 'Please be sure to input.',
			'email_invalid' => __('Invalid Email address.',true),
			'between' => __('Between %2$d and %3$d characters.',true),
		);

		return $default_error_messages;
	}


	/**
	 * Set validation error messages.
	 *
	 * To change default validation error messages,
	 *  set $add_error_message in each model.
	 *
	 * @param array $add_error_message
	 * @param boolean $all_change_flag
	 *    true: change all default validation error messages
	 *    false: merge $add_error_message with default validation error messages
	 * @access public
	 */
	function setErrorMessageI18n( $add_error_message = null, $all_change_flag=false ) {


		$default_error_messages = $this->_getDefaultErrorMessagesI18n();

		if( !empty( $add_error_message ) && is_array( $add_error_message ) ){
			if( $all_change_flag ){
				$default_error_messages = $add_error_message;
			}else{
				$default_error_messages = array_merge( $default_error_messages, $add_error_message );
			}
			$this->_error_messages = $default_error_messages;

		}elseif( empty($this->_error_messages)  ){
			$this->_error_messages = $default_error_messages;
		}


	}

	/**
	 * get validation error messages
	 *
	 * @return array
	 * @access protected
	 */
	function _getErrorMessageI18n(){
		return $this->_error_messages;
	}


	/**
	 * Replace validation error messages for i18n
	 *
	 * @access public
	 */
	function replaceValidationErrorMessagesI18n(){
		$this->setErrorMessageI18n();

		foreach( $this->validate as $fieldname => $ruleSet ){
			foreach( $ruleSet as $rule => $rule_info ){

				$rule_option = array();
				if(!empty($this->validate[$fieldname][$rule]['rule'])) {
					$rule_option = $this->validate[$fieldname][$rule]['rule'];
				}

				$error_message_list = $this->_getErrorMessageI18n();
				$error_message = ( array_key_exists($rule, $error_message_list ) ? $error_message_list[$rule] : null ) ;

				if( !empty( $error_message ) ) {
					$this->validate[$fieldname][$rule]['message'] = vsprintf($error_message, $rule_option);

				}elseif( !empty($this->validate[$fieldname][$rule]['message']) ){
					$this->validate[$fieldname][$rule]['message'] = 
						__( $this->validate[$fieldname][$rule]['message'], true);
				}


				if( $this->_withFieldName && !empty($this->validate[$fieldname][$rule]['message']) ){
					$this->validate[$fieldname][$rule]['message'] = 
						__( $fieldname ,true) . ' : ' . $this->validate[$fieldname][$rule]['message'];
					
				}
			}
		}
	}


	function beforeValidate(){
		$this->replaceValidationErrorMessagesI18n();
		return true;
	}

}

使い方 (基本編)

各モデルのバリデーション定義では、下記の規則に従って

var $validate = array(
	'項目名' => array( 
		'規則名' => array( 'rule' => array( 'バリデーション関数' ) ) 
	) 
);

今までと同じように定義します(下記サンプル)

var $validate = array(
	'email' => array(
		"email_invalid" => array(
			'rule' => VALID_EMAIL,
			'required' => true,
		),
	),
)

app_modelの下記の箇所にシステムで共通して使うエラーメッセージ(今後はデフォルトエラーメッセージと呼ぶことにします)を連想配列で記述します。配列のキーは各モデルで定義するバリデーションの規則名になります。

<?php
function _getDefaultErrorMessagesI18n(){
	//Write Default Error Message
	$default_error_messages = array(
		'require' 	=> 'Please be sure to input.',
		'email_invalid' => __('Invalid Email address.',true),
		'between' => __('Between %2$d and %3$d characters.',true),
	);
	return $default_error_messages;
}

今回の例だと、email_invalidのルールでエラーになった場合は、「Invalid Email address.」が国際化されて表示されます。betweenのように何文字以上という数値が変わるものも、上記例のようにすれば対応できます。



使い方 (各モデルごとにエラーメッセージを変えたい)

デフォルトエラーメッセージは、上記のようにすれば一元管理可能ですが、同じバリデーションルールでも、あるモデルではエラーメッセージを変えたい場合は、モデル側のファイルを下記のようにして上書き(マージ)が可能です

<?php
class User extends AppModel {
	function beforeValidate(){
		$error_messages = array(
			'email_invalid' => __('Oh, Invalid Email address!!!',true),

		);
		$this->setErrorMessageI18n($error_messages, false);
		parent::beforeValidate();
		return true;
	}
}

$this->setErrorMessageI18n()をapp_modelのbeforeValidate前に( parent::beforeValidate()前に )実行すれば、上書きできます。この場合だと、Userモデルの場合のみ、メールのエラーメッセージがapp_modelで定義したものから変わります。

この方法は、デフォルトエラーメッセージを残しつつ上書きしたいものだけマージする方法ですが、デフォルトエラーメッセージを全て使いたくない場合は、マージではなくて入れ替えが可能です。$this->setErrorMessageI18n()の第2引数にtrueを入れればそれが実現可能です。



使い方 (エラーメッセージにフィールド名を自動付与)

エラーメッセージを画面上部に一括で出したい場合などのために、エラーメッセージ毎にフィールド名の自動付与が可能です。app_modelの下記のプロパティをtrueにしてください。

var $_withFieldName = true;

下記のような画面になります。viewファイル側で__('email')とかフィールド名を定義して、poファイルを作っておけば、このエラーメッセージのフィールド名も日本語などになります(email:という箇所がメール:などに変わります)。

f:id:cakephper:20090727192847j:image


参考

今回の実装にあたり、下記を参考にさせていただきました。

validationのメッセージ出力をDRYにしてみる by Writing Some Code


CakePHPによる実践Webアプリケーション開発」の本(85ページあたり)



今後の予定

とりあえずプラグイン化しているので、プラグインとしてメンテしていく予定です。下記のgithubのmodels/behaviors/validation_error_i18n.phpがそれです。

http://github.com/ichikaway/cakeplus/tree


  • Task
    • デフォルトメッセージの充実
    • TestCaseの作成
    • bakeryへの記事投稿

もっと良いアイディアとか、使い方が分からないとかあれば、何でも良いのでコメント下さい。

2009-07-23

find条件とか、unbindModelのメモ

自分用メモ

findの条件でcurrent_date使う場合。配列のキーを指定せずに、値に全部入れる
<php
$field = array( 'id' );
$cond = array( 'activeid' => $id, 'status' => 1, 'activeid_expire >= current_date' );
$result = $this->find( 'first', array( 'conditions' => $cond, 'fields' => $field )  );

Findの条件指定は下記に色々と書いてある

http://book.cakephp.org/ja/view/74/Complex-Find-Conditions


findの第1引数で指定できるもの

(all / first / count / neighbors / list / threaded )


unbindModelについて

hasManyとかしてる場合、必要なければ一時的にアソシエーションをはずす(下記はmodel内のメソッドで実行する場合)

<?php
$this->unbindModel(array('hasMany'=>array('Post')));

一時的じゃなくて恒久的にアソシエーションをはずす場合は、第2引数にfalseを指定する

<?php
$this->unbindModel(array('hasMany'=>array('Post')),false);

2009-04-17

意外と知られていない? queryメソッドで値をbindできるってこと

CakePHP 1.2.2を利用してます。この話は1.2だったらどれでも適用できると思います(cake1.1は分かりません)

ーーーーーーーーーーーーーーーーー

CakePHP2であれば、Model::query()が、擬似bindではなくPrepared StatementでSQL発行するので安心です

http://d.hatena.ne.jp/cakephper/20120204/1328324327

ーーーーーーーーーーーーーーーーー

集計用SQLなど、SQLが複雑になったりする場合や、SQL文を直接書いたほうが開発効率が上がる個所に関しては、Model::query()を利用して、下記のように直接SQL文を発行してます。

$this->Model->query("SELECT `Post`.`id` FROM `posts` AS `Post` WHERE `Post`.`id` = 100");

実はqueryメソッドは、第2引数で配列を与えると、値をbindしてくれます。(SQL文の中でbindさせたい個所は?にしておきます)

$sql = "SELECT `Post`.`id` FROM `posts` AS `Post` WHERE `Post`.`id` = ? LIMIT ?";
$this->Model->query($sql, array(100,1));

こうすると、下記のSQL文が発行されます

SELECT `Post`.`id` FROM `posts` AS `Post` WHERE `Post`.`id` = 100 LIMIT 1

変数をダイレクトにSQL文に埋め込む場合、エスケープ処理などをし忘れると大変ですが、このbindの機能を使っておけば、引数で与えた配列の値は自動でエスケープ処理してくれるので、安心です。


bindしてくれる話って、CakeBookにも書いてないしAPIにも書いてないんだけどw



追記2

ここに、queryメソッドを使うとデフォルトではキャッシュされた結果が利用されるとありました。バインドする場合で、キャッシュを効かせないようにするためには、第3引数にfalseを指定すればOK(1.2.2のソースを読む限りはそれでいける)。

$sql = "SELECT `Post`.`id` FROM `posts` AS `Post` WHERE `Post`.`id` = ? LIMIT ?";
$this->Model->query($sql, array(100,1), false);


追記

miauさんから下記の意見を頂きました。ありがとうございます。

「bind できる」っていうと DB のバインド機構が使われると思われてしまうような・・・。
ソースちゃんと読んでませんけど、
CakePHP はプレースホルダを自前で置換してる擬似バインド機構ですよね?
http://twitter.com/miau_jp/status/1539713129

結論は、Cakeが自前でやってる擬似バインドです。



ちょっとソースを追ってみました。

まず、Model::query()を呼び出すと、DboSource::query()に渡されます。ここで第1引数しかない場合(SQL文のみ)は、DboSource::fetchAllメソッドが呼ばれてSQLが実行されます。


今回のように、第2引数が配列の場合は、その配列に対してvalueメソッドが呼ばれて(呼ばれるのは、MySQLであればDboMysql::value()とか)、値のエスケープ処理(mysql_real_escape_string)が実行されます。そのエスケープ処理された配列を、String::insert()に渡して、擬似的にバインドさせて一つのSQL文を作ります。(String::insertなんてあったんだ、知らなかった。)


最後にそれを、DboSource::fetchAll()に渡して、DboSource::execute()が呼ばれて、SQLが実行されます。余談ですが、execute()では、SQLの実行時間とかSQL文を保持させてます。これを使って最初のSQL Explainコンポーネントを作りました。

executeするとDboMysql::_execute()が呼ばれて、その中でmysql_query()が実行されてSQL処理が走ります。