BitmaskedBehavior.php 10 KB

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