Browse Source

bitmasked init

euromark 14 years ago
parent
commit
6192a6f344

+ 226 - 0
Model/Behavior/BitmaskedBehavior.php

@@ -0,0 +1,226 @@
+<?php
+
+/**
+ * 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 shoudn'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.
+ *
+ * @author Mark Scherer
+ * @cake 2.x
+ * @license MIT
+ * @uses ModelBehavior
+ * 2012-02-24 ms
+ */
+class BitmaskedBehavior extends ModelBehavior {
+
+	/**
+	 * settings defaults
+	 */
+	protected $_defaults = array(
+		'field' => 'status',
+		'mappedField' => null, # NULL = same as above
+		//'mask' => null,
+		'bits' => null,
+		'before' => 'validate', // on: save or validate
+	);
+
+	/**
+	 * Behavior configuration
+	 *
+	 * @param Model $Model
+	 * @param array $config
+	 * @return void
+	 */
+	public function setup(Model $Model, $config = array()) {
+		$config = array_merge($this->_defaults, $config);
+		
+		if (empty($config['bits'])) {
+			$config['bits'] = Inflector::pluralize($config['field']);
+		}
+		if (is_string($config['bits'])) {
+			if (method_exists($Model, $config['bits'])) {
+				$config['bits'] = $Model->{$config['bits']}();
+			}	else {
+				$config['bits'] = false;
+			}
+		}
+		if (empty($config['bits'])) {
+			throw new InternalErrorException('Bits not found');
+		}
+		
+		/*
+		if (Set::numeric(array_keys($config['bits']))) {
+			$last = 1;
+			$bits = array();
+			foreach ($config['bits'] as $flag) {
+				$bits[$flag] = $last = $last * 2;
+			}
+			$config['bits'] = $bits;
+		}
+		*/
+		$this->settings[$Model->alias] = $config;
+	}
+	
+	public function beforeFind(Model $Model, $query) {
+		$field = $this->settings[$Model->alias]['field'];
+		
+		if (isset($query['conditions']) && is_array($query['conditions'])) {
+			$query['conditions'] = $this->encodeConditions($Model, $query['conditions']);
+		}
+				
+		return $query;
+	}
+	
+	public function afterFind(Model $Model, $results, $primary) {
+		$field = $this->settings[$Model->alias]['field'];
+		if (!($mappedField = $this->settings[$Model->alias]['mappedField'])) {
+			$mappedField = $field;
+		}
+		
+		foreach ($results as $key => $result) {
+			if (isset($result[$Model->alias][$field])) {
+				$results[$key][$Model->alias][$mappedField] = $this->decode($Model, $result[$Model->alias][$field]);
+			}	
+		}
+		
+		return $results;
+	}
+	
+	public function beforeValidate(Model $Model) {
+		if ($this->settings[$Model->alias]['before'] != 'validate') {
+			return true;
+		}
+		$this->encodeData($Model);
+		return true;
+	}
+	
+	public function beforeSave(Model $Model) {
+		if ($this->settings[$Model->alias]['before'] != 'save') {
+			return true;
+		}
+		$this->encodeData($Model);
+		return true;
+	}
+		
+	
+	/**
+	 * @param int $bitmask
+	 * @return array $bitmaskArray
+	 * from DB to APP
+	 */
+	public function decode(Model $Model, $value) {
+		$res = array();
+		$i = 0;
+		$value = (int) $value;
+		foreach ($this->settings[$Model->alias]['bits'] as $key => $val) {
+			$val = (($value & pow(2, $i)) != 0) ? true : false;
+			if ($val) {
+				$res[] = $key;
+			}
+			$i++;
+ 		}
+		
+		return $res;
+	}
+	
+	/**
+	 * @param array $bitmaskArrayk
+	 * @return int $bitmask
+	 * from APP to DB
+	 */
+	public function encode(Model $Model, $value) {
+		$res = 0;
+		foreach ($value as $key => $val) {
+			$res |= (int) $val;
+		}
+		if ($res === 0) {
+			return null; # make sure notEmpty validation rule triggers
+		}
+		return $res;
+	}
+	
+	public function encodeConditions(Model $Model, $conditions) {
+		$field = $this->settings[$Model->alias]['field'];
+		if (!($mappedField = $this->settings[$Model->alias]['mappedField'])) {
+			$mappedField = $field;
+		}
+
+		foreach ($conditions as $key => $val) {
+			if ($key === $mappedField) {
+				$conditions[$field] = $this->encode($Model, $val);
+				if ($field != $mappedField) {
+					unset($conditions[$mappedField]);
+				}
+				continue;
+			} elseif ($key === $Model->alias . '.' . $mappedField) {
+				$conditions[$Model->alias . '.' .$field] = $this->encode($Model, $val);
+				if ($field != $mappedField) {
+					unset($conditions[$Model->alias . '.' .$mappedField]);
+				}
+				continue;
+			}
+			if (!is_array($val)) {
+				continue;
+			}
+			$conditions[$key] = $this->encodeConditions($Model, $val);
+		}
+		return $conditions;
+	}
+	
+	public function encodeData(Model $Model) {
+		$field = $this->settings[$Model->alias]['field'];
+		if (!($mappedField = $this->settings[$Model->alias]['mappedField'])) {
+			$mappedField = $field;
+		}
+		
+		if (isset($Model->data[$Model->alias][$mappedField])) {
+			$Model->data[$Model->alias][$field] = $this->encode($Model, $Model->data[$Model->alias][$mappedField]);
+		}
+		if ($field != $mappedField) {
+			unset($Model->data[$Model->alias][$mappedField]);
+		}
+	}
+	
+	/**
+	 * @param mixed bits (int, array)
+	 * @return array $sqlSnippet
+	 */
+	public function containsBit(Model $Model, $bits) {
+		$bits = (array) $bits;
+		
+		$res = array();
+		foreach ($bits as $bit) {
+			$res[]['('.$Model->alias.'.'.$this->settings[$Model->alias]['field'].' & ? = ?)'] = array($bit, $bit);
+		}
+		if (count($res) > 1) {
+			return array('AND'=>$res);
+		}
+		return $res[0];
+	}
+	
+	/**
+	 * @param mixed bits (int, array)
+	 * @return array $sqlSnippet
+	 */
+	public function containsNotBit(Model $Model, $bits) {
+		$bits = (array) $bits;
+		
+		$res = array();
+		foreach ($bits as $bit) {
+			$res[]['('.$Model->alias.'.'.$this->settings[$Model->alias]['field'].' & ? != ?)'] = array($bit, $bit);
+		}
+		if (count($res) > 1) {
+			return array('AND'=>$res);
+		}
+		return $res[0];
+	}
+	
+}

+ 175 - 0
Test/Case/Behavior/BitmaskedBehaviorTest.php

@@ -0,0 +1,175 @@
+<?php
+
+App::import('Behavior', 'Tools.Bitmasked');
+App::import('Model', 'App');
+App::uses('MyCakeTestCase', 'Tools.Lib');
+App::uses('MyModel', 'Tools.Lib');
+
+class BitmaskedBehaviorTest extends MyCakeTestCase {
+	
+	public $fixtures = array(
+		'plugin.tools.bitmasked_comment'
+	);
+	
+	public $Comment;
+
+	public function startTest() {
+
+		$this->Comment = new BitmaskedComment();
+		$this->Comment->Behaviors->attach('Bitmasked', array('mappedField'=>'statuses'));
+	}
+	
+
+	public function testFind() {
+		$res = $this->Comment->find('all');
+		$this->assertTrue(!empty($res) && is_array($res));
+		
+		$this->assertTrue(!empty($res[1]['BitmaskedComment']['statuses']) && is_array($res[1]['BitmaskedComment']['statuses']));
+		
+		debug($res[count($res)-1]);
+		ob_flush();
+	}
+
+	public function testSave() {
+		$data = array(
+			'comment' => 'test save',
+			'statuses' => array(),
+		);
+		$this->Comment->create();
+		$this->Comment->set($data);
+		$res = $this->Comment->validates();
+		$this->assertFalse($res);
+		
+		$data = array(
+			'comment' => 'test save',
+			'statuses' => array(BitmaskedComment::STATUS_PUBLISHED, BitmaskedComment::STATUS_APPROVED),
+		);
+		$this->Comment->create();
+		$this->Comment->set($data);
+		$res = $this->Comment->validates();
+		$this->assertTrue($res);
+		
+		# save + find
+		
+		$this->Comment->create();
+		$res = $this->Comment->save($data);
+		$this->assertTrue(!empty($res));
+		
+		$res = $this->Comment->find('first', array('conditions'=>array('statuses'=>$data['statuses'])));
+		$this->assertTrue(!empty($res));
+		$expected = BitmaskedComment::STATUS_APPROVED | BitmaskedComment::STATUS_PUBLISHED; // 6
+		$this->assertEquals($expected, $res['BitmaskedComment']['status']);
+		$expected = $data['statuses'];
+
+		$this->assertEquals($expected, $res['BitmaskedComment']['statuses']);
+		
+		# model.field syntax
+		$res = $this->Comment->find('first', array('conditions'=>array('BitmaskedComment.statuses'=>$data['statuses'])));
+		$this->assertTrue(!empty($res));
+		
+		# explitit
+		$activeApprovedAndPublished = BitmaskedComment::STATUS_ACTIVE | BitmaskedComment::STATUS_APPROVED | BitmaskedComment::STATUS_PUBLISHED;
+		$data = array(
+			'comment' => 'another post comment',
+			'status' => $activeApprovedAndPublished,
+		);
+		$this->Comment->create();
+		$res = $this->Comment->save($data);
+		$this->assertTrue(!empty($res));
+				
+		$res = $this->Comment->find('first', array('conditions'=>array('status'=>$activeApprovedAndPublished)));
+		$this->assertTrue(!empty($res));
+		$this->assertEquals($activeApprovedAndPublished, $res['BitmaskedComment']['status']);
+		$expected = array(BitmaskedComment::STATUS_ACTIVE, BitmaskedComment::STATUS_PUBLISHED, BitmaskedComment::STATUS_APPROVED);
+
+		$this->assertEquals($expected, $res['BitmaskedComment']['statuses']);
+	}
+	
+	public function testContains() {
+		$res = $this->Comment->containsBit(BitmaskedComment::STATUS_PUBLISHED);
+		$expected = array(
+			'(BitmaskedComment.status & ? = ?)' => array(2, 2)
+		);
+		$this->assertEquals($expected, $res);
+		
+		$conditions = $res;
+		$res = $this->Comment->find('all', array('conditions'=>$conditions));
+		$this->assertTrue(!empty($res) && count($res) === 3);
+		
+		# multiple (AND)
+		$res = $this->Comment->containsBit(array(BitmaskedComment::STATUS_PUBLISHED, BitmaskedComment::STATUS_ACTIVE));
+		
+		$expected = array(
+			'AND' => array(
+				array('(BitmaskedComment.status & ? = ?)' => array(2, 2)),
+				array('(BitmaskedComment.status & ? = ?)' => array(1, 1))
+			)
+		);
+		$this->assertEquals($expected, $res);
+		
+		$conditions = $res;
+		$res = $this->Comment->find('all', array('conditions'=>$conditions));
+		$this->assertTrue(!empty($res) && count($res) === 2);
+	}
+	
+	public function testNotContains() {
+		$res = $this->Comment->containsNotBit(BitmaskedComment::STATUS_PUBLISHED);
+		$expected = array(
+			'(BitmaskedComment.status & ? != ?)' => array(2, 2)
+		);
+		$this->assertEquals($expected, $res);
+		
+		$conditions = $res;
+		$res = $this->Comment->find('all', array('conditions'=>$conditions));
+		$this->assertTrue(!empty($res) && count($res) === 4);
+		
+		# multiple (AND)
+		$res = $this->Comment->containsNotBit(array(BitmaskedComment::STATUS_PUBLISHED, BitmaskedComment::STATUS_ACTIVE));
+		
+		$expected = array(
+			'AND' => array(
+				array('(BitmaskedComment.status & ? != ?)' => array(2, 2)),
+				array('(BitmaskedComment.status & ? != ?)' => array(1, 1))
+			)
+		);
+		$this->assertEquals($expected, $res);
+		
+		$conditions = $res;
+		$res = $this->Comment->find('all', array('conditions'=>$conditions));
+		$this->assertTrue(!empty($res) && count($res) === 2);
+		
+		ob_flush();
+	}
+		
+}
+
+
+
+class BitmaskedComment extends CakeTestModel {
+	
+	public $validate = array(
+		'status' => array(
+			'notEmpty' => array(
+				'rule' => 'notEmpty',
+				'last' => true
+			)
+		)
+	);
+	
+	public static function statuses($value = null) {
+		$options = array(
+			self::STATUS_ACTIVE => __('Active'),
+			self::STATUS_PUBLISHED => __('Published'),
+			self::STATUS_APPROVED => __('Approved'),
+			self::STATUS_FLAGGED => __('Flagged'),
+		);
+				
+		return MyModel::enum($value, $options);
+	}
+	
+	const STATUS_NONE = 0;
+	const STATUS_ACTIVE = 1;
+	const STATUS_PUBLISHED = 2;
+	const STATUS_APPROVED = 4;
+	const STATUS_FLAGGED = 8;
+}

+ 37 - 0
Test/Fixture/BitmaskedCommentFixture.php

@@ -0,0 +1,37 @@
+<?php
+/**
+ * For BitmaskedBehaviorTest
+ *
+ */
+class BitmaskedCommentFixture extends CakeTestFixture {
+
+	/**
+	 * fields property
+	 *
+	 * @var array
+	 */
+	public $fields = array(
+		'id' => array('type' => 'integer', 'key' => 'primary'),
+		'article_id' => array('type' => 'integer', 'null' => false),
+		'user_id' => array('type' => 'integer', 'null' => false),
+		'comment' => 'text',
+		'status' => array('type' => 'integer', 'null' => false, 'length' => 2, 'default' => '0'),
+		'created' => 'datetime',
+		'updated' => 'datetime'
+	);
+
+	/**
+	 * 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')
+	);
+}