BitmaskedBehavior.php 11 KB

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