BitmaskedBehavior.php 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. <?php
  2. namespace Tools\Model\Behavior;
  3. use ArrayObject;
  4. use Cake\Database\Expression\Comparison;
  5. use Cake\Datasource\EntityInterface;
  6. use Cake\Event\Event;
  7. use Cake\ORM\Behavior;
  8. use Cake\ORM\Query;
  9. use Cake\Utility\Inflector;
  10. use InvalidArgumentException;
  11. use RuntimeException;
  12. use Tools\Utility\Text;
  13. /**
  14. * BitmaskedBehavior
  15. *
  16. * An implementation of bitwise masks for row-level operations.
  17. * You can submit/register flags in different ways. The easiest way is using a static model function.
  18. * It should contain the bits like so (starting with 1):
  19. * 1 => w, 2 => x, 4 => y, 8 => z, ... (bits as keys - names as values)
  20. * The order doesn't matter, as long as no bit is used twice.
  21. *
  22. * The theoretical limit for a 64-bit integer would be 64 bits (2^64).
  23. * But if you actually seem to need more than a hand full you
  24. * obviously do something wrong and should better use a joined table etc.
  25. *
  26. * @author Mark Scherer
  27. * @license MIT
  28. * @link http://www.dereuromark.de/2012/02/26/bitmasked-using-bitmasks-in-cakephp/
  29. */
  30. class BitmaskedBehavior extends Behavior {
  31. /**
  32. * Default config
  33. *
  34. * @var array
  35. */
  36. protected $_defaultConfig = [
  37. 'field' => 'status',
  38. 'mappedField' => null, // NULL = same as above
  39. 'bits' => null, // Method or callback to get the bits data
  40. 'on' => 'beforeMarshal', // or beforeRules or beforeSave
  41. 'defaultValue' => null, // NULL = auto (use empty string to trigger "notEmpty" rule for "default NOT NULL" db fields)
  42. 'implementedFinders' => [
  43. 'bits' => 'findBitmasked'
  44. ],
  45. ];
  46. /**
  47. * @param \Cake\ORM\Query $query
  48. * @param array $options
  49. * @return \Cake\ORM\Query
  50. * @throws \InvalidArgumentException If the 'slug' key is missing in options
  51. */
  52. public function findBitmasked(Query $query, array $options) {
  53. if (!isset($options['bits'])) {
  54. throw new InvalidArgumentException("The 'bits' key is required for find('bits')");
  55. }
  56. $bits = $this->encodeBitmask($options['bits']);
  57. return $query->where([$this->_table->getAlias() . '.' . $this->_config['field'] => $bits]);
  58. }
  59. /**
  60. * Behavior configuration
  61. *
  62. * @param array $config
  63. * @return void
  64. */
  65. public function initialize(array $config = []) {
  66. $config = $this->_config;
  67. if (empty($config['bits'])) {
  68. $config['bits'] = Inflector::variable(Inflector::pluralize($config['field']));
  69. }
  70. $entity = $this->_table->newEntity();
  71. if (is_callable($config['bits'])) {
  72. $config['bits'] = call_user_func($config['bits']);
  73. } elseif (is_string($config['bits']) && method_exists($entity, $config['bits'])) {
  74. $method = $config['bits'];
  75. $config['bits'] = version_compare(PHP_VERSION, '7.0', '<') ? $entity->$method() : $entity::$method();
  76. } elseif (is_string($config['bits']) && method_exists($this->_table, $config['bits'])) {
  77. $table = $this->_table;
  78. $method = $config['bits'];
  79. $config['bits'] = version_compare(PHP_VERSION, '7.0', '<') ? $table->$method() : $table::$method();
  80. } elseif (!is_array($config['bits'])) {
  81. $config['bits'] = false;
  82. }
  83. if (empty($config['bits'])) {
  84. $method = Inflector::variable(Inflector::pluralize($config['field'])) . '()';
  85. throw new RuntimeException('Bits not found for field ' . $config['field'] . ', expected pluralized static method ' . $method . ' on the entity.');
  86. }
  87. ksort($config['bits'], SORT_NUMERIC);
  88. $this->_config = $config;
  89. }
  90. /**
  91. * @param \Cake\Event\Event $event
  92. * @param \Cake\ORM\Query $query
  93. * @param \ArrayObject $options
  94. * @param bool $primary
  95. *
  96. * @return void
  97. */
  98. public function beforeFind(Event $event, Query $query, ArrayObject $options, $primary) {
  99. $this->encodeBitmaskConditions($query);
  100. $field = $this->_config['field'];
  101. if (!($mappedField = $this->_config['mappedField'])) {
  102. $mappedField = $field;
  103. }
  104. $mapper = function ($row, $key, $mr) use ($field, $mappedField) {
  105. /**
  106. * @var \Cake\Collection\Iterator\MapReduce $mr
  107. * @var \Cake\Datasource\EntityInterface|array $row
  108. */
  109. if (!is_object($row)) {
  110. if (isset($row[$field])) {
  111. $row[$mappedField] = $this->decodeBitmask($row[$field]);
  112. }
  113. $mr->emit($row);
  114. return;
  115. }
  116. /** @var \Cake\Datasource\EntityInterface $entity */
  117. $entity = $row;
  118. $entity->set($mappedField, $this->decodeBitmask($entity->get($field)));
  119. $entity->setDirty($mappedField, false);
  120. $mr->emit($entity);
  121. };
  122. $query->mapReduce($mapper);
  123. }
  124. /**
  125. * @param \Cake\Event\Event $event
  126. * @param \ArrayObject $data
  127. * @param \ArrayObject $options
  128. * @return void
  129. */
  130. public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $options) {
  131. if ($this->_config['on'] !== 'beforeMarshal') {
  132. return;
  133. }
  134. $this->encodeBitmaskDataRaw($data);
  135. }
  136. /**
  137. * @param \Cake\Event\Event $event
  138. * @param \Cake\Datasource\EntityInterface $entity
  139. * @param \ArrayObject $options
  140. * @param string $operation
  141. *
  142. * @return void
  143. */
  144. public function beforeRules(Event $event, EntityInterface $entity, ArrayObject $options, $operation) {
  145. if ($this->_config['on'] !== 'beforeRules' || !$options['checkRules']) {
  146. return;
  147. }
  148. $this->encodeBitmaskData($entity);
  149. }
  150. /**
  151. * @param \Cake\Event\Event $event
  152. * @param \Cake\Datasource\EntityInterface $entity
  153. * @param \ArrayObject $options
  154. * @return void
  155. */
  156. public function beforeSave(Event $event, EntityInterface $entity, ArrayObject $options) {
  157. if ($this->_config['on'] !== 'beforeSave') {
  158. return;
  159. }
  160. $this->encodeBitmaskData($entity);
  161. }
  162. /**
  163. * @param int $value Bitmask.
  164. * @return array Bitmask array (from DB to APP).
  165. */
  166. public function decodeBitmask($value) {
  167. $res = [];
  168. $value = (int)$value;
  169. foreach ($this->_config['bits'] as $key => $val) {
  170. $val = (($value & $key) !== 0) ? true : false;
  171. if ($val) {
  172. $res[] = $key;
  173. }
  174. }
  175. return $res;
  176. }
  177. /**
  178. * @param array $value Bitmask array.
  179. * @param mixed $defaultValue Default bitmask value.
  180. * @return int Bitmask (from APP to DB).
  181. */
  182. public function encodeBitmask($value, $defaultValue = null) {
  183. $res = 0;
  184. if (empty($value)) {
  185. return $defaultValue;
  186. }
  187. foreach ((array)$value as $key => $val) {
  188. $res |= (int)$val;
  189. }
  190. if ($res === 0) {
  191. return $defaultValue; // Make sure notEmpty validation rule triggers
  192. }
  193. return $res;
  194. }
  195. /**
  196. * @param \Cake\ORM\Query $query
  197. * @return void
  198. */
  199. public function encodeBitmaskConditions(Query $query) {
  200. $field = $this->_config['field'];
  201. $mappedField = $this->_config['mappedField'];
  202. if (!$mappedField) {
  203. $mappedField = $field;
  204. }
  205. $where = $query->clause('where');
  206. if (!$where) {
  207. return;
  208. }
  209. $callable = function ($comparison) use ($field, $mappedField) {
  210. if (!$comparison instanceof Comparison) {
  211. return $comparison;
  212. }
  213. $key = $comparison->getField();
  214. if ($key !== $mappedField && $key !== $this->_table->getAlias() . '.' . $mappedField) {
  215. return $comparison;
  216. }
  217. $comparison->setValue($this->encodeBitmask($comparison->getValue()));
  218. if ($field !== $mappedField) {
  219. $comparison->setField($field);
  220. }
  221. return $comparison;
  222. };
  223. $where->iterateParts($callable);
  224. }
  225. /**
  226. * @param \ArrayObject $data
  227. * @return void
  228. */
  229. public function encodeBitmaskDataRaw(ArrayObject $data) {
  230. $field = $this->_config['field'];
  231. if (!($mappedField = $this->_config['mappedField'])) {
  232. $mappedField = $field;
  233. }
  234. $default = $this->_getDefault($field);
  235. if (!isset($data[$mappedField])) {
  236. return;
  237. }
  238. $data[$field] = $this->encodeBitmask($data[$mappedField], $default);
  239. if ($field !== $mappedField) {
  240. unset($data[$mappedField]);
  241. }
  242. }
  243. /**
  244. * @param \Cake\Datasource\EntityInterface $entity
  245. * @return void
  246. */
  247. public function encodeBitmaskData(EntityInterface $entity) {
  248. $field = $this->_config['field'];
  249. if (!($mappedField = $this->_config['mappedField'])) {
  250. $mappedField = $field;
  251. }
  252. $default = $this->_getDefault($field);
  253. if ($entity->get($mappedField) === null) {
  254. return;
  255. }
  256. $entity->set($field, $this->encodeBitmask($entity->get($mappedField), $default));
  257. if ($field !== $mappedField) {
  258. $entity->unsetProperty($mappedField);
  259. }
  260. }
  261. /**
  262. * @param string $field
  263. *
  264. * @return int|null
  265. */
  266. protected function _getDefault($field) {
  267. $default = null;
  268. $schema = $this->_table->getSchema()->getColumn($field);
  269. if ($schema && isset($schema['default'])) {
  270. $default = $schema['default'];
  271. }
  272. if ($this->_config['defaultValue'] !== null) {
  273. $default = $this->_config['defaultValue'];
  274. }
  275. return $default;
  276. }
  277. /**
  278. * @param int|array $bits
  279. * @return array SQL snippet.
  280. */
  281. public function isBit($bits) {
  282. $bits = (array)$bits;
  283. $bitmask = $this->encodeBitmask($bits);
  284. $field = $this->_config['field'];
  285. return [$this->_table->getAlias() . '.' . $field => $bitmask];
  286. }
  287. /**
  288. * @param int|array $bits
  289. * @return array SQL snippet.
  290. */
  291. public function isNotBit($bits) {
  292. return ['NOT' => $this->isBit($bits)];
  293. }
  294. /**
  295. * @param int|array $bits
  296. * @return array SQL snippet.
  297. */
  298. public function containsBit($bits) {
  299. return $this->_containsBit($bits);
  300. }
  301. /**
  302. * @param int|array $bits
  303. * @return array SQL snippet.
  304. */
  305. public function containsNotBit($bits) {
  306. return $this->_containsBit($bits, false);
  307. }
  308. /**
  309. * @param int|array $bits
  310. * @param bool $contain
  311. * @return array SQL snippet.
  312. */
  313. protected function _containsBit($bits, $contain = true) {
  314. $bits = (array)$bits;
  315. $bitmask = $this->encodeBitmask($bits);
  316. $field = $this->_config['field'];
  317. $contain = $contain ? ' & ? = ?' : ' & ? != ?';
  318. $contain = Text::insert($contain, [$bitmask, $bitmask]);
  319. // Hack for Postgres for now
  320. $connection = $this->_table->getConnection();
  321. $config = $connection->config();
  322. if ((strpos($config['driver'], 'Postgres') !== false)) {
  323. return ['("' . $this->_table->getAlias() . '"."' . $field . '"' . $contain . ')'];
  324. }
  325. return ['(' . $this->_table->getAlias() . '.' . $field . $contain . ')'];
  326. }
  327. }