BitmaskedBehavior.php 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  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. $options += ['type' => 'exact'];
  57. if ($options['type'] === 'contain') {
  58. return $query->where($this->containsBit($options['bits']));
  59. }
  60. $bits = $this->encodeBitmask($options['bits']);
  61. return $query->where([$this->_table->getAlias() . '.' . $this->_config['field'] => $bits]);
  62. }
  63. /**
  64. * Behavior configuration
  65. *
  66. * @param array $config
  67. * @return void
  68. */
  69. public function initialize(array $config = []) {
  70. $config = $this->_config;
  71. if (empty($config['bits'])) {
  72. $config['bits'] = Inflector::variable(Inflector::pluralize($config['field']));
  73. }
  74. $entity = $this->_table->newEntity();
  75. if (is_callable($config['bits'])) {
  76. $config['bits'] = call_user_func($config['bits']);
  77. } elseif (is_string($config['bits']) && method_exists($entity, $config['bits'])) {
  78. $method = $config['bits'];
  79. $config['bits'] = version_compare(PHP_VERSION, '7.0', '<') ? $entity->$method() : $entity::$method();
  80. } elseif (is_string($config['bits']) && method_exists($this->_table, $config['bits'])) {
  81. $table = $this->_table;
  82. $method = $config['bits'];
  83. $config['bits'] = version_compare(PHP_VERSION, '7.0', '<') ? $table->$method() : $table::$method();
  84. } elseif (!is_array($config['bits'])) {
  85. $config['bits'] = false;
  86. }
  87. if (empty($config['bits'])) {
  88. $method = Inflector::variable(Inflector::pluralize($config['field'])) . '()';
  89. throw new RuntimeException('Bits not found for field ' . $config['field'] . ', expected pluralized static method ' . $method . ' on the entity.');
  90. }
  91. ksort($config['bits'], SORT_NUMERIC);
  92. $this->_config = $config;
  93. }
  94. /**
  95. * @param \Cake\Event\Event $event
  96. * @param \Cake\ORM\Query $query
  97. * @param \ArrayObject $options
  98. * @param bool $primary
  99. *
  100. * @return void
  101. */
  102. public function beforeFind(Event $event, Query $query, ArrayObject $options, $primary) {
  103. $this->encodeBitmaskConditions($query);
  104. $field = $this->_config['field'];
  105. if (!($mappedField = $this->_config['mappedField'])) {
  106. $mappedField = $field;
  107. }
  108. $mapper = function ($row, $key, $mr) use ($field, $mappedField) {
  109. /**
  110. * @var \Cake\Collection\Iterator\MapReduce $mr
  111. * @var \Cake\Datasource\EntityInterface|array $row
  112. */
  113. if (!is_object($row)) {
  114. if (isset($row[$field])) {
  115. $row[$mappedField] = $this->decodeBitmask($row[$field]);
  116. }
  117. $mr->emit($row);
  118. return;
  119. }
  120. /** @var \Cake\Datasource\EntityInterface $entity */
  121. $entity = $row;
  122. $entity->set($mappedField, $this->decodeBitmask($entity->get($field)));
  123. $entity->setDirty($mappedField, false);
  124. $mr->emit($entity);
  125. };
  126. $query->mapReduce($mapper);
  127. }
  128. /**
  129. * @param \Cake\Event\Event $event
  130. * @param \ArrayObject $data
  131. * @param \ArrayObject $options
  132. * @return void
  133. */
  134. public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $options) {
  135. if ($this->_config['on'] !== 'beforeMarshal') {
  136. return;
  137. }
  138. $this->encodeBitmaskDataRaw($data);
  139. }
  140. /**
  141. * @param \Cake\Event\Event $event
  142. * @param \Cake\Datasource\EntityInterface $entity
  143. * @param \ArrayObject $options
  144. * @param string $operation
  145. *
  146. * @return void
  147. */
  148. public function beforeRules(Event $event, EntityInterface $entity, ArrayObject $options, $operation) {
  149. if ($this->_config['on'] !== 'beforeRules' || !$options['checkRules']) {
  150. return;
  151. }
  152. $this->encodeBitmaskData($entity);
  153. }
  154. /**
  155. * @param \Cake\Event\Event $event
  156. * @param \Cake\Datasource\EntityInterface $entity
  157. * @param \ArrayObject $options
  158. * @return void
  159. */
  160. public function beforeSave(Event $event, EntityInterface $entity, ArrayObject $options) {
  161. if ($this->_config['on'] !== 'beforeSave') {
  162. return;
  163. }
  164. $this->encodeBitmaskData($entity);
  165. }
  166. /**
  167. * @param int $value Bitmask.
  168. * @return array Bitmask array (from DB to APP).
  169. */
  170. public function decodeBitmask($value) {
  171. $res = [];
  172. $value = (int)$value;
  173. foreach ($this->_config['bits'] as $key => $val) {
  174. $val = (($value & $key) !== 0) ? true : false;
  175. if ($val) {
  176. $res[] = $key;
  177. }
  178. }
  179. return $res;
  180. }
  181. /**
  182. * @param array $value Bitmask array.
  183. * @param mixed $defaultValue Default bitmask value.
  184. * @return int Bitmask (from APP to DB).
  185. */
  186. public function encodeBitmask($value, $defaultValue = null) {
  187. $res = 0;
  188. if (empty($value)) {
  189. return $defaultValue;
  190. }
  191. foreach ((array)$value as $key => $val) {
  192. $res |= (int)$val;
  193. }
  194. if ($res === 0) {
  195. return $defaultValue; // Make sure notEmpty validation rule triggers
  196. }
  197. return $res;
  198. }
  199. /**
  200. * @param \Cake\ORM\Query $query
  201. * @return void
  202. */
  203. public function encodeBitmaskConditions(Query $query) {
  204. $field = $this->_config['field'];
  205. $mappedField = $this->_config['mappedField'];
  206. if (!$mappedField) {
  207. $mappedField = $field;
  208. }
  209. $where = $query->clause('where');
  210. if (!$where) {
  211. return;
  212. }
  213. $callable = function ($comparison) use ($field, $mappedField) {
  214. if (!$comparison instanceof Comparison) {
  215. return $comparison;
  216. }
  217. $key = $comparison->getField();
  218. if ($key !== $mappedField && $key !== $this->_table->getAlias() . '.' . $mappedField) {
  219. return $comparison;
  220. }
  221. $comparison->setValue($this->encodeBitmask($comparison->getValue()));
  222. if ($field !== $mappedField) {
  223. $comparison->setField($field);
  224. }
  225. return $comparison;
  226. };
  227. $where->iterateParts($callable);
  228. }
  229. /**
  230. * @param \ArrayObject $data
  231. * @return void
  232. */
  233. public function encodeBitmaskDataRaw(ArrayObject $data) {
  234. $field = $this->_config['field'];
  235. if (!($mappedField = $this->_config['mappedField'])) {
  236. $mappedField = $field;
  237. }
  238. $default = $this->_getDefault($field);
  239. if (!isset($data[$mappedField])) {
  240. return;
  241. }
  242. $data[$field] = $this->encodeBitmask($data[$mappedField], $default);
  243. if ($field !== $mappedField) {
  244. unset($data[$mappedField]);
  245. }
  246. }
  247. /**
  248. * @param \Cake\Datasource\EntityInterface $entity
  249. * @return void
  250. */
  251. public function encodeBitmaskData(EntityInterface $entity) {
  252. $field = $this->_config['field'];
  253. if (!($mappedField = $this->_config['mappedField'])) {
  254. $mappedField = $field;
  255. }
  256. $default = $this->_getDefault($field);
  257. if ($entity->get($mappedField) === null) {
  258. return;
  259. }
  260. $entity->set($field, $this->encodeBitmask($entity->get($mappedField), $default));
  261. if ($field !== $mappedField) {
  262. $entity->unsetProperty($mappedField);
  263. }
  264. }
  265. /**
  266. * @param string $field
  267. *
  268. * @return int|null
  269. */
  270. protected function _getDefault($field) {
  271. $default = null;
  272. $schema = $this->_table->getSchema()->getColumn($field);
  273. if ($schema && isset($schema['default'])) {
  274. $default = $schema['default'];
  275. }
  276. if ($this->_config['defaultValue'] !== null) {
  277. $default = $this->_config['defaultValue'];
  278. }
  279. return $default;
  280. }
  281. /**
  282. * @param int|array $bits
  283. * @return array SQL snippet.
  284. */
  285. public function isBit($bits) {
  286. $bits = (array)$bits;
  287. $bitmask = $this->encodeBitmask($bits);
  288. $field = $this->_config['field'];
  289. return [$this->_table->getAlias() . '.' . $field => $bitmask];
  290. }
  291. /**
  292. * @param int|array $bits
  293. * @return array SQL snippet.
  294. */
  295. public function isNotBit($bits) {
  296. return ['NOT' => $this->isBit($bits)];
  297. }
  298. /**
  299. * @param int|array $bits
  300. * @return array SQL snippet.
  301. */
  302. public function containsBit($bits) {
  303. return $this->_containsBit($bits);
  304. }
  305. /**
  306. * @param int|array $bits
  307. * @return array SQL snippet.
  308. */
  309. public function containsNotBit($bits) {
  310. return $this->_containsBit($bits, false);
  311. }
  312. /**
  313. * @param int|array $bits
  314. * @param bool $contain
  315. * @return array SQL snippet.
  316. */
  317. protected function _containsBit($bits, $contain = true) {
  318. $bits = (array)$bits;
  319. $bitmask = $this->encodeBitmask($bits);
  320. $field = $this->_config['field'];
  321. $contain = $contain ? ' & ? = ?' : ' & ? != ?';
  322. $contain = Text::insert($contain, [$bitmask, $bitmask]);
  323. // Hack for Postgres for now
  324. $connection = $this->_table->getConnection();
  325. $config = $connection->config();
  326. if ((strpos($config['driver'], 'Postgres') !== false)) {
  327. return ['("' . $this->_table->getAlias() . '"."' . $field . '"' . $contain . ')'];
  328. }
  329. return ['(' . $this->_table->getAlias() . '.' . $field . $contain . ')'];
  330. }
  331. }