diff --git a/Model/Behavior/ChangeLoggingBehavior.php b/Model/Behavior/ChangeLoggingBehavior.php new file mode 100644 index 0000000..cc893a9 --- /dev/null +++ b/Model/Behavior/ChangeLoggingBehavior.php @@ -0,0 +1,237 @@ +getMetadataForChange(); + } + return array(); + } + +/** + * __getTableName function. + * Fetch the tablename to use for logging in order of priority: + * 1) Model specified name + * 2) Global config name + * 3) string 'change_logs' + * + * @param Model $model the model we are configuring + */ + private function __getTableName(Model $model) { + if (!isset($this->__tableName)) { + $config = Configure::read('changeLogger'); + if (!isset($config['tableName']) || $config['tableName'] == null) { + $this->__tableName = 'change_logs'; + } else { + $this->__tableName = $config['tableName']; + } + } + if (isset($this->settings[$model->name]['tableName'])) { + return $this->settings[$model->name]['tableName']; + } else { + return $this->__tableName; + } + } + + private function __prepare(Model $model, $clobber = false) { + if (!isset($this->__modelCache[$model->name])) { + $this->__modelCache[$model->name] = array(); + } + if ($clobber) { + $this->__modelCache[$model->name][$model->id] = array(); + } + } + + private function __recordCreation(Model $model) { + $newChangeLog = array( + 'ChangeLog' => array( + 'model' => strtolower($model->name), + 'model_id' => $model->id, + 'state' => ItemState::CREATED, + 'change' => array(), + 'metadata' => $this->__getMetadataForChange($model), + ) + ); + + if (isset($newChangeLog['ChangeLog']['metadata']['parent_id'])) { + $newChangeLog['ChangeLog']['parent_id'] = $newChangeLog['ChangeLog']['metadata']['parent_id']; + unset($newChangeLog['ChangeLog']['metadata']['parent_id']); + } + + $model->ChangeLog->save($newChangeLog); + + return true; + } + + private function __recordModification(Model $model) { + if (empty($this->__modelCache[$model->name][$model->id])) { + return true; + } + + $newChangeLog = array( + 'ChangeLog' => array( + 'model' => strtolower($model->name), + 'model_id' => $model->id, + 'state' => ItemState::MODIFIED, + 'change' => array(), + 'metadata' => $this->__getMetadataForChange($model), + ) + ); + + if (isset($newChangeLog['ChangeLog']['metadata']['parent_id'])) { + $newChangeLog['ChangeLog']['parent_id'] = $newChangeLog['ChangeLog']['metadata']['parent_id']; + unset($newChangeLog['ChangeLog']['metadata']['parent_id']); + } + + foreach ($this->__modelCache[$model->name][$model->id] as $field => $oldValue) { + $newChangeLog['ChangeLog']['change'][$field] = array($oldValue, $model->field($field)); + } + + $model->ChangeLog->save($newChangeLog); + + return true; + } + +/** + * See: http://book.cakephp.org/2.0/en/models/behaviors.html#creating-a-behavior-callback + */ + public function afterDelete(Model $model) { + $model->ChangeLog->save($this->__modelCache[$model->name][$model->id]); + return true; + } + +/** + * See: http://book.cakephp.org/2.0/en/models/behaviors.html#creating-a-behavior-callback + * Store the changes that just happened + */ + public function afterSave(Model $model, $created = false) { + if ($created) { + return $this->__recordCreation($model); + } else { + return $this->__recordModification($model); + } + } + +/** + * See: http://book.cakephp.org/2.0/en/models/behaviors.html#creating-a-behavior-callback + */ + public function beforeDelete(Model $model, $cascade = true) { + $this->__prepare($model, true); + + $newChangeLog = array( + 'ChangeLog' => array( + 'model' => strtolower($model->name), + 'model_id' => $model->id, + 'state' => ItemState::DELETED, + 'change' => array(), + 'metadata' => $this->__getMetadataForChange($model), + ) + ); + + if (isset($newChangeLog['ChangeLog']['metadata']['parent_id'])) { + $newChangeLog['ChangeLog']['parent_id'] = $newChangeLog['ChangeLog']['metadata']['parent_id']; + unset($newChangeLog['ChangeLog']['metadata']['parent_id']); + } + + $this->__modelCache[$model->name][$model->id] = $newChangeLog; + } + +/** + * See: http://book.cakephp.org/2.0/en/models/behaviors.html#creating-a-behavior-callback + * Cache the relevant older version of the model item + */ + public function beforeSave(Model $model) { + $this->__prepare($model, true); + + $before = $model->findById($model->id); + $before = $before[$model->name]; + + foreach ($model->data[$model->name] as $field => $value) { + if ($field != 'modified' && $before[$field] != $value) { + $this->__modelCache[$model->name][$model->id][$field] = $before[$field]; + } + } + return true; + } + +/** + * See: http://book.cakephp.org/2.0/en/models/behaviors.html#creating-a-behavior-callback + * @throws Exception if model is incorrectly configured + */ + public function setup(Model $model, $settings = array()) { + $this->settings[$model->name] = $settings; + + if (!isset($model->ChangeLog) || $model->ChangeLog == null) { + throw new Exception($model->name . " is incorrectly configured"); + } else if ($this->__getTableName($model) != null) { + $model->ChangeLog->setSource($this->__getTableName($model)); + } + } + + public function changeLogById(Model $model, $id = null, $modelName = null, $numberOfResults = 10, $offset = 0) { + $id = ($id) ? $id : $model->id; + $modelName = ($modelName) ? $modelName : $model->name; + + $conditions = array( + 'conditions' => array( + 'ChangeLog.model' => $modelName, + 'ChangeLog.model_id' => $id, + ), + 'order' => array( + 'ChangeLog.created' + ), + 'limit' => $numberOfResults, + 'offset' => $offset, + ); + return $model->ChangeLog->find('all', $conditions); + } + + public function changeLogByParentId(Model $model, $parentId = null, $modelName = null, $numberOfResults = 10, $offset = 0) { + $parentId = ($parentId != null) ? $parentId : $model->id; + $modelName = ($modelName) ? $modelName : $model->name; + + $conditions = array( + 'conditions' => array( + 'ChangeLog.model' => $modelName, + 'ChangeLog.parent_id' => $parentId, + ), + 'order' => array( + 'ChangeLog.created' + ), + 'limit' => $numberOfResults, + 'offset' => $offset, + ); + return $model->ChangeLog->find('all', $conditions); + } + +} + +class ItemState { + const CREATED = 1; + const MODIFIED = 2; + const DELETED = 3; +} \ No newline at end of file diff --git a/Model/ChangeLog.php b/Model/ChangeLog.php new file mode 100644 index 0000000..b0d70cb --- /dev/null +++ b/Model/ChangeLog.php @@ -0,0 +1,46 @@ + $val) { + if (isset($val['ChangeLog']['change'])) { + $results[$key]['ChangeLog']['change'] = json_decode($val['ChangeLog']['change'], true); + } + if (isset($val['ChangeLog']['metadata'])) { + $results[$key]['ChangeLog']['metadata'] = json_decode($val['ChangeLog']['metadata'], true); + } + } + return $results; + } + +/** + * See: http://book.cakephp.org/2.0/en/models/callback-methods.html + */ + public function beforeSave($options = array()) { + if (isset($this->data['ChangeLog']['change'])) { + $this->data['ChangeLog']['change'] = json_encode($this->data['ChangeLog']['change']); + } + if (isset($this->data['ChangeLog']['metadata'])) { + $this->data['ChangeLog']['metadata'] = json_encode($this->data['ChangeLog']['metadata']); + } + return true; + } +} diff --git a/Model/ChangeLoggerAppModel.php b/Model/ChangeLoggerAppModel.php new file mode 100644 index 0000000..885e8fd --- /dev/null +++ b/Model/ChangeLoggerAppModel.php @@ -0,0 +1,18 @@ + array( + 'id' => '12', + 'parent_id' => '3', + 'model' => 'LoggableTime', + 'model_id' => '6', + 'state' => '1', + 'change' => array(), + 'metadata' => array( + "infoA" => "valueA", + "infoB" => "2", + ), + 'created' => '2013-01-20 18:18:54', + 'modified' => '2013-01-20 18:18:54' + ) + ), + array( + 'ChangeLog' => array( + 'id' => '13', + 'parent_id' => '3', + 'model' => 'LoggableTime', + 'model_id' => '6', + 'state' => '3', + 'change' => array(), + 'metadata' => array( + "infoA" => "valueA", + "infoB" => "2", + ), + 'created' => '2013-01-20 18:18:54', + 'modified' => '2013-01-20 18:18:54' + ) + ) + ); + + private $__expectedDataB = array( + array( + 'ChangeLog' => array( + 'id' => '9', + 'parent_id' => '3', + 'model' => 'modelA', + 'model_id' => '5', + 'state' => '1', + 'change' => array(), + 'metadata' => array( + "infoA" => "valueC", + "infoB" => "0" + ), + 'created' => '2013-01-20 17:47:26', + 'modified' => '2013-01-20 17:47:26' + ), + ), + array( + 'ChangeLog' => array( + 'id' => '10', + 'parent_id' => '3', + 'model' => 'modelA', + 'model_id' => '5', + 'state' => '2', + 'change' => array( + "description" => array( + "old value", + "new value" + ), + "title" => array( + "old value", + "new value" + ) + ), + 'metadata' => array( + "infoA" => "valueD", + "infoB" => "-1" + ), + 'created' => '2013-01-20 17:47:40', + 'modified' => '2013-01-20 17:47:40' + ), + ), + array( + 'ChangeLog' => array( + 'id' => '11', + 'parent_id' => '3', + 'model' => 'modelA', + 'model_id' => '5', + 'state' => '3', + 'change' => array(), + 'metadata' => array( + "infoA" => "valueB", + "infoB" => "1" + ), + 'created' => '2013-01-20 17:47:43', + 'modified' => '2013-01-20 17:47:43' + ), + ), + ); + + public $fixtures = array( + 'plugin.change_logger.change_log', + 'plugin.change_logger.loggable_time', + ); + +/** + * setUp method + * + * @return void + */ + public function setUp() { + parent::setUp(); + $this->LoggableTime = ClassRegistry::init('LoggableTime'); + } + +/** + * tearDown method + * + * @return void + */ + public function tearDown() { + unset($this->__testModel); + parent::tearDown(); + } + + public function testChangeLogById1() { + $actualData = $this->LoggableTime->changeLogById(6, 'LoggableTime'); + $this->assertEquals($this->__expectedDataA, $actualData, "Output was not as expected"); + } + + public function testChangeLogById2() { + $actualData = $this->LoggableTime->changeLogById(6); + $this->assertEquals($this->__expectedDataA, $actualData, "Output was not as expected"); + } + + public function testChangeLogById3() { + $this->LoggableTime->id = 6; + $actualData = $this->LoggableTime->changeLogById(); + $this->assertEquals($this->__expectedDataA, $actualData, "Output was not as expected"); + } + + public function testChangeLogById4() { + $this->LoggableTime->id = 3; + $actualData = $this->LoggableTime->changeLogById(6); + $this->assertEquals($this->__expectedDataA, $actualData, "Output was not as expected"); + } + + public function testChangeLogById5() { + $actualData = $this->LoggableTime->changeLogById(6, null, 1); + $this->assertEquals(array($this->__expectedDataA[0]), $actualData, "Output was not as expected"); + } + + public function testChangeLogById6() { + $actualData = $this->LoggableTime->changeLogById(6, null, 1, 1); + $this->assertEquals(array($this->__expectedDataA[1]), $actualData, "Output was not as expected"); + } + + public function testChangeLogByParentId1() { + $actualData = $this->LoggableTime->changeLogByParentId(3, 'modelA'); + $this->assertEquals($this->__expectedDataB, $actualData, "Output was not as expected"); + } + + public function testChangeLogByParentId2() { + $this->LoggableTime->id = 3; + $actualData = $this->LoggableTime->changeLogByParentId(null, 'modelA'); + $this->assertEquals($this->__expectedDataB, $actualData, "Output was not as expected"); + } + + public function testCreate() { + $this->LoggableTime->create(); + $loggableTime = array( + 'field1' => 1, + 'field2' => 'two' + ); + $this->LoggableTime->save($loggableTime); + + $expectedLogs = array( + array( + 'ChangeLog' => array( + 'id' => '14', + 'parent_id' => 1, + 'model' => 'loggabletime', + 'model_id' => '13', + 'state' => '1', + 'change' => array(), + 'metadata' => array() + ) + ) + ); + $actualData = $this->LoggableTime->changeLogById(); + unset($actualData[0]['ChangeLog']['created']); + unset($actualData[0]['ChangeLog']['modified']); + $this->assertEquals($expectedLogs, $actualData, "Log was not created as expected"); + } + + public function testUpdate() { + $this->LoggableTime->recursive = -1; + $time = $this->LoggableTime->findById(12); + $time['LoggableTime']['field1'] = 5; + $this->LoggableTime->save($time); + + $expectedLogs = array( + array( + 'ChangeLog' => array( + 'id' => '14', + 'parent_id' => 1, + 'model' => 'loggabletime', + 'model_id' => '12', + 'state' => '2', + 'change' => array( + 'field1' => array( + '3', + '5' + ) + ), + 'metadata' => array(), + ) + ) + ); + $actualData = $this->LoggableTime->changeLogById(12); + unset($actualData[0]['ChangeLog']['created']); + unset($actualData[0]['ChangeLog']['modified']); + $this->assertEquals($expectedLogs, $actualData, "Log was not created as expected"); + } + + public function testDelete() { + $this->LoggableTime->delete(12); + + $expectedLogs = array( + array( + 'ChangeLog' => array( + 'id' => '14', + 'parent_id' => 1, + 'model' => 'loggabletime', + 'model_id' => '12', + 'state' => '3', + 'change' => array(), + 'metadata' => array(), + ) + ) + ); + $actualData = $this->LoggableTime->changeLogById(12); + unset($actualData[0]['ChangeLog']['created']); + unset($actualData[0]['ChangeLog']['modified']); + $this->assertEquals($expectedLogs, $actualData, "Log was not created as expected"); + } + +} + +class LoggableTime extends CakeTestModel { + + public $name = 'LoggableTime'; + + public $actsAs = array( + 'ChangeLogger.ChangeLogging' + ); + + public $hasMany = array( + 'ChangeLog' => array( + 'className' => 'ChangeLogger.ChangeLog', + ) + ); + + public function getMetadataForChange() { + return array( + 'parent_id' => 1 + ); + } +} \ No newline at end of file diff --git a/Test/Case/Model/ChangeLogTest.php b/Test/Case/Model/ChangeLogTest.php new file mode 100644 index 0000000..3913c97 --- /dev/null +++ b/Test/Case/Model/ChangeLogTest.php @@ -0,0 +1,116 @@ +ChangeLog = ClassRegistry::init('ChangeLogger.ChangeLog'); + } + +/** + * tearDown method + * + * @return void + */ + public function tearDown() { + unset($this->ChangeLog); + parent::tearDown(); + } + + public function testBeforeSaveNoMetaData() { + $this->ChangeLog->data = array( + 'ChangeLog' => array( + 'change' => array( + "infoA" => "valueA", + "infoB" => "2" + ) + ) + ); + + $expectedData = array( + 'ChangeLog' => array( + 'change' => '{"infoA":"valueA","infoB":"2"}' + ) + ); + + $this->ChangeLog->beforeSave(null); + $this->assertEquals($expectedData, $this->ChangeLog->data, "Before save was not JSON encoded"); + } + + public function testBeforeSaveNoChange() { + $this->ChangeLog->data = array( + 'ChangeLog' => array( + 'metadata' => array( + "infoA" => "valueA", + "infoB" => "2" + ) + ) + ); + + $expectedData = array( + 'ChangeLog' => array( + 'metadata' => '{"infoA":"valueA","infoB":"2"}' + ) + ); + + $this->ChangeLog->beforeSave(null); + $this->assertEquals($expectedData, $this->ChangeLog->data, "Before save was not JSON encoded"); + } + + public function testAfterFind() { + $inputRecord = array( + array( + 'ChangeLog' => array( + 'id' => '10', + 'parent_id' => '3', + 'model' => 'modelA', + 'model_id' => '5', + 'state' => '2', + 'change' => '{"description":["old value","new value"],"title":["old value","new value"]}', + 'metadata' => '{"infoA":"valueD","infoB":"-1"}', + 'created' => '2013-01-20 17:47:40', + 'modified' => '2013-01-20 17:47:40' + ) + ) + ); + + $expectedData = array( + array( + 'ChangeLog' => array( + 'id' => '10', + 'parent_id' => '3', + 'model' => 'modelA', + 'model_id' => '5', + 'state' => '2', + 'change' => array( + "description" => array( + "old value", + "new value" + ), + "title" => array( + "old value", + "new value" + ) + ), + 'metadata' => array( + "infoA" => "valueD", + "infoB" => "-1", + ), + 'created' => '2013-01-20 17:47:40', + 'modified' => '2013-01-20 17:47:40' + ) + ) + ); + + $this->assertEquals($expectedData, $this->ChangeLog->afterFind($inputRecord, false), "After find was not JSON Encoded"); + } + +} diff --git a/Test/Case/View/Helper/empty b/Test/Case/View/Helper/empty new file mode 100644 index 0000000..e69de29 diff --git a/Test/Fixture/ChangeLogFixture.php b/Test/Fixture/ChangeLogFixture.php new file mode 100644 index 0000000..5c20845 --- /dev/null +++ b/Test/Fixture/ChangeLogFixture.php @@ -0,0 +1,89 @@ + array('type' => 'integer', 'null' => false, 'default' => null, 'key' => 'primary'), + 'parent_id' => array('type' => 'integer', 'null' => true, 'default' => null), + 'model' => array('type' => 'string', 'null' => false, 'length' => 24, 'collate' => 'utf8_general_ci', 'charset' => 'utf8'), + 'model_id' => array('type' => 'integer', 'null' => false, 'default' => null), + 'state' => array('type' => 'integer', 'null' => false, 'default' => null), + 'change' => array('type' => 'binary', 'null' => false, 'default' => null), + 'metadata' => array('type' => 'binary', 'null' => true, 'default' => null), + 'created' => array('type' => 'datetime', 'null' => false, 'default' => null), + 'modified' => array('type' => 'datetime', 'null' => false, 'default' => null), + 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1)), + 'tableParameters' => array('charset' => 'utf8', 'collate' => 'utf8_general_ci', 'engine' => 'MyISAM') + ); + +/** + * Records + * + * @var array + */ + public $records = array( + array( + 'id' => '12', + 'parent_id' => '3', + 'model' => 'LoggableTime', + 'model_id' => '6', + 'state' => '1', + 'change' => '[]', + 'metadata' => '{"infoA":"valueA","infoB":"2"}', + 'created' => '2013-01-20 18:18:54', + 'modified' => '2013-01-20 18:18:54' + ), + array( + 'id' => '13', + 'parent_id' => '3', + 'model' => 'LoggableTime', + 'model_id' => '6', + 'state' => '3', + 'change' => '[]', + 'metadata' => '{"infoA":"valueA","infoB":"2"}', + 'created' => '2013-01-20 18:18:54', + 'modified' => '2013-01-20 18:18:54' + ), + array( + 'id' => '11', + 'parent_id' => '3', + 'model' => 'modelA', + 'model_id' => '5', + 'state' => '3', + 'change' => '[]', + 'metadata' => '{"infoA":"valueB","infoB":"1"}', + 'created' => '2013-01-20 17:47:43', + 'modified' => '2013-01-20 17:47:43' + ), + array( + 'id' => '9', + 'parent_id' => '3', + 'model' => 'modelA', + 'model_id' => '5', + 'state' => '1', + 'change' => '[]', + 'metadata' => '{"infoA":"valueC","infoB":"0"}', + 'created' => '2013-01-20 17:47:26', + 'modified' => '2013-01-20 17:47:26' + ), + array( + 'id' => '10', + 'parent_id' => '3', + 'model' => 'modelA', + 'model_id' => '5', + 'state' => '2', + 'change' => '{"description":["old value","new value"],"title":["old value","new value"]}', + 'metadata' => '{"infoA":"valueD","infoB":"-1"}', + 'created' => '2013-01-20 17:47:40', + 'modified' => '2013-01-20 17:47:40' + ), + ); +} diff --git a/Test/Fixture/LoggableTimeFixture.php b/Test/Fixture/LoggableTimeFixture.php new file mode 100644 index 0000000..638b17c --- /dev/null +++ b/Test/Fixture/LoggableTimeFixture.php @@ -0,0 +1,37 @@ + array('type' => 'integer', 'null' => false, 'default' => null, 'key' => 'primary'), + 'field1' => array('type' => 'integer', 'null' => true, 'default' => null), + 'field2' => array('type' => 'string', 'null' => false, 'length' => 24, 'collate' => 'utf8_general_ci', 'charset' => 'utf8'), + 'created' => array('type' => 'datetime', 'null' => false, 'default' => null), + 'modified' => array('type' => 'datetime', 'null' => false, 'default' => null), + 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1)), + 'tableParameters' => array('charset' => 'utf8', 'collate' => 'utf8_general_ci', 'engine' => 'MyISAM') + ); + +/** + * Records + * + * @var array + */ + public $records = array( + array( + 'id' => '12', + 'field1' => '3', + 'field2' => 'LoggableTime', + 'created' => '2013-01-20 18:18:54', + 'modified' => '2013-01-20 18:18:54' + ), + ); +} diff --git a/View/Helper/empty b/View/Helper/empty new file mode 100644 index 0000000..e69de29 diff --git a/webroot/empty b/webroot/empty new file mode 100644 index 0000000..e69de29