miauのブログ

はてなダイアリー「miauの避難所」をはてなブログに移行しました

SoftDeletable で関連テーブルの deleted も見るように

CakePHP には論理削除を扱う SoftDeletable というプラグインがあります。

今回はじめて使ったんですが、関連テーブル($hasMany とか)については deleted = 1 になっていても、そのレコードも拾ってしまう造りのようで。今回のシステムは関連もそこそこ複雑で論理削除データを見る箇所は少ないので、関連テーブルの論理削除データを拾わないようにしてみました。

関連テーブルの対応を行う前に

SoftDeletable の基本的なところ

がいい感じの解説になっています。

最新版の状況

soft_deletable.php のリビジョンログ を見るとわかりますが、最新のリリース版である 1.1.38 の後に CakePHP 1.2 RC1 用の対応が入っているので、SVN から

をチェックアウトして使ったほうがいいでしょう。

実際の使い方

私は

を app/plugins/cake_syrup として取得して、app/models/app_model.php で、

class AppModel extends Model {
	var $actsAs = array('CakeSyrup.SoftDeletable');
	:
}

のように指定しています。

  • $actsAs での指定がそれっぽくなるようにディレクトリ名をアンダースコアに変更している
  • すべてのモデルで使いたいので、個別のモデルでなく AppModel で指定している

あたりがちょっと標準的ではないかもしれません。

関連テーブルの論理削除対応

まずはパッチを探してみる

論理削除まわりに限らず、Bakery のページには結構バグ報告や修正案のコメントが寄せられています。

このコメント欄の 18個目 は beforeFind() で $hasMany で指定されたモデルの deleted も見に行くような修正案です。本当はこの形が無駄がなくていい(余計なデータを SELECT してこない)んですが、recursive まわりを真面目に実装するのは大変そうなので、こちらはパス。

SourceForge のほうにチケットとして上がっていた修正案で、afterFind() での対応例です。afterFind() での対応であれば、recursive まわりの対応も要りませんし(取得されたデータに対して処理すればいいので)、こちらの方法をベースに進めてみることにします。

afterFind() での対応

載っているのはあくまでも参考コードなので、

  • インデントその他が崩れているのを整形
  • $this->setting を $this->__settings に変更
  • enabled の部分を find に変更
  • 添字を振り直すために sort() してたけど、それだと順番が変わってしまうので array_values() に変更

すると、こんな感じに。

function afterFind(&$model, $results, $primary) {
	// get list of associated models and their types
	$assoc_models = $model->getAssociated();

	// loop through query results
	foreach ($results as &$result) {
		// loop through active PseudoDelete enabled models
		foreach ($assoc_models as $k => $v) {
			// check if model data was retireved
			if (isset($this->settings[$k]) && $this->settings[$k]['enabled'] &&
			    isset($result[$k])) {
				// handle different types of associations
				if (in_array($v, array('belongsTo', 'hasOne'))) {
					// check if delete field was returned with result and if flag is set to deleted
					if (isset($result[$k][$this->settings[$k]['field']]) &&
					    (bool)$result[$k][$this->settings[$k]['field']] == true) {
						// remove result form query results
						unset($result[$k]);
					}
				} elseif (in_array($v, array('hasMany', 'hasAndBelongsToMany'))) {
					// loop through records
					foreach ($result[$k] as $assoc_k => $assoc_v) {
						// check if delete field was returned with result and if flag is set to deleted
						if (isset($assoc_v[$this->settings[$k]['field']]) &&
						    (bool)$assoc_v[$this->settings[$k]['field']] == true) {
							// remove result form query results
							unset($result[$k][$assoc_k]);
						}
					}
					// reset array keys - due to unset() some array indexes may be missing
					//  - done more for consistance with original result set
					$result[$assocModel] = array_values($result[$assocModel]);
				}
			}
		}
	}

	return $results;
}

このコードでも、recursive が 1 以下なら普通に動作すると思いますが、今回は recursive = 2 な処理も結構あるので、もう一歩進めてみます。

recursive に対応したコード

soft_deletable.php の beforeSave() の後くらいに以下のコードを入れてます。

	/**
	 * After find callback. Can be used to modify any results returned by find and findAll.
	 *
	 * @param object $model Model using this behavior
	 * @param mixed $results The results of the find operation
	 * @param boolean $primary Whether this model is being queried directly (vs. being queried as an association)
	 * @return mixed Result of the find operation
	 * @access public
	 */	
	function afterFind(&$model, $results, $primary) {
		// get list of associated models and their types
		$assocModels = $model->getAssociated();
		
		// loop through query results
		foreach ($results as &$result) {
			// loop through active PseudoDelete enabled models
			foreach ($assocModels as $assocModel => $assocType) {
				// check if model data was retireved
				if (!isset($this->__settings[$assocModel]['find']) || !$this->__settings[$assocModel]['find'] ||
				    !isset($result[$assocModel])) {
					continue;
				}
				
				// handle different types of associations
				$field = $this->__settings[$assocModel]['field'];
				if (in_array($assocType, array('belongsTo', 'hasOne'))) {
					// check if delete field was returned with result and if flag is set to deleted
					if (isset($result[$assocModel][$field]) && $result[$assocModel][$field]) {
						// remove result form query results
						unset($result[$assocModel]);
					} else {
						$partialResult = $this->afterFind($model->{$assocModel}, array($result[$assocModel]), false);
						$result[$assocModel] = $partialResult[0];
					}
					
				} elseif (in_array($assocType, array('hasMany', 'hasAndBelongsToMany'))) {
					// loop through records
					foreach ($result[$assocModel] as $index => $record) {
						// check if delete field was returned with result and if flag is set to deleted
						if (isset($record[$field]) && $record[$field]) {
							// remove result form query results
							unset($result[$assocModel][$index]);
						} else {
							$partialResult = $this->afterFind($model->{$assocModel}, array($record), false);
							$result[$assocModel][$index] = $partialResult[0];
						}
					}
					// reset array keys - due to unset() some array indexes may be missing
					//  - done more for consistance with original result set
					$result[$assocModel] = array_values($result[$assocModel]);
				}
			}
		}
	
		return $results;
	}

afterFind() を再帰的に呼び出して、関連テーブルに対しても同じような処理を行うようにしています。データの渡し方が胡散臭いですけど、コア部分にも似たようなコードはあるので気にしない方向で。

ちなみに、enableSoftDeletable() は deleted を持つモデルに対して設定してやる必要があります。たとえば User has many Posts みたいな関係があるとして、User モデル経由で論理削除済みの posts データも拾いたいのであれば、

$this->User->enableSoftDeletable(false);
$this->User->find('all');

こうではなく、

$this->User->Post->enableSoftDeletable(false);
$this->User->find('all');

こう書く必要があります。

実はテストとか足りてないんですけど、「ちゃんとテストやってから〜」とか思ってるとお蔵入りになってしまうケースが多いので、とりあえず公開してみた次第です。問題があればご連絡くださいませ。

蛇足: afterFind() の別実装→失敗

beforeFind() は親テーブルで一度呼ばれるだけですが、afterFind() は関連テーブル毎に呼び出されるような処理になっているようです。であれば自分で関連テーブルを辿る処理を書かなくても結構スマートに実装できるかなと期待していたんですが、どうも関連テーブルから呼び出される afterFind() はそのモデルで設定されているものだけで、ビヘイビア側で設定された afterFind() は呼び出されないようです。

バグなのか仕様なのかわからないので下手に対応できませんし、試しに cake/libs/model/datasources/dbo_source.php の __filterResults() を

if (isset($model->{$className}) && is_object($model->{$className})) {
	$data = $model->{$className}->afterFind(array(array($className => $results[$i][$className])), false);
}

から

if (isset($model->{$className}) && is_object($model->{$className})) {
	$data = array(array($className => $results[$i][$className]));
	$return = $model->{$className}->Behaviors->trigger($model->{$className}, 'afterFind', array($data, false), array('modParams' => true));
	if ($return !== true) {
		$data = $return;
	}
	$data = $model->{$className}->afterFind($data, false);
 	if (!isset($data[0][$className])) {
		unset($results[$i][$className]);
	}
}

に変えてビヘイビアの afterFind() が呼び出されるようにしてみたんですが、レコードを消しちゃうと後の処理がうまく通らないみたいなのでやめておきました。