Browse Source

Bitmasked

euromark 11 years ago
parent
commit
0fbd2e65df

+ 293 - 0
src/Model/Behavior/BitmaskedBehavior.php

@@ -0,0 +1,293 @@
+<?php
+namespace Tools\Model\Behavior;
+
+use Cake\Event\Event;
+use Cake\ORM\Behavior;
+use Cake\ORM\Entity;
+use Cake\Utility\Inflector;
+use Cake\Core\Configure;
+use Cake\ORM\Query;
+
+/**
+ * BitmaskedBehavior
+ *
+ * An implementation of bitwise masks for row-level operations.
+ * You can submit/register flags in different ways. The easiest way is using a static model function.
+ * It should contain the bits like so (starting with 1):
+ *   1 => w, 2 => x, 4 => y, 8 => z, ... (bits as keys - names as values)
+ * The order doesn't matter, as long as no bit is used twice.
+ *
+ * The theoretical limit for a 64-bit integer would be 64 bits (2^64).
+ * But if you actually seem to need more than a hand full you
+ * obviously do something wrong and should better use a joined table etc.
+ *
+ * @version 1.1
+ * @author Mark Scherer
+ * @cake 2.x
+ * @license MIT
+ * @link http://www.dereuromark.de/2012/02/26/bitmasked-using-bitmasks-in-cakephp/
+ */
+class BitmaskedBehavior extends Behavior {
+
+	/**
+	 * Default config
+	 *
+	 * @var array
+	 */
+	protected $_defaultConfig = array(
+		'field' => 'status',
+		'mappedField' => null, // NULL = same as above
+		'bits' => null, // Method or callback
+		'on' => 'validate', // on: save or validate
+		'defaultValue' => null, // NULL = auto (use empty string to trigger "notEmpty" rule for "default NOT NULL" db fields)
+	);
+
+	/**
+	 * Behavior configuration
+	 *
+	 * @param array $config
+	 * @return void
+	 */
+	public function initialize(array $config = array()) {
+		$config = $this->_config;
+
+		if (empty($config['bits'])) {
+			$config['bits'] = Inflector::pluralize($config['field']);
+		}
+
+		$entity = $this->_table->newEntity();
+		//$entity = $this->_table->entityClass();
+		//$entity = new $entity;
+
+		if (is_callable($config['bits'])) {
+			$config['bits'] = call_user_func($config['bits']);
+
+		} elseif (is_string($config['bits']) && method_exists($entity, $config['bits'])) {
+			$config['bits'] = $entity->{$config['bits']}();
+		} elseif (!is_array($config['bits'])) {
+			$config['bits'] = false;
+		}
+		if (empty($config['bits'])) {
+			throw new \Exception('Bits not found');
+		}
+		ksort($config['bits'], SORT_NUMERIC);
+
+		$this->_config = $config;
+	}
+
+	/**
+	 * @param Event $event
+	 * @param Query $query
+	 * @return void
+	 */
+	public function beforeFind(Event $event, Query $query) {
+		$this->encodeBitmaskConditions($query);
+
+		$field = $this->_config['field'];
+		if (!($mappedField = $this->_config['mappedField'])) {
+			$mappedField = $field;
+		}
+
+		$mapper = function ($row, $key, $mr) use ($field, $mappedField) {
+			//debug($mappedField);
+			//debug($this->decodeBitmask($row->get($field)));
+			$row->set($mappedField, $this->decodeBitmask($row->get($field)));
+    };
+    //$query->mapReduce($mapper);
+	}
+
+	/**
+	 * @param Model $Model
+	 * @param array $results
+	 * @param bool $primary
+	 * @return array
+	 */
+	public function __afterFind(Event $event, $results, $primary = false) {
+		foreach ($results as $key => $result) {
+			if (isset($result[$this->_table->alias()][$field])) {
+				$results[$key][$this->_table->alias()][$mappedField] = $this->decodeBitmask($result[$this->_table->alias()][$field]);
+			}
+		}
+
+		return $results;
+	}
+
+	/**
+	 * @param Model $Model
+	 * @param array $options
+	 * @return bool Success
+	 */
+	public function beforeValidate(Event $event, Entity $entity) {
+		if ($this->_config['on'] !== 'validate') {
+			return true;
+		}
+		$this->encodeBitmaskData($entity);
+		return true;
+	}
+
+	/**
+	 * @param Model $Model
+	 * @param array $options
+	 * @return bool Success
+	 */
+	public function beforeSave(Event $event, Entity $entity, \ArrayObject $options) {
+		if ($this->_config['on'] !== 'save') {
+			return true;
+		}
+		$this->encodeBitmaskData($entity);
+		return true;
+	}
+
+	/**
+	 * @param Model $Model
+	 * @param int $value Bitmask.
+	 * @return array Bitmask array (from DB to APP).
+	 */
+	public function decodeBitmask($value) {
+		$res = array();
+		$value = (int)$value;
+		foreach ($this->_config['bits'] as $key => $val) {
+			$val = (($value & $key) !== 0) ? true : false;
+			if ($val) {
+				$res[] = $key;
+			}
+		}
+		return $res;
+	}
+
+	/**
+	 * @param array $value Bitmask array.
+	 * @param array $defaultValue Default bitmask array.
+	 * @return int Bitmask (from APP to DB).
+	 */
+	public function encodeBitmask($value, $defaultValue = null) {
+		$res = 0;
+		if (empty($value)) {
+			return $defaultValue;
+		}
+		foreach ((array)$value as $key => $val) {
+			$res |= (int)$val;
+		}
+		if ($res === 0) {
+			return $defaultValue; // make sure notEmpty validation rule triggers
+		}
+		return $res;
+	}
+
+	/**
+	 * @param Model $Model
+	 * @param array $conditions
+	 * @return void
+	 */
+	public function encodeBitmaskConditions(Query $query) {
+		$field = $this->_config['field'];
+		if (!($mappedField = $this->_config['mappedField'])) {
+			$mappedField = $field;
+		}
+
+		$where = $query->clause('where');
+		if (!$where) {
+			return;
+		}
+
+		$callable = function ($foo) use ($field, $mappedField) {
+			if (!$foo instanceof \Cake\Database\Expression\Comparison) {
+				return $foo;
+			}
+			$key = $foo->getField();
+			if ($key === $mappedField || $key === $this->_table->alias() . '.' . $mappedField) {
+				$foo->value($this->encodeBitmask($foo->getValue()));
+			}
+			if ($field !== $mappedField) {
+				$foo->field($field);
+			}
+
+			return $foo;
+		};
+
+		$where->iterateParts($callable);
+	}
+
+	/**
+	 * @param Entity $entity
+	 * @return void
+	 */
+	public function encodeBitmaskData(Entity $entity) {
+		$field = $this->_config['field'];
+		if (!($mappedField = $this->_config['mappedField'])) {
+			$mappedField = $field;
+		}
+		$default = null;
+		$schema = $this->_table->schema()->column($field);
+
+		if ($schema && isset($schema['default'])) {
+			$default = $schema['default'];
+		}
+		if ($this->_config['defaultValue'] !== null) {
+			$default = $this->_config['defaultValue'];
+		}
+
+		if ($entity->get($mappedField) !== null) {
+			$entity->set($field, $this->encodeBitmask($entity->get($mappedField), $default));
+		}
+		if ($field !== $mappedField) {
+			$entity->unsetProperty($mappedField);
+		}
+	}
+
+	/**
+	 * @param Model $Model
+	 * @param mixed $bits (int, array)
+	 * @return array SQL snippet.
+	 */
+	public function isBit($bits) {
+		$bits = (array)$bits;
+		$bitmask = $this->encodeBitmask($bits);
+
+		$field = $this->_config['field'];
+		return array($this->_table->alias() . '.' . $field => $bitmask);
+	}
+
+	/**
+	 * @param Model $Model
+	 * @param mixed $bits (int, array)
+	 * @return array SQL snippet.
+	 */
+	public function isNotBit($bits) {
+		return array('NOT' => $this->isBit($bits));
+	}
+
+	/**
+	 * @param Model $Model
+	 * @param mixed $bits (int, array)
+	 * @return array SQL snippet.
+	 */
+	public function containsBit($bits) {
+		return $this->_containsBit($bits);
+	}
+
+	/**
+	 * @param Model $Model
+	 * @param mixed $bits (int, array)
+	 * @return array SQL snippet.
+	 */
+	public function containsNotBit($bits) {
+		return $this->_containsBit($bits, false);
+	}
+
+	/**
+	 * @param Model $Model
+	 * @param mixed $bits (int, array)
+	 * @param bool $contain
+	 * @return array SQL snippet.
+	 */
+	protected function _containsBit($bits, $contain = true) {
+		$bits = (array)$bits;
+		$bitmask = $this->encodeBitmask($bits);
+
+		$field = $this->_config['field'];
+		$contain = $contain ? ' & ? = ?' : ' & ? != ?';
+		return array('(' . $this->_table->alias() . '.' . $field . $contain . ')' => array($bitmask, $bitmask));
+	}
+
+}

+ 43 - 0
tests/Fixture/BitmaskedCommentsFixture.php

@@ -0,0 +1,43 @@
+<?php
+namespace Tools\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+/**
+ * For BitmaskedBehaviorTest
+ *
+ */
+class BitmaskedCommentsFixture extends TestFixture {
+
+	/**
+	 * Fields property
+	 *
+	 * @var array
+	 */
+	public $fields = array(
+		'id' => ['type' => 'integer'],
+		'article_id' => ['type' => 'integer', 'null' => true],
+		'user_id' => ['type' => 'integer', 'null' => true],
+		'comment' => 'text',
+		'status' => ['type' => 'integer', 'null' => false, 'length' => 2, 'default' => '0'],
+		'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', 'status' => '0', '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', 'status' => '1', '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', 'status' => '2', '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', 'status' => '3', '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', 'status' => '4', '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', 'status' => '5', 'created' => '2007-03-18 10:55:23', 'updated' => '2007-03-18 10:57:31'),
+		array('article_id' => 2, 'user_id' => 3, 'comment' => 'Comment With All Bits set', 'status' => '15', 'created' => '2007-03-18 10:55:23', 'updated' => '2007-03-18 10:57:31')
+	);
+
+}

+ 25 - 0
tests/TestApp/Model/Entity/BitmaskedComment.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace TestApp\Model\Entity;
+
+use Tools\Model\Entity\Entity;
+
+class BitmaskedComment extends Entity {
+
+	public static function statuses($value = null) {
+		$options = array(
+			static::STATUS_ACTIVE => __d('tools', 'Active'),
+			static::STATUS_PUBLISHED => __d('tools', 'Published'),
+			static::STATUS_APPROVED => __d('tools', 'Approved'),
+			static::STATUS_FLAGGED => __d('tools', 'Flagged'),
+		);
+
+		return parent::enum($value, $options);
+	}
+
+	const STATUS_NONE = 0;
+	const STATUS_ACTIVE = 1;
+	const STATUS_PUBLISHED = 2;
+	const STATUS_APPROVED = 4;
+	const STATUS_FLAGGED = 8;
+}

+ 18 - 0
tests/TestApp/Model/Table/BitmaskedCommentsTable.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace TestApp\Model\Table;
+
+use Tools\Model\Table\Table;
+
+class BitmaskedCommentsTable extends Table {
+
+	public $validate = array(
+		'status' => array(
+			'notEmpty' => array(
+				'rule' => 'notEmpty',
+				'last' => true
+			)
+		)
+	);
+
+}

+ 203 - 0
tests/TestCase/Model/Behavior/BitmaskedBehaviorTest.php

@@ -0,0 +1,203 @@
+<?php
+
+namespace Tools\Test\TestCase\Model\Behavior;
+
+use Cake\Database\Query;
+use Cake\Datasource\ConnectionManager;
+use Cake\Event\Event;
+use TestApp\Model\Entity\BitmaskedComment;
+//use TestApp\Model\Table\BitmaskedCommentsTable;
+use Cake\ORM\TableRegistry;
+use Cake\TestSuite\TestCase;
+use Cake\Core\Configure;
+use Tools\Model\Behavior\BitmaskedBehavior;
+
+//App::uses('AppModel', 'Model');
+
+class BitmaskedBehaviorTest extends TestCase {
+
+	public $fixtures = array(
+		'plugin.tools.bitmasked_comments'
+	);
+
+	public $Comments;
+
+	public function setUp() {
+		parent::setUp();
+
+		Configure::write('App.namespace', 'TestApp');
+
+		$this->Comments = TableRegistry::get('BitmaskedComments');
+		$this->Comments->addBehavior('Tools.Bitmasked', array('mappedField' => 'statuses'));
+	}
+
+	/**
+	 * BitmaskedBehaviorTest::testEncodeBitmask()
+	 *
+	 * @return void
+	 */
+	public function testEncodeBitmask() {
+		$res = $this->Comments->encodeBitmask(array(BitmaskedComment::STATUS_PUBLISHED, BitmaskedComment::STATUS_APPROVED));
+		$expected = BitmaskedComment::STATUS_PUBLISHED | BitmaskedComment::STATUS_APPROVED;
+		$this->assertSame($expected, $res);
+	}
+
+	/**
+	 * BitmaskedBehaviorTest::testDecodeBitmask()
+	 *
+	 * @return void
+	 */
+	public function testDecodeBitmask() {
+		$res = $this->Comments->decodeBitmask(BitmaskedComment::STATUS_PUBLISHED | BitmaskedComment::STATUS_APPROVED);
+		$expected = array(BitmaskedComment::STATUS_PUBLISHED, BitmaskedComment::STATUS_APPROVED);
+		$this->assertSame($expected, $res);
+	}
+
+	/**
+	 * BitmaskedBehaviorTest::testFind()
+	 *
+	 * @return void
+	 */
+	public function testFind() {
+		$res = $this->Comments->find('all');
+		//debug($res);
+		//die(debug($res->toArray()));
+		$this->assertTrue(!empty($res) && is_array($res));
+
+		//debug($res);
+		$this->assertTrue(!empty($res[1]['statuses']) && is_array($res[1]['statuses']));
+	}
+
+	/**
+	 * BitmaskedBehaviorTest::testSave()
+	 *
+	 * @return void
+	 */
+	public function testSave() {
+		$data = array(
+			'comment' => 'test save',
+			'statuses' => array(),
+		);
+
+		$entity = $this->Comments->newEntity($data);
+		$res = $this->Comments->validate($entity);
+		$this->assertTrue($res);
+
+		$this->assertSame('0', $entity->get('status'));
+
+		$data = array(
+			'comment' => 'test save',
+			'statuses' => array(BitmaskedComment::STATUS_PUBLISHED, BitmaskedComment::STATUS_APPROVED),
+		);
+		$entity = $this->Comments->newEntity($data);
+		$res = $this->Comments->validate($entity);
+		$this->assertTrue($res);
+
+		$is = $entity->get('status');
+		$this->assertSame(BitmaskedComment::STATUS_PUBLISHED | BitmaskedComment::STATUS_APPROVED, $is);
+
+		// save + find
+		$entity = $this->Comments->newEntity($data);
+		$res = $this->Comments->save($entity);
+		$this->assertTrue((bool)$res);
+
+		$res = $this->Comments->find('first', array('conditions' => array('statuses' => $data['statuses'])));
+		$this->assertTrue(!empty($res));
+		$expected = BitmaskedComment::STATUS_APPROVED | BitmaskedComment::STATUS_PUBLISHED; // 6
+		$this->assertEquals($expected, $res['status']);
+		$expected = $data['statuses'];
+
+		$this->assertEquals($expected, $res['statuses']);
+
+		// model.field syntax
+		$res = $this->Comments->find('first', array('conditions' => array('BitmaskedComment.statuses' => $data['statuses'])));
+		$this->assertTrue((bool)$res);
+
+		// explicit
+		$activeApprovedAndPublished = BitmaskedComment::STATUS_ACTIVE | BitmaskedComment::STATUS_APPROVED | BitmaskedComment::STATUS_PUBLISHED;
+		$data = array(
+			'comment' => 'another post comment',
+			'status' => $activeApprovedAndPublished,
+		);
+		$entity = $this->Comments->newEntity($data);
+		$res = $this->Comments->save($entity);
+		$this->assertTrue((bool)$res);
+
+		$res = $this->Comments->find('first', array('conditions' => array('status' => $activeApprovedAndPublished)));
+		$this->assertTrue((bool)$res);
+		$this->assertEquals($activeApprovedAndPublished, $res['status']);
+		$expected = array(BitmaskedComment::STATUS_ACTIVE, BitmaskedComment::STATUS_PUBLISHED, BitmaskedComment::STATUS_APPROVED);
+
+		$this->assertEquals($expected, $res['statuses']);
+	}
+
+	/**
+	 * Assert that you can manually trigger "notEmpty" rule with null instead of 0 for "not null" db fields
+	 */
+	public function testSaveWithDefaultValue() {
+		$this->Comments->removeBehavior('Bitmasked');
+		$this->Comments->addBehavior('Tools.Bitmasked', array('mappedField' => 'statuses', 'defaultValue' => ''));
+		$data = array(
+			'comment' => 'test save',
+			'statuses' => array(),
+		);
+		$entity = $this->Comments->newEntity($data);
+		$res = $this->Comments->validate($entity);
+		$this->assertFalse($res);
+
+		$this->assertSame('', $entity->get('status'));
+	}
+
+	public function testIs() {
+		$res = $this->Comments->isBit(BitmaskedComment::STATUS_PUBLISHED);
+		$expected = array('BitmaskedComments.status' => 2);
+		$this->assertEquals($expected, $res);
+	}
+
+	public function testIsNot() {
+		$res = $this->Comments->isNotBit(BitmaskedComment::STATUS_PUBLISHED);
+		$expected = array('NOT' => array('BitmaskedComments.status' => 2));
+		$this->assertEquals($expected, $res);
+	}
+
+	public function testContains() {
+		$res = $this->Comments->containsBit(BitmaskedComment::STATUS_PUBLISHED);
+		$expected = array('(BitmaskedComments.status & ? = ?)' => array(2, 2));
+		$this->assertEquals($expected, $res);
+
+		$conditions = $res;
+		$res = $this->Comments->find('all', array('conditions' => $conditions));
+		$this->assertTrue(!empty($res) && count($res) === 3);
+
+		// multiple (AND)
+		$res = $this->Comments->containsBit(array(BitmaskedComment::STATUS_PUBLISHED, BitmaskedComment::STATUS_ACTIVE));
+
+		$expected = array('(BitmaskedComments.status & ? = ?)' => array(3, 3));
+		$this->assertEquals($expected, $res);
+
+		$conditions = $res;
+		$res = $this->Comments->find('all', array('conditions' => $conditions));
+		$this->assertTrue(!empty($res) && count($res) === 2);
+	}
+
+	public function testNotContains() {
+		$res = $this->Comments->containsNotBit(BitmaskedComment::STATUS_PUBLISHED);
+		$expected = array('(BitmaskedComments.status & ? != ?)' => array(2, 2));
+		$this->assertEquals($expected, $res);
+
+		$conditions = $res;
+		$res = $this->Comments->find('all', array('conditions' => $conditions));
+		$this->assertTrue(!empty($res) && count($res) === 4);
+
+		// multiple (AND)
+		$res = $this->Comments->containsNotBit(array(BitmaskedComment::STATUS_PUBLISHED, BitmaskedComment::STATUS_ACTIVE));
+
+		$expected = array('(BitmaskedComments.status & ? != ?)' => array(3, 3));
+		$this->assertEquals($expected, $res);
+
+		$conditions = $res;
+		$res = $this->Comments->find('all', array('conditions' => $conditions));
+		$this->assertTrue(!empty($res) && count($res) === 5);
+	}
+
+}