浏览代码

Merge pull request #109 from dereuromark/cake3-reset

ResetBehavior
Mark S. 11 年之前
父节点
当前提交
89a514e1f8

+ 161 - 0
src/Model/Behavior/ResetBehavior.php

@@ -0,0 +1,161 @@
+<?php
+namespace Tools\Model\Behavior;
+
+use Cake\Event\Event;
+use Cake\ORM\Behavior;
+use Cake\ORM\Entity;
+use Cake\ORM\Query;
+use Cake\Core\Configure;
+use Cake\ORM\Table;
+
+/**
+ * Allows the model to reset all records as batch command.
+ * This way any slugging, geocoding or other beforeValidate, beforeSave, ... callbacks
+ * can be retriggered for them.
+ *
+ * By default it will not update the modified timestamp and will re-save id and displayName.
+ * If you need more fields, you need to specify them manually.
+ *
+ * You can also disable validate callback or provide a conditions scope to match only a subset
+ * of records.
+ *
+ * For performance and memory reasons the records will only be processed in loops (not all at once).
+ * If you have time-sensitive data, you can modify the limit of records per loop as well as the
+ * timeout in between each loop.
+ * Remember to raise set_time_limit() if you do not run this via CLI.
+ *
+ * It is recommended to attach this behavior dynamically where needed:
+ *
+ *    $table->addBehavior('Tools.Reset', array(...));
+ *    $table->resetRecords();
+ *
+ * If you want to provide a callback function/method, you can either use object methods or
+ * static functions/methods:
+ *
+ *    'callback' => array($this, 'methodName')
+ *
+ * and
+ *
+ *    public function methodName(Entity $entity, &$fields) {}
+ *
+ * For tables with lots of records you might want to use a shell and the CLI to invoke the reset/update process.
+ *
+ * @author Mark Scherer
+ * @license MIT
+ * @version 1.0
+ */
+class ResetBehavior extends Behavior {
+
+	protected $_defaultConfig = array(
+		'limit' => 100, // batch of records per loop
+		'timeout' => null, // in seconds
+		'fields' => array(), // if not displayField
+		'updateFields' => array(), // if saved fields should be different from fields
+		'validate' => true, // trigger beforeValidate callback
+		'updateTimestamp' => false, // update modified/updated timestamp
+		'scope' => array(), // optional conditions
+		'callback' => null,
+	);
+
+	/**
+	 * Adding validation rules
+	 * also adds and merges config settings (direct + configure)
+	 *
+	 * @return void
+	 */
+	public function __construct(Table $table, array $config = []) {
+		$defaults = $this->_defaultConfig;
+		if ($configureDefaults = Configure::read('Reset')) {
+			$defaults = $configureDefaults + $defaults;
+		}
+		$config + $defaults;
+		parent::__construct($table, $config);
+
+		$this->_table = $table;
+	}
+
+	/**
+	 * Regenerate all records (including possible beforeValidate/beforeSave callbacks).
+	 *
+	 * @param Model $Model
+	 * @param array $conditions
+	 * @param int $recursive
+	 * @return int Modified records
+	 */
+	public function resetRecords($params = array()) {
+		$defaults = array(
+			'page' => 1,
+			'limit' => $this->_config['limit'],
+			'fields' => array(),
+			'order' => $this->_table->alias() . '.' . $this->_table->primaryKey() . ' ASC',
+			'conditions' => $this->_config['scope'],
+			//'recursive' => $this->_config['recursive'],
+		);
+		if (!empty($this->_config['fields'])) {
+			foreach ((array)$this->_config['fields'] as $field) {
+				if (!$this->_table->hasField($field)) {
+					throw new \Exception('Table does not have field ' . $field);
+				}
+			}
+			$defaults['fields'] = array_merge(array($this->_table->alias() . '.' . $this->_table->primaryKey()), $this->_config['fields']);
+		} else {
+			$defaults['fields'] = array($this->_table->alias() . '.' . $this->_table->primaryKey());
+			if ($this->_table->displayField() !== $this->_table->primaryKey()) {
+				$defaults['fields'][] = $this->_table->alias() . '.' . $this->_table->displayField();
+			}
+		}
+		if (!$this->_config['updateTimestamp']) {
+			$fields = array('modified', 'updated');
+			foreach ($fields as $field) {
+				if ($this->_table->schema()->column($field)) {
+					$defaults['fields'][] = $field;
+					break;
+				}
+			}
+		}
+
+		$params += $defaults;
+		$count = $this->_table->find('count', compact('conditions'));
+		$max = ini_get('max_execution_time');
+		if ($max) {
+			set_time_limit(max($max, $count / $this->_config['limit']));
+		}
+
+		$modified = 0;
+		while (($records = $this->_table->find('all', $params)->toArray())) {
+			foreach ($records as $record) {
+				$fieldList = $params['fields'];
+				if (!empty($updateFields)) {
+					$fieldList = $updateFields;
+				}
+				if ($fieldList && !in_array($this->_table->primaryKey(), $fieldList)) {
+					$fieldList[] = $this->_table->primaryKey();
+				}
+
+				if ($this->_config['callback']) {
+					if (is_callable($this->_config['callback'])) {
+						$parameters = array($record, &$fieldList);
+						$record = call_user_func_array($this->_config['callback'], $parameters);
+					} else {
+						$record = $this->_table->{$this->_config['callback']}($record, $fieldList);
+					}
+					if (!$record) {
+						continue;
+					}
+				}
+
+				$res = $this->_table->save($record, compact('validate', 'fieldList'));
+				if (!$res) {
+					throw new \Exception(print_r($this->_table->errors(), true));
+				}
+				$modified++;
+			}
+			$params['page']++;
+			if ($this->_config['timeout']) {
+				sleep((int)$this->_config['timeout']);
+			}
+		}
+		return $modified;
+	}
+
+}

+ 3 - 3
src/Model/Behavior/SluggedBehavior.php

@@ -124,11 +124,11 @@ class SluggedBehavior extends Behavior {
 				if (strpos($field, '.')) {
 					list($alias, $field) = explode('.', $field);
 					if (!$Model->$alias->hasField($field)) {
-						throw new Exception('(SluggedBehavior::setup) model ' . $Model->$alias->name . ' is missing the field ' . $field .
+						throw new \Exception('(SluggedBehavior::setup) model ' . $Model->$alias->name . ' is missing the field ' . $field .
 							' (specified in the setup for model ' . $Model->name . ') ');
 					}
 				} elseif (!$Model->hasField($field)) {
-					throw new Exception('(SluggedBehavior::setup) model ' . $Model->name . ' is missing the field ' . $field . ' specified in the setup.');
+					throw new \Exception('(SluggedBehavior::setup) model ' . $Model->name . ' is missing the field ' . $field . ' specified in the setup.');
 				}
 			}
 		}
@@ -382,7 +382,7 @@ class SluggedBehavior extends Behavior {
 					'fieldList' => array_merge(array($this->_table->primaryKey(), $slugField), $label)
 				);
 				if (!$Model->save($row, $options)) {
-					throw new RuntimeException(print_r($row[$this->_table->alias()], true) . ': ' . print_r($Model->validationErrors, true));
+					throw new \Exception(print_r($row[$this->_table->alias()], true) . ': ' . print_r($Model->validationErrors, true));
 				}
 			}
 			$params['page']++;

+ 3 - 0
src/Model/Table/Table.php

@@ -253,6 +253,9 @@ class Table extends CakeTable {
 		if ($type === 'first') {
 			return parent::find('all', $options)->first();
 		}
+		if ($type === 'count') {
+			return parent::find('all', $options)->count();
+		}
 		return parent::find($type, $options);
 	}
 

+ 41 - 0
tests/Fixture/ResetCommentsFixture.php

@@ -0,0 +1,41 @@
+<?php
+namespace Tools\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+/**
+ * For ResetBehavior
+ */
+class ResetCommentsFixture extends TestFixture {
+
+/**
+ * fields property
+ *
+ * @var array
+ */
+	public $fields = array(
+		'id' => ['type' => 'integer'],
+		'article_id' => ['type' => 'integer', 'null' => false],
+		'user_id' => ['type' => 'integer', 'null' => false],
+		'comment' => 'text',
+		'published' => ['type' => 'string', 'length' => 1, 'default' => 'N'],
+		'created' => 'datetime',
+		'updated' => 'datetime',
+		'_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]]
+	);
+
+/**
+ * records property
+ *
+ * @var array
+ */
+	public $records = array(
+		array('article_id' => 1, 'user_id' => 2, 'comment' => 'First Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:45:23', 'updated' => '2007-03-18 10:47:31'),
+		array('article_id' => 1, 'user_id' => 4, 'comment' => 'Second Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:47:23', 'updated' => '2007-03-18 10:49:31'),
+		array('article_id' => 1, 'user_id' => 1, 'comment' => 'Third Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:49:23', 'updated' => '2007-03-18 10:51:31'),
+		array('article_id' => 1, 'user_id' => 1, 'comment' => 'Fourth Comment for First Article', 'published' => 'N', 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31'),
+		array('article_id' => 2, 'user_id' => 1, 'comment' => 'First Comment for Second Article', 'published' => 'Y', 'created' => '2007-03-18 10:53:23', 'updated' => '2007-03-18 10:55:31'),
+		array('article_id' => 2, 'user_id' => 2, 'comment' => 'Second Comment for Second Article', 'published' => 'Y', 'created' => '2007-03-18 10:55:23', 'updated' => '2007-03-18 10:57:31')
+	);
+
+}

+ 8 - 8
tests/Fixture/RolesFixture.php

@@ -16,14 +16,14 @@ class RolesFixture extends TestFixture {
 	 */
 	public $fields = array(
 		'id' => ['type' => 'integer'],
-		'name' => array('type' => 'string', 'null' => false, 'length' => 64, 'collate' => 'utf8_unicode_ci', 'comment' => '', 'charset' => 'utf8'),
-		'description' => array('type' => 'string', 'null' => false, 'default' => null, 'collate' => 'utf8_unicode_ci', 'comment' => '', 'charset' => 'utf8'),
-		'alias' => array('type' => 'string', 'null' => false, 'default' => null, 'length' => 20, 'collate' => 'utf8_unicode_ci', 'comment' => '', 'charset' => 'utf8'),
-		'default_role' => array('type' => 'boolean', 'null' => false, 'default' => false, 'collate' => null, 'comment' => 'set at register'),
-		'created' => array('type' => 'datetime', 'null' => true, 'default' => null, 'collate' => null, 'comment' => ''),
-		'modified' => array('type' => 'datetime', 'null' => true, 'default' => null, 'collate' => null, 'comment' => ''),
-		'sort' => array('type' => 'integer', 'null' => false, 'default' => '0', 'length' => 10, 'collate' => null, 'comment' => ''),
-		'active' => array('type' => 'boolean', 'null' => false, 'default' => false, 'collate' => null, 'comment' => ''),
+		'name' => ['type' => 'string', 'null' => false, 'length' => 64, 'collate' => 'utf8_unicode_ci', 'comment' => '', 'charset' => 'utf8'],
+		'description' => ['type' => 'string', 'null' => false, 'default' => null, 'collate' => 'utf8_unicode_ci', 'comment' => '', 'charset' => 'utf8'],
+		'alias' => ['type' => 'string', 'null' => false, 'default' => null, 'length' => 20, 'collate' => 'utf8_unicode_ci', 'comment' => '', 'charset' => 'utf8'],
+		'default_role' => ['type' => 'boolean', 'null' => false, 'default' => false, 'collate' => null, 'comment' => 'set at register'],
+		'created' => ['type' => 'datetime', 'null' => true, 'default' => null, 'collate' => null, 'comment' => ''],
+		'modified' => ['type' => 'datetime', 'null' => true, 'default' => null, 'collate' => null, 'comment' => ''],
+		'sort' => ['type' => 'integer', 'null' => false, 'default' => '0', 'length' => 10, 'collate' => null, 'comment' => ''],
+		'active' => ['type' => 'boolean', 'null' => false, 'default' => false, 'collate' => null, 'comment' => ''],
 		'_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]]
 	);
 

+ 44 - 0
tests/TestApp/Model/Table/ResetCommentsTable.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace TestApp\Model\Table;
+
+use Tools\Model\Table\Table;
+use Cake\ORM\Entity;
+
+class ResetCommentsTable extends Table {
+
+	public function initialize(array $config) {
+		$this->displayField('comment');
+		parent::initialize($config);
+	}
+
+	public function customCallback(Entity $record, &$updateFields) {
+		$record->comment .= ' xyz';
+		$fields[] = 'some_other_field';
+		return $record;
+	}
+
+	public function customObjectCallback(Entity $record, &$updateFields) {
+		$record['comment'] .= ' xxx';
+		$updateFields[] = 'some_other_field';
+		return $record;
+	}
+
+	public static function customStaticCallback(Entity $record, &$updateFields) {
+		$record['comment'] .= ' yyy';
+		$updateFields[] = 'some_other_field';
+		return $record;
+	}
+
+	public static function fieldsCallback(Entity $record, &$updateFields) {
+		$record['comment'] = 'foo';
+		return $record;
+	}
+
+	public static function fieldsCallbackAuto(Entity $record, &$updateFields) {
+		$record['comment'] = 'bar';
+		$updateFields[] = 'comment';
+		return $record;
+	}
+
+}

+ 177 - 0
tests/TestCase/Model/Behavior/ResetBehaviorTest.php

@@ -0,0 +1,177 @@
+<?php
+
+namespace Tools\Model\Behavior;
+
+use Tools\Model\Behavior\ResetBehavior;
+use Tools\TestSuite\TestCase;
+//use App\Model\AppModel;
+use Cake\ORM\TableRegistry;
+use Tools\Model\Table\Table;
+use Cake\Core\Configure;
+
+class ResetBehaviorTest extends TestCase {
+
+	public $ResetBehavior;
+
+	public $Table;
+
+	public $fixtures = array('plugin.tools.reset_comments');
+
+	public function setUp() {
+		parent::setUp();
+
+		Configure::write('App.namespace', 'TestApp');
+
+		//set_time_limit(10);
+
+		$this->Table = TableRegistry::get('ResetComments');
+		$this->Table->addBehavior('Tools.Reset');
+	}
+
+	public function tearDown() {
+		TableRegistry::clear();
+
+		parent::tearDown();
+	}
+
+	/**
+	 * ResetBehaviorTest::testResetRecords()
+	 *
+	 * @return void
+	 */
+	public function testResetRecords() {
+		$x = $this->Table->find('all', array('fields' => array('comment'), 'order' => array('updated' => 'DESC')))->first();
+
+		$result = $this->Table->resetRecords();
+		$this->assertTrue((bool)$result);
+
+		$y = $this->Table->find('all', array('fields' => array('comment'), 'order' => array('updated' => 'DESC')))->first();
+		$this->assertSame($x->toArray(), $y->toArray());
+	}
+
+	/**
+	 * ResetBehaviorTest::testResetRecordsWithUpdatedTimestamp()
+	 *
+	 * @return void
+	 */
+	public function _testResetRecordsWithUpdatedTimestamp() {
+		$this->Table->removeBehavior('Reset');
+		$this->Table->addBehavior('Tools.Reset', array('updateTimestamp' => true));
+
+		$x = $this->Table->find('all', array('order' => array('updated' => 'DESC')))->first();
+		$this->assertTrue($x['updated'] < '2007-12-31');
+
+		$result = $this->Table->resetRecords();
+		$this->assertTrue((bool)$result);
+
+		$x = $this->Table->find('all', array('order' => array('updated' => 'ASC')))->first();
+		$this->assertTrue($x['updated'] > (date('Y') - 1) . '-12-31');
+	}
+
+	/**
+	 * ResetBehaviorTest::testResetWithCallback()
+	 *
+	 * @return void
+	 */
+	public function testResetWithCallback() {
+		$this->Table->removeBehavior('Reset');
+		$this->Table->addBehavior('Tools.Reset', array('callback' => 'customCallback'));
+
+		$x = $this->Table->find('all', array('conditions' => array('id' => 6)))->first();
+		$this->assertEquals('Second Comment for Second Article', $x['comment']);
+
+		$result = $this->Table->resetRecords();
+		$this->assertTrue((bool)$result);
+
+		$x = $this->Table->find('all', array('conditions' => array('id' => 6)))->first();
+		$expected = 'Second Comment for Second Article xyz';
+		$this->assertEquals($expected, $x['comment']);
+	}
+
+	/**
+	 * ResetBehaviorTest::testResetWithObjectCallback()
+	 *
+	 * @return void
+	 */
+	public function testResetWithObjectCallback() {
+		$this->Table->removeBehavior('Reset');
+		$this->Table->addBehavior('Tools.Reset', array('callback' => array($this->Table, 'customObjectCallback')));
+
+		$x = $this->Table->find('first', array('conditions' => array('id' => 6)));
+		$this->assertEquals('Second Comment for Second Article', $x['comment']);
+
+		$result = $this->Table->resetRecords();
+		$this->assertTrue((bool)$result);
+
+		$x = $this->Table->find('first', array('conditions' => array('id' => 6)));
+		$expected = 'Second Comment for Second Article xxx';
+		$this->assertEquals($expected, $x['comment']);
+	}
+
+	/**
+	 * ResetBehaviorTest::testResetWithStaticCallback()
+	 *
+	 * @return void
+	 */
+	public function testResetWithStaticCallback() {
+		$this->Table->removeBehavior('Reset');
+		$this->Table->addBehavior('Tools.Reset', array('callback' => 'TestApp\Model\Table\ResetCommentsTable::customStaticCallback'));
+
+		$x = $this->Table->find('first', array('conditions' => array('id' => 6)));
+		$this->assertEquals('Second Comment for Second Article', $x['comment']);
+
+		$result = $this->Table->resetRecords();
+		$this->assertTrue((bool)$result);
+
+		$x = $this->Table->find('first', array('conditions' => array('id' => 6)));
+		$expected = 'Second Comment for Second Article yyy';
+		$this->assertEquals($expected, $x['comment']);
+	}
+
+	/**
+	 * ResetBehaviorTest::testResetWithCallbackAndFields()
+	 *
+	 * @return void
+	 */
+	public function testResetWithCallbackAndFields() {
+		$this->Table->removeBehavior('Reset');
+		$this->Table->addBehavior('Tools.Reset', array(
+			'fields' => array('id'),
+			'updateFields' => array('comment'),
+			'callback' => 'TestApp\Model\Table\ResetCommentsTable::fieldsCallback'));
+
+		$x = $this->Table->find('first', array('conditions' => array('id' => 6)));
+		$this->assertEquals('Second Comment for Second Article', $x['comment']);
+
+		$result = $this->Table->resetRecords();
+		$this->assertTrue((bool)$result);
+
+		$x = $this->Table->find('first', array('conditions' => array('id' => 6)));
+		$expected = 'foo';
+		$this->assertEquals($expected, $x['comment']);
+	}
+
+	/**
+	 * ResetBehaviorTest::testResetWithCallbackAndFieldsAutoAdded()
+	 *
+	 * @return void
+	 */
+	public function testResetWithCallbackAndFieldsAutoAdded() {
+		$this->Table->removeBehavior('Reset');
+		$this->Table->addBehavior('Tools.Reset', array(
+			'fields' => array('id'),
+			'updateFields' => array('id'),
+			'callback' => 'TestApp\Model\Table\ResetCommentsTable::fieldsCallbackAuto'));
+
+		$x = $this->Table->find('first', array('conditions' => array('id' => 6)));
+		$this->assertEquals('Second Comment for Second Article', $x['comment']);
+
+		$result = $this->Table->resetRecords();
+		$this->assertTrue((bool)$result);
+
+		$x = $this->Table->find('first', array('conditions' => array('id' => 6)));
+		$expected = 'bar';
+		$this->assertEquals($expected, $x['comment']);
+	}
+
+}