Browse Source

Add Enum support to Bitmasked behavior.

mscherer 2 years ago
parent
commit
5a8c30ad45

+ 3 - 0
docs/Behavior/Bitmasked.md

@@ -148,11 +148,14 @@ If you don't need that, and it is nullable, you can also set the event to e.g. `
 If you use `fields` config to whitelist the fields for patching, you should also whitelist
 the alias field if you defined one and if you are using `onMarshal`.
 
+Set `bits` to your `MyEnum::class` if you want to use backed enums. Setting `enum` to this class is only needed if you use a different way of setting
+the bits, e.g. using `'bits' => MyEnum::tryFrom(0)->options()` etc.
 
 ### Demo
 
 - Basics: https://sandbox.dereuromark.de/sandbox/tools-examples/bitmasks
 - Filtering: https://sandbox.dereuromark.de/sandbox/tools-examples/bitmask-search
+- Enums: https://sandbox.dereuromark.de/sandbox/tools-examples/bitmask-enums
 
 ### Outview
 

+ 83 - 52
src/Model/Behavior/BitmaskedBehavior.php

@@ -3,12 +3,15 @@
 namespace Tools\Model\Behavior;
 
 use ArrayObject;
+use BackedEnum;
 use Cake\Database\Expression\ComparisonExpression;
 use Cake\Datasource\EntityInterface;
 use Cake\Event\EventInterface;
 use Cake\ORM\Behavior;
 use Cake\ORM\Query\SelectQuery;
 use Cake\Utility\Inflector;
+use ReflectionEnum;
+use ReflectionException;
 use RuntimeException;
 
 /**
@@ -38,7 +41,8 @@ class BitmaskedBehavior extends Behavior {
 	protected array $_defaultConfig = [
 		'field' => 'status',
 		'mappedField' => null, // NULL = same as above
-		'bits' => null, // Method or callback to get the bits data
+		'bits' => null, // Enum, method or callback to get the bits data
+		'enum' => null, // Set to Enum class to use backed enum collection instead of plain scalar array, false to disable auto-detect
 		'on' => 'beforeMarshal', // or beforeRules or beforeSave
 		'defaultValue' => null, // NULL = auto (use empty string to trigger "notEmpty" rule for "default NOT NULL" db fields)
 		'implementedFinders' => [
@@ -49,6 +53,63 @@ class BitmaskedBehavior extends Behavior {
 	];
 
 	/**
+	 * Behavior configuration
+	 *
+	 * @param array $config
+	 * @throws \RuntimeException
+	 * @return void
+	 */
+	public function initialize(array $config): void {
+		$config += $this->_config;
+		if (empty($config['bits'])) {
+			$config['bits'] = Inflector::variable(Inflector::pluralize($config['field']));
+		}
+
+		$entity = $this->_table->newEmptyEntity();
+		$enumClass = false;
+
+		if (is_string($config['bits'])) {
+			try {
+				$reflectionEnum = new ReflectionEnum($config['bits']);
+				$cases = [];
+				foreach ($reflectionEnum->getCases() as $case) {
+					/** @var \BackedEnum $intBackedEnum */
+					$intBackedEnum = $case->getValue();
+					$cases[$intBackedEnum->value] = $intBackedEnum->name;
+				}
+				$enumClass = $config['bits'];
+				$config['bits'] = $cases;
+			} catch (ReflectionException) {
+			}
+		}
+		if ($config['enum'] === null) {
+			$config['enum'] = $enumClass;
+		}
+
+		if (is_callable($config['bits'])) {
+			$config['bits'] = call_user_func($config['bits']);
+		} elseif (is_string($config['bits']) && method_exists($entity, $config['bits'])) {
+			$method = $config['bits'];
+			$config['bits'] = $entity::$method();
+		} elseif (is_string($config['bits']) && method_exists($this->_table, $config['bits'])) {
+			// Deprecated: Will be removed in the next major, use Entity instead.
+			$table = $this->_table;
+			$method = $config['bits'];
+			$config['bits'] = $table::$method();
+		} elseif (!is_array($config['bits'])) {
+			$config['bits'] = false;
+		}
+		if (empty($config['bits'])) {
+			$method = Inflector::variable(Inflector::pluralize($config['field'])) . '()';
+
+			throw new RuntimeException('Bits not found for field ' . $config['field'] . ', expected pluralized static method ' . $method . ' on the entity.');
+		}
+		ksort($config['bits'], SORT_NUMERIC);
+
+		$this->_config = $config;
+	}
+
+	/**
 	 * @param \Cake\ORM\Query\SelectQuery $query
 	 * @param array<int> $bits
 	 * @param array<string, mixed> $options
@@ -92,44 +153,6 @@ class BitmaskedBehavior extends Behavior {
 	}
 
 	/**
-	 * Behavior configuration
-	 *
-	 * @param array $config
-	 * @throws \RuntimeException
-	 * @return void
-	 */
-	public function initialize(array $config): void {
-		$config += $this->_config;
-
-		if (empty($config['bits'])) {
-			$config['bits'] = Inflector::variable(Inflector::pluralize($config['field']));
-		}
-
-		$entity = $this->_table->newEmptyEntity();
-
-		if (is_callable($config['bits'])) {
-			$config['bits'] = call_user_func($config['bits']);
-		} elseif (is_string($config['bits']) && method_exists($entity, $config['bits'])) {
-			$method = $config['bits'];
-			$config['bits'] = $entity::$method();
-		} elseif (is_string($config['bits']) && method_exists($this->_table, $config['bits'])) {
-			$table = $this->_table;
-			$method = $config['bits'];
-			$config['bits'] = $table::$method();
-		} elseif (!is_array($config['bits'])) {
-			$config['bits'] = false;
-		}
-		if (empty($config['bits'])) {
-			$method = Inflector::variable(Inflector::pluralize($config['field'])) . '()';
-
-			throw new RuntimeException('Bits not found for field ' . $config['field'] . ', expected pluralized static method ' . $method . ' on the entity.');
-		}
-		ksort($config['bits'], SORT_NUMERIC);
-
-		$this->_config = $config;
-	}
-
-	/**
 	 * @param \Cake\Event\EventInterface $event
 	 * @param \Cake\ORM\Query\SelectQuery $query
 	 * @param \ArrayObject $options
@@ -227,16 +250,18 @@ class BitmaskedBehavior extends Behavior {
 	}
 
 	/**
-	 * @param int $value Bitmask.
-	 * @return array<int> Bitmask array (from DB to APP).
+	 * @param int|string $value Bitmask.
+	 * @return array<int|\BackedEnum> Bitmask array (from DB to APP).
 	 */
-	public function decodeBitmask($value) {
+	public function decodeBitmask($value): array {
 		$res = [];
 		$value = (int)$value;
+
+		$enum = $this->_config['enum'];
 		foreach ($this->_config['bits'] as $key => $val) {
-			$val = (($value & $key) !== 0) ? true : false;
+			$val = ($value & $key) !== 0;
 			if ($val) {
-				$res[] = $key;
+				$res[] = $enum ? $enum::tryFrom($key) : $key;
 			}
 		}
 
@@ -244,16 +269,21 @@ class BitmaskedBehavior extends Behavior {
 	}
 
 	/**
-	 * @param array<int> $value Bitmask array.
-	 * @param mixed|null $defaultValue Default bitmask value.
+	 * @param array<int|string>|string $value Bitmask array.
+	 * @param int|null $defaultValue Default bitmask value.
 	 * @return int|null Bitmask (from APP to DB).
 	 */
-	public function encodeBitmask($value, $defaultValue = null) {
+	public function encodeBitmask(array|string $value, $defaultValue = null): ?int {
 		$res = 0;
 		if (!$value) {
 			return $defaultValue;
 		}
-		foreach ((array)$value as $key => $val) {
+
+		foreach ((array)$value as $val) {
+			if ($val instanceof BackedEnum) {
+				$val = $val->value;
+			}
+
 			$res |= (int)$val;
 		}
 		if ($res === 0) {
@@ -289,7 +319,8 @@ class BitmaskedBehavior extends Behavior {
 				return $comparison;
 			}
 
-			$comparison->setValue((array)$this->encodeBitmask($comparison->getValue()));
+			$bitmask = $this->encodeBitmask($comparison->getValue());
+			$comparison->setValue((array)$bitmask);
 			if ($field !== $mappedField) {
 				$comparison->setField($field);
 			}
@@ -346,7 +377,7 @@ class BitmaskedBehavior extends Behavior {
 	 *
 	 * @return int|null
 	 */
-	protected function _getDefault($field) {
+	protected function _getDefault(string $field): ?int {
 		$default = null;
 		$schema = $this->_table->getSchema()->getColumn($field);
 
@@ -419,7 +450,7 @@ class BitmaskedBehavior extends Behavior {
 		// Hack for Postgres for now
 		$connection = $this->_table->getConnection();
 		$config = $connection->config();
-		if ((strpos($config['driver'], 'Postgres') !== false)) {
+		if ((str_contains($config['driver'], 'Postgres'))) {
 			return ['("' . $this->_table->getAlias() . '"."' . $field . '"' . $contain . ')'];
 		}
 
@@ -431,7 +462,7 @@ class BitmaskedBehavior extends Behavior {
 	 *
 	 * @return int|null
 	 */
-	protected function _getDefaultValue($field) {
+	protected function _getDefaultValue(string $field): ?int {
 		$schema = $this->_table->getSchema()->getColumn($field);
 
 		return $schema['default'] ?: 0;

+ 3 - 1
src/Model/Enum/EnumOptionsTrait.php

@@ -2,6 +2,8 @@
 
 namespace Tools\Model\Enum;
 
+use BackedEnum;
+
 /**
  * @mixin \BackedEnum&\Cake\Database\Type\EnumLabelInterface
  */
@@ -17,7 +19,7 @@ trait EnumOptionsTrait {
 
 		if ($cases) {
 			foreach ($cases as $case) {
-				if (!($case instanceof \BackedEnum)) {
+				if (!($case instanceof BackedEnum)) {
 					$case = static::from($case);
 				}
 

+ 40 - 5
tests/TestCase/Model/Behavior/BitmaskedBehaviorTest.php

@@ -5,6 +5,7 @@ namespace Tools\Test\TestCase\Model\Behavior;
 use RuntimeException;
 use Shim\TestSuite\TestCase;
 use TestApp\Model\Entity\BitmaskedComment;
+use TestApp\Model\Enum\BitmaskEnum;
 
 class BitmaskedBehaviorTest extends TestCase {
 
@@ -27,14 +28,12 @@ class BitmaskedBehaviorTest extends TestCase {
 		parent::setUp();
 
 		$this->Comments = $this->getTableLocator()->get('BitmaskedComments');
-		$this->Comments->addBehavior('Tools.Bitmasked', ['mappedField' => 'statuses']);
 	}
 
 	/**
 	 * @return void
 	 */
 	public function testConfig() {
-		$this->Comments->removeBehavior('Bitmasked');
 		$this->Comments->addBehavior('Tools.Bitmasked', []);
 		$bits = $this->Comments->behaviors()->Bitmasked->getConfig('bits');
 		$expected = BitmaskedComment::statuses();
@@ -45,8 +44,6 @@ class BitmaskedBehaviorTest extends TestCase {
 	 * @return void
 	 */
 	public function testFieldMethodMissing() {
-		$this->Comments->removeBehavior('Bitmasked');
-
 		$this->expectException(RuntimeException::class);
 		$this->expectExceptionMessage('Bits not found for field my_field, expected pluralized static method myFields() on the entity.');
 
@@ -57,6 +54,8 @@ class BitmaskedBehaviorTest extends TestCase {
 	 * @return void
 	 */
 	public function testEncodeBitmask() {
+		$this->Comments->addBehavior('Tools.Bitmasked', ['mappedField' => 'statuses']);
+
 		$res = $this->Comments->encodeBitmask([BitmaskedComment::STATUS_PUBLISHED, BitmaskedComment::STATUS_APPROVED]);
 		$expected = BitmaskedComment::STATUS_PUBLISHED | BitmaskedComment::STATUS_APPROVED;
 		$this->assertSame($expected, $res);
@@ -66,6 +65,8 @@ class BitmaskedBehaviorTest extends TestCase {
 	 * @return void
 	 */
 	public function testDecodeBitmask() {
+		$this->Comments->addBehavior('Tools.Bitmasked', ['mappedField' => 'statuses']);
+
 		$res = $this->Comments->decodeBitmask(BitmaskedComment::STATUS_PUBLISHED | BitmaskedComment::STATUS_APPROVED);
 		$expected = [BitmaskedComment::STATUS_PUBLISHED, BitmaskedComment::STATUS_APPROVED];
 		$this->assertSame($expected, $res);
@@ -75,6 +76,8 @@ class BitmaskedBehaviorTest extends TestCase {
 	 * @return void
 	 */
 	public function testFind() {
+		$this->Comments->addBehavior('Tools.Bitmasked', ['mappedField' => 'statuses']);
+
 		$res = $this->Comments->find('all')->toArray();
 		$this->assertTrue(!empty($res) && is_array($res));
 
@@ -85,6 +88,8 @@ class BitmaskedBehaviorTest extends TestCase {
 	 * @return void
 	 */
 	public function testFindBitmasked() {
+		$this->Comments->addBehavior('Tools.Bitmasked', ['mappedField' => 'statuses']);
+
 		$res = $this->Comments->find('bits', bits: [])->toArray();
 		$this->assertCount(1, $res);
 		$this->assertSame([], $res[0]->statuses);
@@ -97,7 +102,24 @@ class BitmaskedBehaviorTest extends TestCase {
 	/**
 	 * @return void
 	 */
+	public function testFindBitmaskedEnums() {
+		$this->Comments->addBehavior('Tools.Bitmasked', ['bits' => BitmaskEnum::class, 'mappedField' => 'statuses']);
+
+		$res = $this->Comments->find('bits', bits: [])->toArray();
+		$this->assertCount(1, $res);
+		$this->assertSame([], $res[0]->statuses);
+
+		$res = $this->Comments->find('bits', bits: [BitmaskEnum::One, BitmaskEnum::Four])->toArray();
+		$this->assertCount(1, $res);
+		$this->assertSame([BitmaskEnum::One, BitmaskEnum::Four], $res[0]->statuses);
+	}
+
+	/**
+	 * @return void
+	 */
 	public function testFindBitmaskedContain() {
+		$this->Comments->addBehavior('Tools.Bitmasked', ['mappedField' => 'statuses']);
+
 		$bits = [];
 		$options = [
 			'type' => 'contain',
@@ -125,6 +147,8 @@ class BitmaskedBehaviorTest extends TestCase {
 	 * @return void
 	 */
 	public function testFindBitmaskedContainAnd() {
+		$this->Comments->addBehavior('Tools.Bitmasked', ['mappedField' => 'statuses']);
+
 		$bits = [];
 		$options = [
 			'type' => 'contain',
@@ -147,6 +171,8 @@ class BitmaskedBehaviorTest extends TestCase {
 	 * @return void
 	 */
 	public function testSaveBasic() {
+		$this->Comments->addBehavior('Tools.Bitmasked', ['mappedField' => 'statuses']);
+
 		$data = [
 			'comment' => 'test save',
 			'statuses' => [],
@@ -211,6 +237,8 @@ class BitmaskedBehaviorTest extends TestCase {
 	 * @return void
 	 */
 	public function testSaveWithDefaultValue() {
+		$this->Comments->addBehavior('Tools.Bitmasked', ['mappedField' => 'statuses']);
+
 		$data = [
 			'comment' => 'test save',
 			'statuses' => [],
@@ -242,7 +270,6 @@ class BitmaskedBehaviorTest extends TestCase {
 	 * @return void
 	 */
 	public function testSaveOnBeforeSave() {
-		$this->Comments->removeBehavior('Bitmasked');
 		$this->Comments->addBehavior('Tools.Bitmasked', ['mappedField' => 'statuses', 'on' => 'beforeSave']);
 		$data = [
 			'comment' => 'test save',
@@ -260,6 +287,8 @@ class BitmaskedBehaviorTest extends TestCase {
 	 * @return void
 	 */
 	public function testIs() {
+		$this->Comments->addBehavior('Tools.Bitmasked', ['mappedField' => 'statuses']);
+
 		$res = $this->Comments->isBit(BitmaskedComment::STATUS_PUBLISHED);
 		$expected = ['BitmaskedComments.status' => 2];
 		$this->assertEquals($expected, $res);
@@ -269,6 +298,8 @@ class BitmaskedBehaviorTest extends TestCase {
 	 * @return void
 	 */
 	public function testIsNot() {
+		$this->Comments->addBehavior('Tools.Bitmasked', ['mappedField' => 'statuses']);
+
 		$res = $this->Comments->isNotBit(BitmaskedComment::STATUS_PUBLISHED);
 		$expected = ['NOT' => ['BitmaskedComments.status' => 2]];
 		$this->assertEquals($expected, $res);
@@ -278,6 +309,8 @@ class BitmaskedBehaviorTest extends TestCase {
 	 * @return void
 	 */
 	public function testContains() {
+		$this->Comments->addBehavior('Tools.Bitmasked', ['mappedField' => 'statuses']);
+
 		$config = $this->Comments->getConnection()->config();
 		$isPostgres = strpos($config['driver'], 'Postgres') !== false;
 
@@ -309,6 +342,8 @@ class BitmaskedBehaviorTest extends TestCase {
 	 * @return void
 	 */
 	public function testNotContains() {
+		$this->Comments->addBehavior('Tools.Bitmasked', ['mappedField' => 'statuses']);
+
 		$config = $this->Comments->getConnection()->config();
 		$isPostgres = strpos($config['driver'], 'Postgres') !== false;
 

+ 1 - 1
tests/TestCase/Model/Enum/EnumOptionsTraitTest.php

@@ -32,7 +32,7 @@ class EnumOptionsTraitTest extends TestCase {
 			1 => 'One',
 		];
 
-		$res = FooBar::options([FooBar::TWO, FooBar::ONE], $array);
+		$res = FooBar::options([FooBar::Two, FooBar::One], $array);
 		$expected = $array;
 		$this->assertSame($expected, $res);
 	}

+ 24 - 0
tests/test_app/Model/Enum/BitmaskEnum.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace TestApp\Model\Enum;
+
+use Cake\Database\Type\EnumLabelInterface;
+use Cake\Utility\Inflector;
+use Tools\Model\Enum\EnumOptionsTrait;
+
+enum BitmaskEnum: int implements EnumLabelInterface
+{
+	use EnumOptionsTrait;
+
+	case Zero = 0;
+	case One = 1;
+	case Two = 2;
+	case Four = 4;
+
+	/**
+	 * @return string
+	 */
+	public function label(): string {
+		return Inflector::humanize(Inflector::underscore($this->name));
+	}
+}

+ 4 - 4
tests/test_app/Model/Enum/FooBar.php

@@ -10,14 +10,14 @@ enum FooBar: int implements EnumLabelInterface
 {
 	use EnumOptionsTrait;
 
-	case ZERO = 0;
-	case ONE = 1;
-	case TWO = 2;
+	case Zero = 0;
+	case One = 1;
+	case Two = 2;
 
 	/**
 	 * @return string
 	 */
 	public function label(): string {
-		return Inflector::humanize(mb_strtolower($this->name));
+		return Inflector::humanize(Inflector::underscore($this->name));
 	}
 }