diff --git a/README.md b/README.md index 5d47247..e95b134 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ implemented the methods of `Getters`, `Extractor` and `Reporters`. - [FormRequest](docs/formrequests.md) - [Implicit (basic) enum binding](docs/binding.md) - [Validation](docs/laravel.validation.md) +- [Eloquent](docs/laravel.eloquent.md) ### Laravel's auto-discovery diff --git a/composer.json b/composer.json index ef5d127..b52651d 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,7 @@ "composer/composer": "2.8.9", "henzeb/enumhancer-ide-helper": "main-dev", "mockery/mockery": "^1.5", - "orchestra/testbench": "^8|^9|^10", + "orchestra/testbench": "^8.6.0|^9|^10", "pestphp/pest": "^2.0|^3.0", "phpstan/phpstan": "^2.0" }, diff --git a/docs/bitmasks.md b/docs/bitmasks.md index a022fcd..2972542 100644 --- a/docs/bitmasks.md +++ b/docs/bitmasks.md @@ -336,3 +336,9 @@ Returns the class name of the enum the Bitmask belongs to. Permission::mask()->forEnum(); // returns Permission::class PermissionInt::mask()->forEnum(); // returns PermissionInt::class ```` + +### Laravel Casting +For details on integrating bitmask enums with Eloquent models, see [Laravel Casting](casting.md#bitmask). + +### Laravel Eloquent +For details on using bitmask enums with Eloquent query scopes, see [Laravel Eloquent](laravel.eloquent.md#bitmask-query-scopes). diff --git a/docs/casting.md b/docs/casting.md index e423e5f..c2367d5 100644 --- a/docs/casting.md +++ b/docs/casting.md @@ -1,7 +1,7 @@ # Eloquent Attribute Casting Laravel supports casting backed enums out of the box, but what if you don't want -to use backed enums? This is where `CastsBasicEnumerations` comes in. +to use backed enums? This is where `CastsBasicEnumerations` and `AsBitmask` comes in. Note: for attribute casting with [State](state.md) see [here](#state). @@ -104,3 +104,96 @@ class YourModel extends Model } ``` + + +### Bitmask +When you need to store multiple enum values in a single database column, you can use [Bitmasks](bitmasks.md) together with the `AsBitmask` cast. This allows you to efficiently store and retrieve sets of enum values as a single integer. + +##### Enum: +First, define your enum and use the `Bitmasks` trait. Each case should have a unique power-of-two value. + +```php +namespace App\Enums; + +use Henzeb\Enumhancer\Concerns\Bitmasks; + +enum Preferences: string +{ + use Bitmasks; + + private const BIT_VALUES = true; + + case LogActivity = 1; + case PushNotification = 2; + case TwoFactorAuth = 4; + case DarkMode = 8; +} +``` + +##### Model: +In your Eloquent model, use the AsBitmask cast for the relevant attribute. This will handle conversion between the integer in the database and your enum values. + +```php +namespace App\Models; + +use Henzeb\Enumhancer\Laravel\Casts\AsBitmask; +use Illuminate\Database\Eloquent\Model; +use Henzeb\Enumhancer\Laravel\Concerns\CastsStatefulEnumerations; +use App\Enums\Preferences; + +class YourModel extends Model +{ + protected function casts(): array + { + return [ + 'preferences' => AsBitmask::class . ':' . Preferences::class, + ]; + } +} +``` + +#### Usage Examples + +##### Setting Values +You can assign enum values to the attribute in several ways: +```php +$model = new YourModel; + +// using the mask helper (stores 5: LogActivity + TwoFactorAuth) +$model->preferences = Preferences::mask( + Preferences::LogActivity, + Preferences::TwoFactorAuth, +); + +// using an array (also stores 5) +$model->preferences = [ + Preferences::LogActivity, + Preferences::TwoFactorAuth, +]; + +// single value (stores 1) +$model->preferences = Preferences::LogActivity; + +// using a comma-separated string (stores 11: DarkMode + LogActivity + PushNotification) +$model->preferences = 'DarkMode,LogActivity,PushNotification'; + +// no preferences (stores 0) +$model->preferences = []; +$model->preferences = ''; +$model->preferences = Preferences::mask(); +``` + +##### Retrieving Values +When you retrieve the model, the `preferences` attribute will be an instance of Bitmask: +```php +$model = YourModel::first(); +$model->preferences; // Bitmask instance with set values + +// check if a specific preference is set +$model->preferences->has(Preferences::LogActivity); // true or false + +// cet the raw integer value +$model->preferences->value(); // e.g. 5 for LogActivity and TwoFactorAuth +``` + +> Using bitmasks is a space-efficient way to store multiple enum values in a single column, and the AsBitmask cast makes working with them in Eloquent models seamless. diff --git a/docs/laravel.eloquent.md b/docs/laravel.eloquent.md new file mode 100644 index 0000000..7bdf903 --- /dev/null +++ b/docs/laravel.eloquent.md @@ -0,0 +1,62 @@ +# Laravel Eloquent + + + +## Bitmask Query Scopes +The `InteractsWithBitmask` trait adds **expressive, reusable query scopes** to your Eloquent models for working with **bitmask columns**. +It simplifies filtering records based on bitwise values without manually writing bitwise SQL conditions. + +### Features +- **whereBitmask** – Adds a `WHERE` condition to match records where the given bitmask **contains all bits** from the provided value. +- **orWhereBitmask** – Adds an `OR WHERE` condition for the same logic. +- Works with both **integer values** and **Bitmask enum instances**. + + +### Configuration +Apply the `InteractsWithBitmask` trait to your model, and set up the cast for your bitmask column. +```php +use Henzeb\Enumhancer\Laravel\Casts\AsBitmask; +use Henzeb\Enumhancer\Laravel\Traits\InteractsWithBitmask; +use Henzeb\Enumhancer\Tests\Fixtures\BackedEnums\Bitmasks\BitmaskPreferenceEnum; +use Illuminate\Database\Eloquent\Model; + + +class MyModel extends Model +{ + use InteractsWithBitmask; + + + protected $casts = [ + 'preferences' => AsBitmask::class . ':' . BitmaskPreferenceEnum::class, + ]; +} +``` + +### Usage Examples + +Using an Integer Value +```php +# match where 'preferences' has all bits in 5 set +MyModel::whereBitmask('preferences', 5)->get(); + +# same but using or condition +MyModel::orWhereBitmask('preferences', 5)->get(); +``` + +Using a Bitmask Enum Instance +```php +$value = BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::AutoUpdates, + BitmaskPreferenceEnum::DarkMode, +); + +# match records where both flags are set +MyModel::whereBitmask('preferences', $value)->get(); + +# or condition +MyModel::orWhereBitmask('preferences', $value)->get(); +``` + + +> [!NOTE] +> If the value is `0`, the query matches **only** records where the column is exactly `zero`, ensuring no bits are set. diff --git a/src/Laravel/Casts/AsBitmask.php b/src/Laravel/Casts/AsBitmask.php new file mode 100644 index 0000000..53a23b2 --- /dev/null +++ b/src/Laravel/Casts/AsBitmask.php @@ -0,0 +1,86 @@ + + */ + protected string $enum; + + + public function __construct(string $enum) + { + if (!enum_exists($enum)) { + throw new InvalidArgumentException("Enum class [$enum] does not exist."); + } + + $this->enum = $enum; + } + + + public function get($model, string $key, mixed $value, array $attributes): Bitmask + { + if ($value instanceof Bitmask) { + return $value; + } + + return $this->enum::fromMask((int)$value); + } + + public function set($model, string $key, mixed $value, array $attributes): int + { + if (is_array($value)) { + return $this->enum::mask(...$value)->value(); + } + + if (is_string($value)) { + $cases = explode(',', $value); + $cases = array_filter( + array_map('trim', $cases), + fn($case) => !empty($case) + ); + + return $this->enum::mask(...$cases)->value(); + } + + if ($value instanceof BackedEnum) { + return $this->enum::mask($value->name)->value(); + } + + if ($value instanceof Bitmask) { + return $value->value(); + } + + + throw new InvalidArgumentException('The value must be an array of enum cases, string, or single enum case.'); + } + + public function serialize($model, string $key, $value, array $attributes): string + { + if ($value instanceof Bitmask) { + $cases = $value->cases(); + $enabled = []; + + foreach ($cases as $case) { + $enabled[] = $case->name; + } + + + return implode(',', $enabled); + } + + + return (string)$value; + } +} diff --git a/src/Laravel/Traits/InteractsWithBitmask.php b/src/Laravel/Traits/InteractsWithBitmask.php new file mode 100644 index 0000000..fdc28b5 --- /dev/null +++ b/src/Laravel/Traits/InteractsWithBitmask.php @@ -0,0 +1,45 @@ +value(); + } + + if ($value === 0) { + $query->where($column, 0); + + return; + } + + $query->whereRaw("`$column` & ? = ?", [ + $value, $value + ]); + } + + public function scopeOrWhereBitmask(Builder $query, string $column, Bitmask|int $value): void + { + if ($value instanceof Bitmask) { + $value = $value->value(); + } + + if ($value === 0) { + $query->orWhere($column, 0); + + return; + } + + + $query->orWhereRaw("`$column` & ? = ?", [ + $value, $value + ]); + } +} diff --git a/tests/Fixtures/BackedEnums/Bitmasks/BitmaskPreferenceEnum.php b/tests/Fixtures/BackedEnums/Bitmasks/BitmaskPreferenceEnum.php new file mode 100644 index 0000000..a2afa03 --- /dev/null +++ b/tests/Fixtures/BackedEnums/Bitmasks/BitmaskPreferenceEnum.php @@ -0,0 +1,28 @@ +value(); + } +} diff --git a/tests/Fixtures/Models/CastsBitmaskEnumsModel.php b/tests/Fixtures/Models/CastsBitmaskEnumsModel.php new file mode 100644 index 0000000..226d4fa --- /dev/null +++ b/tests/Fixtures/Models/CastsBitmaskEnumsModel.php @@ -0,0 +1,23 @@ + AsBitmask::class . ':' . BitmaskPreferenceEnum::class, + ]; +} diff --git a/tests/Pest.php b/tests/Pest.php index f726141..0698118 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -11,10 +11,13 @@ | */ -use PHPUnit\Framework\TestCase; +use Henzeb\Enumhancer\Tests\TestCase; +use Illuminate\Foundation\Testing\Concerns\InteractsWithViews; +use Illuminate\Foundation\Testing\RefreshDatabase; ini_set('memory_limit', '512M'); -uses(TestCase::class)->in('Unit'); + +uses(TestCase::class, InteractsWithViews::class, RefreshDatabase::class)->in('Unit'); /* |-------------------------------------------------------------------------- diff --git a/tests/TestCase.php b/tests/TestCase.php index 2d317c3..14ab956 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,8 +8,10 @@ use Henzeb\Enumhancer\Tests\Fixtures\EnhancedIntBackedEnum; use Henzeb\Enumhancer\Tests\Fixtures\SimpleEnum; use Henzeb\Enumhancer\Tests\Fixtures\UnitEnums\Defaults\DefaultsEnum; +use Orchestra\Testbench\TestCase as OrchestraTestCase; -class TestCase extends \Orchestra\Testbench\TestCase + +class TestCase extends OrchestraTestCase { protected function setUp(): void { @@ -17,12 +19,24 @@ protected function setUp(): void parent::setUp(); } - protected function getPackageProviders($app) + protected function getPackageProviders($app): array + { + return [ + EnumhancerServiceProvider::class, + ]; + } + + protected function defineEnvironment($app): void { - return [EnumhancerServiceProvider::class]; + config()->set('database.default', 'sqlite'); + config()->set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); } - protected function defineRoutes($router) + protected function defineRoutes($router): void { // For SubstituteEnumsTest $router->middleware('api')->get('/simpleapi/{status}', @@ -32,7 +46,7 @@ function (SimpleEnum $status) { ); } - protected function defineWebRoutes($router) + protected function defineWebRoutes($router): void { // For SubstituteEnumsTest $router->get('/noparams', @@ -69,4 +83,4 @@ function (DefaultsEnum $status) { } ); } -} \ No newline at end of file +} diff --git a/tests/Unit/Helpers/EnumBladeTest.php b/tests/Unit/Helpers/EnumBladeTest.php index f47bedc..65d6034 100644 --- a/tests/Unit/Helpers/EnumBladeTest.php +++ b/tests/Unit/Helpers/EnumBladeTest.php @@ -5,13 +5,10 @@ use Henzeb\Enumhancer\Tests\Fixtures\EnhancedBackedEnum; use Henzeb\Enumhancer\Tests\Fixtures\EnhancedUnitEnum; use Henzeb\Enumhancer\Tests\Fixtures\IntBackedEnum; -use Illuminate\Foundation\Testing\Concerns\InteractsWithViews; -use Orchestra\Testbench\TestCase; use function Henzeb\Enumhancer\Functions\backing; use function Henzeb\Enumhancer\Functions\name; use function Henzeb\Enumhancer\Functions\value; -uses(TestCase::class, InteractsWithViews::class); test('should render value', function ($enum, $keepValueCase = true) { $method = $keepValueCase ? 'register' : 'registerLowercase'; diff --git a/tests/Unit/Helpers/EnumReporterTest.php b/tests/Unit/Helpers/EnumReporterTest.php index 4757434..dd77d09 100644 --- a/tests/Unit/Helpers/EnumReporterTest.php +++ b/tests/Unit/Helpers/EnumReporterTest.php @@ -8,11 +8,8 @@ use Henzeb\Enumhancer\Tests\Fixtures\EnhancedBackedEnum; use Henzeb\Enumhancer\Tests\Fixtures\EnhancedUnitEnum; use Illuminate\Support\Facades\Log; -use Orchestra\Testbench\TestCase; use Psr\Log\LoggerInterface; -uses(TestCase::class); - beforeEach(function () { $this->app->getProviders(EnumhancerServiceProvider::class); }); @@ -128,4 +125,4 @@ public function report(string $enum, ?string $key, ?\BackedEnum $context): void test('make or report array should error with non enum', function () { EnumReporter::getOrReportArray(stdClass::class, [], null, new LaravelLogReporter()); -})->throws(TypeError::class); \ No newline at end of file +})->throws(TypeError::class); diff --git a/tests/Unit/Laravel/Casts/AsBitmaskTest.php b/tests/Unit/Laravel/Casts/AsBitmaskTest.php new file mode 100644 index 0000000..f19372c --- /dev/null +++ b/tests/Unit/Laravel/Casts/AsBitmaskTest.php @@ -0,0 +1,506 @@ +in('Unit'); + +beforeEach(function () { + $this->attr = 'preferences'; + + $this->app['db']->connection() + ->getSchemaBuilder() + ->create('casts_bitmask_enums', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger($this->attr) + ->default(BitmaskPreferenceEnum::allOptionsEnabled()) + ->comment('bitmask preferences'); + + $table->timestamps(); + }); +}); + + +it('throws exception for invalid enums', function () { + new AsBitmask('InvalidEnum'); + +})->throws(InvalidArgumentException::class, 'Enum class [InvalidEnum] does not exist.'); + + +# set +test('`set` method returns correct mask for enum input', function () { + $model = new CastsBitmaskEnumsModel; + $enum = BitmaskPreferenceEnum::class; + $cast = new AsBitmask($enum); + + expect($cast->set($model, $this->attr, BitmaskPreferenceEnum::AutoUpdates, [])) + ->toBe(16) + ->and($cast->set($model, $this->attr, BitmaskPreferenceEnum::PushNotification, [])) + ->toBe(2) + ->and($cast->set($model, $this->attr, BitmaskPreferenceEnum::DarkMode, [])) + ->toBe(8) + ->and($cast->set($model, $this->attr, BitmaskPreferenceEnum::LogActivity, [])) + ->toBe(1); +}); + +test('`set` method throws exception for invalid enum value type', function () { + $model = new CastsBitmaskEnumsModel; + $enum = BitmaskPreferenceEnum::class; + $cast = new AsBitmask($enum); + + $cast->set($model, $this->attr, BitmasksIncorrectIntEnum::Read, []); + +})->throws(TypeError::class, 'This method can only be used with an enum'); + +test('`set` method returns correct mask for bitmask input', function () { + $model = new CastsBitmaskEnumsModel; + $enum = BitmaskPreferenceEnum::class; + $cast = new AsBitmask($enum); + + # test 1 + $value = BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::AutoUpdates + ); + + $result = $cast->set($model, $this->attr, $value, []); + expect($result)->toBe(16); + + + # test 2 + $value = BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::AutoUpdates, + BitmaskPreferenceEnum::PushNotification + ); + + $result = $cast->set($model, $this->attr, $value, []); + expect($result)->toBe(18); + + + # test 3 + $value = BitmaskPreferenceEnum::mask(); + $result = $cast->set($model, $this->attr, $value, []); + expect($result)->toBe(0); +}); + +test('`set` method throws exception for invalid bitmask value type', function () { + $model = new CastsBitmaskEnumsModel; + $enum = BitmaskPreferenceEnum::class; + $cast = new AsBitmask($enum); + + $value = BitmasksIncorrectIntEnum::mask( + BitmasksIncorrectIntEnum::Read, + BitmasksIncorrectIntEnum::Execute, + ); + + $cast->set($model, $this->attr, $value, []); + +})->throws(TypeError::class, 'Henzeb\Enumhancer\Tests\Fixtures\BackedEnums\Bitmasks\BitmasksIncorrectIntEnum::Execute is not a valid bit value'); + +test('`set` method returns correct mask for string input', function () { + $model = new CastsBitmaskEnumsModel; + $enum = BitmaskPreferenceEnum::class; + $cast = new AsBitmask($enum); + + + # test 1 + $result = $cast->set($model, $this->attr, 'AutoUpdates', []); + expect($result)->toBe(16); + + + # test 2 + $result = $cast->set($model, $this->attr, 'PushNotification', []); + expect($result)->toBe(2); + + + # test 3 + $result = $cast->set($model, $this->attr, 'DarkMode', []); + expect($result)->toBe(8); + + + # test 4 + $result = $cast->set($model, $this->attr, 'LogActivity', []); + expect($result)->toBe(1); + + + # test 5 + $result = $cast->set($model, $this->attr, 'LogActivity,DarkMode', []); + expect($result)->toBe(9); + + + # test 6 + $result = $cast->set($model, $this->attr, 'LogActivity, PushNotification ,DarkMode', []); + expect($result)->toBe(11); + + + # test 7 + $result = $cast->set($model, $this->attr, '', []); + expect($result)->toBe(0); +}); + +test('`set` method throws exception for invalid string value type', function () { + $model = new CastsBitmaskEnumsModel; + $enum = BitmaskPreferenceEnum::class; + $cast = new AsBitmask($enum); + + $cast->set($model, $this->attr, 'RANDOM_CASE', []); + +})->throws(TypeError::class, 'This method can only be used with an enum'); + +test('`set` method returns correct mask for array input', function () { + $model = new CastsBitmaskEnumsModel; + $enum = BitmaskPreferenceEnum::class; + $cast = new AsBitmask($enum); + + + # test 1 + $result = $cast->set($model, $this->attr, [BitmaskPreferenceEnum::DarkMode, BitmaskPreferenceEnum::LogActivity], []); + expect($result)->toBe(9); + + + # test 2 + $result = $cast->set($model, $this->attr, [BitmaskPreferenceEnum::TwoFactorAuth], []); + expect($result)->toBe(4); + + + # test 3 + $result = $cast->set($model, $this->attr, [BitmaskPreferenceEnum::TwoFactorAuth, BitmaskPreferenceEnum::DataExport, BitmaskPreferenceEnum::PushNotification], []); + expect($result)->toBe(38); + + + # test 4 + $result = $cast->set($model, $this->attr, [], []); + expect($result)->toBe(0); + + + # test 5 + $result = $cast->set($model, $this->attr, [BitmaskPreferenceEnum::TwoFactorAuth, 'AutoUpdates', BitmaskPreferenceEnum::PushNotification], []); + expect($result)->toBe(22); + + + # test 6 + $result = $cast->set($model, $this->attr, ['DataExport', 'AutoUpdates'], []); + expect($result)->toBe(48); +}); + +test('`set` method throws exception for invalid array value type', function () { + $model = new CastsBitmaskEnumsModel; + $enum = BitmaskPreferenceEnum::class; + $cast = new AsBitmask($enum); + + $cast->set($model, $this->attr, [BitmaskPreferenceEnum::DarkMode, BitmasksIncorrectIntEnum::Read], []); + +})->throws(TypeError::class); + +test('`set` method throws exception for invalid value types', function () { + $model = new CastsBitmaskEnumsModel; + $enum = BitmaskPreferenceEnum::class; + $cast = new AsBitmask($enum); + + $cast->set($model, $this->attr, 3, []); + +})->throws(InvalidArgumentException::class); + + +# get +test('`get` method returns bitmask for valid value', function () { + $model = new CastsBitmaskEnumsModel; + $enum = BitmaskPreferenceEnum::class; + $cast = new AsBitmask($enum); + + + # test 1 + $result = $cast->get($model, $this->attr, 16, []); + expect($result->value()) + ->toBe(16) + ->and($result->has(BitmaskPreferenceEnum::AutoUpdates)) + ->toBeTrue(); + + + # test 2 + $result = $cast->get($model, $this->attr, 17, []); + expect($result->value()) + ->toBe(17) + ->and($result->has(BitmaskPreferenceEnum::LogActivity)) + ->toBeTrue() + ->and($result->has(BitmaskPreferenceEnum::AutoUpdates)) + ->toBeTrue(); + + # test 3 + $result = $cast->get($model, $this->attr, '3', []); + expect($result->value()) + ->toBe(3) + ->and($result->has(BitmaskPreferenceEnum::LogActivity)) + ->toBeTrue() + ->and($result->has(BitmaskPreferenceEnum::PushNotification)) + ->toBeTrue(); + + + # test 4 + $value = BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::LogActivity, + BitmaskPreferenceEnum::DarkMode, + ); + + $result = $cast->get($model, $this->attr, $value, []); + expect($result->value()) + ->toBe(9) + ->and($result) + ->toBe($value) + ->and($result->has(BitmaskPreferenceEnum::LogActivity)) + ->toBeTrue() + ->and($result->has(BitmaskPreferenceEnum::DarkMode)) + ->toBeTrue(); +}); + + +# model +test('returns all items enabled', function () { + DB::table('casts_bitmask_enums')->insert([ + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $record = CastsBitmaskEnumsModel::query()->first(); + + expect($record->preferences) + ->toBeInstanceOf(Bitmask::class) + ->and($record->preferences->value()) + ->toBe(63) + ->and($record->preferences->has(BitmaskPreferenceEnum::LogActivity)) + ->toBeTrue() + ->and($record->preferences->has(BitmaskPreferenceEnum::PushNotification)) + ->toBeTrue() + ->and($record->preferences->has(BitmaskPreferenceEnum::PushNotification)) + ->toBeTrue() + ->and($record->preferences->has(BitmaskPreferenceEnum::DarkMode)) + ->toBeTrue() + ->and($record->preferences->has(BitmaskPreferenceEnum::AutoUpdates)) + ->toBeTrue() + ->and($record->preferences->has(BitmaskPreferenceEnum::DataExport)) + ->toBeTrue(); +}); + +test('stores correct mask to database', function (mixed $preferences, int $value, string $serialized, array $has) { + $model = new CastsBitmaskEnumsModel; + $model->preferences = $preferences; + $model->save(); + + + # test 1 + expect($model->preferences->value())->toBe($value); + + + foreach ($has as $item) { + expect($model->preferences->has($item))->toBeTrue(); + } + + $array = $model->toArray(); + expect($array['preferences'])->toBe($serialized); + + + # test 2 + $model = CastsBitmaskEnumsModel::find($model->id); + + expect($model->preferences->value())->toBe($value); + + foreach ($has as $item) { + expect($model->preferences->has($item))->toBeTrue(); + } + + $array = $model->toArray(); + expect($array['preferences'])->toBe($serialized); + +})->with([ + [ + 'preferences' => BitmaskPreferenceEnum::AutoUpdates, + 'value' => 16, + 'serialized' => 'AutoUpdates', + 'has' => [ + BitmaskPreferenceEnum::AutoUpdates + ] + ], + [ + 'preferences' => 'DarkMode', + 'value' => 8, + 'serialized' => 'DarkMode', + 'has' => [ + BitmaskPreferenceEnum::DarkMode + ] + ], + [ + 'preferences' => 'DataExport,TwoFactorAuth,PushNotification', + 'value' => 38, + 'serialized' => 'PushNotification,TwoFactorAuth,DataExport', + 'has' => [ + BitmaskPreferenceEnum::DataExport, + BitmaskPreferenceEnum::TwoFactorAuth, + BitmaskPreferenceEnum::PushNotification, + ] + ], + [ + 'preferences' => ' DataExport, TwoFactorAuth ,DarkMode ', + 'value' => 44, + 'serialized' => 'TwoFactorAuth,DarkMode,DataExport', + 'has' => [ + BitmaskPreferenceEnum::DataExport, + BitmaskPreferenceEnum::TwoFactorAuth, + BitmaskPreferenceEnum::DarkMode, + ] + ], + [ + 'preferences' => '', + 'value' => 0, + 'serialized' => '', + 'has' => [] + ], + [ + 'preferences' => [ + BitmaskPreferenceEnum::DarkMode, + BitmaskPreferenceEnum::LogActivity, + ], + 'value' => 9, + 'serialized' => 'LogActivity,DarkMode', + 'has' => [ + BitmaskPreferenceEnum::DarkMode, + BitmaskPreferenceEnum::LogActivity, + ] + ], + [ + 'preferences' => BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::TwoFactorAuth, + BitmaskPreferenceEnum::LogActivity, + ), + 'value' => 5, + 'serialized' => 'LogActivity,TwoFactorAuth', + 'has' => [ + BitmaskPreferenceEnum::TwoFactorAuth, + BitmaskPreferenceEnum::LogActivity, + ] + ], + [ + 'preferences' => BitmaskPreferenceEnum::mask(), + 'value' => 0, + 'serialized' => '', + 'has' => [] + ], + [ + 'preferences' => [], + 'value' => 0, + 'serialized' => '', + 'has' => [] + ], +]); + +test('bitmask operations', function () { + $model = new CastsBitmaskEnumsModel; + $model->preferences = [BitmaskPreferenceEnum::DarkMode, BitmaskPreferenceEnum::AutoUpdates]; + $model->save(); + + + # test 1 + expect($model->preferences->has(BitmaskPreferenceEnum::DarkMode)) + ->toBeTrue() + ->and($model->preferences->has(BitmaskPreferenceEnum::AutoUpdates)) + ->toBeTrue(); + + + # test 2 + $model->preferences = $model->preferences->set(BitmaskPreferenceEnum::LogActivity); + $model->preferences = $model->preferences->unset(BitmaskPreferenceEnum::AutoUpdates); + $model->save(); + + expect($model->preferences->has(BitmaskPreferenceEnum::LogActivity)) + ->toBeTrue() + ->and($model->preferences->has(BitmaskPreferenceEnum::DarkMode)) + ->toBeTrue() + ->and($model->preferences->has(BitmaskPreferenceEnum::AutoUpdates)) + ->toBeFalse(); + + + + # test 3 + $model->preferences = $model->preferences->toggle(BitmaskPreferenceEnum::AutoUpdates); + $model->preferences = $model->preferences->toggle(BitmaskPreferenceEnum::LogActivity); + $model->save(); + + expect($model->preferences->has(BitmaskPreferenceEnum::LogActivity)) + ->toBeFalse() + ->and($model->preferences->has(BitmaskPreferenceEnum::DarkMode)) + ->toBeTrue() + ->and($model->preferences->has(BitmaskPreferenceEnum::AutoUpdates)) + ->toBeTrue(); + + + + # test 4 + $model = CastsBitmaskEnumsModel::find($model->id); + + expect($model->preferences->has(BitmaskPreferenceEnum::DarkMode)) + ->toBeTrue() + ->and($model->preferences->has(BitmaskPreferenceEnum::AutoUpdates)) + ->toBeTrue() + ->and($model->preferences->has(BitmaskPreferenceEnum::LogActivity)) + ->toBeFalse() + ->and($model->preferences->value()) + ->toBe(24); +}); + + + +# serialize +test('`serialize` method returns empty string for non bitmask values', function () { + $model = new CastsBitmaskEnumsModel; + $enum = BitmaskPreferenceEnum::class; + $cast = new AsBitmask($enum); + + $result = $cast->serialize($model, $this->attr, '', []); + expect($result)->toBe(''); +}); + +test('`serialize` method returns correct string for bitmask values', function () { + $model = new CastsBitmaskEnumsModel; + $enum = BitmaskPreferenceEnum::class; + $cast = new AsBitmask($enum); + + + + # test 1 + $value = BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::LogActivity, + BitmaskPreferenceEnum::DarkMode, + ); + + $result = $cast->serialize($model, $this->attr, $value, []); + expect($result)->toBe('LogActivity,DarkMode'); + + + # test 2 + $value = BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::AutoUpdates, + ); + + $result = $cast->serialize($model, $this->attr, $value, []); + expect($result)->toBe('AutoUpdates'); + + + # test 3 + $value = BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::LogActivity, + BitmaskPreferenceEnum::DataExport, + BitmaskPreferenceEnum::DarkMode, + ); + + $result = $cast->serialize($model, $this->attr, $value, []); + expect($result)->toBe('LogActivity,DarkMode,DataExport'); +}); diff --git a/tests/Unit/Laravel/Concerns/CastsBasicEnumerationsTest.php b/tests/Unit/Laravel/Concerns/CastsBasicEnumerationsTest.php index b965a92..b4a9a27 100644 --- a/tests/Unit/Laravel/Concerns/CastsBasicEnumerationsTest.php +++ b/tests/Unit/Laravel/Concerns/CastsBasicEnumerationsTest.php @@ -7,8 +7,7 @@ use Henzeb\Enumhancer\Tests\Fixtures\Models\CastsBasicEnumsNoPropertyModel; use Henzeb\Enumhancer\Tests\Fixtures\StringBackedGetEnum; use Henzeb\Enumhancer\Tests\Fixtures\SubsetUnitEnum; -use Henzeb\Enumhancer\Tests\TestCase; -uses(TestCase::class); + test('should cast correctly from string', function (\UnitEnum $enum, string $key, bool $keepCase = true) { $model = $keepCase ? new CastsBasicEnumsModel() : new CastsBasicEnumsLowerCaseModel(); @@ -100,12 +99,12 @@ test('should return non-enum value in getStorableEnumValue', function () { $model = new CastsBasicEnumsModel(); - + // Use reflection to test the protected method $reflection = new ReflectionClass($model); $method = $reflection->getMethod('getStorableEnumValue'); $method->setAccessible(true); - + // Test with a non-UnitEnum value - this should hit line 79 $result = $method->invoke($model, 'some_string', 'some_string'); expect($result)->toBe('some_string'); diff --git a/tests/Unit/Laravel/Middleware/SubstituteEnumsTest.php b/tests/Unit/Laravel/Middleware/SubstituteEnumsTest.php index 4de65ea..6f6e176 100644 --- a/tests/Unit/Laravel/Middleware/SubstituteEnumsTest.php +++ b/tests/Unit/Laravel/Middleware/SubstituteEnumsTest.php @@ -3,7 +3,6 @@ use Henzeb\Enumhancer\Tests\TestCase; use Illuminate\Support\Facades\Config; -uses(TestCase::class); beforeEach(function () { Config::set('app.key', 'base64:+vvg9yApP0djYSZlVTA0y4QnzdC7icL1U5qExdW4gts='); diff --git a/tests/Unit/Laravel/Mixins/FormRequestMixinTest.php b/tests/Unit/Laravel/Mixins/FormRequestMixinTest.php index 7bafac6..9574c3c 100644 --- a/tests/Unit/Laravel/Mixins/FormRequestMixinTest.php +++ b/tests/Unit/Laravel/Mixins/FormRequestMixinTest.php @@ -3,10 +3,8 @@ use Henzeb\Enumhancer\Contracts\Mapper; use Henzeb\Enumhancer\Tests\Fixtures\SimpleEnum; use Henzeb\Enumhancer\Tests\Fixtures\UnitEnums\Defaults\DefaultsEnum; -use Henzeb\Enumhancer\Tests\TestCase; use Illuminate\Foundation\Http\FormRequest; -uses(TestCase::class); test('as enum', function () { $request = new FormRequest( diff --git a/tests/Unit/Laravel/Provider/EnumhancerServiceProviderTest.php b/tests/Unit/Laravel/Provider/EnumhancerServiceProviderTest.php index 10b8e0c..7aec391 100644 --- a/tests/Unit/Laravel/Provider/EnumhancerServiceProviderTest.php +++ b/tests/Unit/Laravel/Provider/EnumhancerServiceProviderTest.php @@ -3,9 +3,7 @@ use Henzeb\Enumhancer\Enums\LogLevel; use Henzeb\Enumhancer\Helpers\EnumReporter; use Henzeb\Enumhancer\Laravel\Reporters\LaravelLogReporter; -use Henzeb\Enumhancer\Tests\TestCase; -uses(TestCase::class); test('has set laravel reporter', function () { $reporter = EnumReporter::get(); diff --git a/tests/Unit/Laravel/Reporters/LaravelLogReporterTest.php b/tests/Unit/Laravel/Reporters/LaravelLogReporterTest.php index 35516f6..254ac07 100644 --- a/tests/Unit/Laravel/Reporters/LaravelLogReporterTest.php +++ b/tests/Unit/Laravel/Reporters/LaravelLogReporterTest.php @@ -3,12 +3,10 @@ use Henzeb\Enumhancer\Enums\LogLevel; use Henzeb\Enumhancer\Laravel\Reporters\LaravelLogReporter; use Henzeb\Enumhancer\Tests\Fixtures\EnhancedBackedEnum; -use Henzeb\Enumhancer\Tests\TestCase; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; use Psr\Log\LoggerInterface; -uses(TestCase::class); beforeEach(function () { Config::set('logging.default', 'stack'); diff --git a/tests/Unit/Laravel/Traits/InteractsWithBitmaskTest.php b/tests/Unit/Laravel/Traits/InteractsWithBitmaskTest.php new file mode 100644 index 0000000..2fe1eb5 --- /dev/null +++ b/tests/Unit/Laravel/Traits/InteractsWithBitmaskTest.php @@ -0,0 +1,401 @@ +attr = 'preferences'; + + $this->app['db']->connection() + ->getSchemaBuilder() + ->create('casts_bitmask_enums', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger($this->attr) + ->default(BitmaskPreferenceEnum::allOptionsEnabled()) + ->comment('bitmask preferences'); + + $table->timestamps(); + }); + + + $this->record1 = CastsBitmaskEnumsModel::query()->create([ + $this->attr => [ + BitmaskPreferenceEnum::AutoUpdates, + BitmaskPreferenceEnum::DarkMode, + ] + ]); + + $this->record2 = CastsBitmaskEnumsModel::query()->create([ + $this->attr => BitmaskPreferenceEnum::DataExport + ]); + + $this->record3 = CastsBitmaskEnumsModel::query()->create([ + $this->attr => 'TwoFactorAuth,DarkMode,PushNotification' + ]); + + $this->record4 = CastsBitmaskEnumsModel::query()->create([ + $this->attr => [] + ]); +}); + + +# where +test('whereBitmask applies correct condition for integer value', function () { + # test 1 + $results = CastsBitmaskEnumsModel::whereBitmask('preferences', 8)->get(); + $this->assertEquals([$this->record1->id, $this->record3->id], $results->pluck('id')->toArray()); + expect($results) + ->toHaveCount(2) + ->and($results->pluck('id')->toArray()) + ->toBe([ + $this->record1->id, $this->record3->id + ]); + + + # test 2 + $results = CastsBitmaskEnumsModel::whereBitmask('preferences', 32)->get(); + $this->assertCount(1, $results); + $this->assertEquals($this->record2->id, $results->first()->id); + expect($results) + ->toHaveCount(1) + ->and($results->first()->id) + ->toBe($this->record2->id); + + + # test 3 + $results = CastsBitmaskEnumsModel::whereBitmask('preferences', 24)->get(); + $this->assertCount(1, $results); + $this->assertEquals($this->record1->id, $results->first()->id); + expect($results) + ->toHaveCount(1) + ->and($results->first()->id) + ->toBe($this->record1->id); + + + # test 4 + $results = CastsBitmaskEnumsModel::whereBitmask('preferences', 1)->get(); + expect($results)->toHaveCount(0); + + + # test 5 + $results = CastsBitmaskEnumsModel::whereBitmask('preferences', 0)->get(); + $this->assertCount(1, $results); + $this->assertEquals($this->record4->id, $results->first()->id); + expect($results) + ->toHaveCount(1) + ->and($results->first()->id) + ->toBe($this->record4->id); +}); + +test('whereBitmask applies correct condition for bitmask value', function () { + # test 1 + $value = BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::DarkMode + ); + + $results = CastsBitmaskEnumsModel::whereBitmask('preferences', $value)->get(); + $this->assertCount(2, $results); + $this->assertEquals([$this->record1->id, $this->record3->id], $results->pluck('id')->toArray()); + + expect($results) + ->toHaveCount(2) + ->and($results->pluck('id')->toArray()) + ->toBe([ + $this->record1->id, $this->record3->id + ]); + + + # test 2 + $value = BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::PushNotification + ); + + $results = CastsBitmaskEnumsModel::whereBitmask('preferences', $value)->get(); + $this->assertCount(1, $results); + $this->assertEquals($this->record3->id, $results->first()->id); + expect($results) + ->toHaveCount(1) + ->and($results->first()->id) + ->toBe($this->record3->id); + + + # test 3 + $value = BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::PushNotification, + BitmaskPreferenceEnum::DarkMode, + BitmaskPreferenceEnum::TwoFactorAuth, + ); + + $results = CastsBitmaskEnumsModel::whereBitmask('preferences', $value)->get(); + $this->assertCount(1, $results); + $this->assertEquals($this->record3->id, $results->first()->id); + expect($results) + ->toHaveCount(1) + ->and($results->first()->id) + ->toBe($this->record3->id); + + # test 4 + $value = BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::PushNotification, + BitmaskPreferenceEnum::DarkMode, + BitmaskPreferenceEnum::AutoUpdates, + ); + + $results = CastsBitmaskEnumsModel::whereBitmask('preferences', $value)->get(); + expect($results)->toHaveCount(0); + + + # test 5 + $value = BitmaskPreferenceEnum::mask(); + $results = CastsBitmaskEnumsModel::whereBitmask('preferences', $value)->get(); + expect($results) + ->toHaveCount(1) + ->and($results->first()->id) + ->toBe($this->record4->id); +}); + + +# or-where +test('orWhereBitmask applies correct condition for integer value', function () { + # test 1 + $results = CastsBitmaskEnumsModel::query() + ->whereBitmask('preferences', 1) + ->orWhereBitmask('preferences', 8) + ->get(); + + expect($results) + ->toHaveCount(2) + ->and($results->pluck('id')->toArray()) + ->toBe([ + $this->record1->id, $this->record3->id + ]); + + + # test 2 + $results = CastsBitmaskEnumsModel::query() + ->whereBitmask('preferences', 3) + ->orWhereBitmask('preferences', 32) + ->get(); + + expect($results) + ->toHaveCount(1) + ->and($results->first()->id) + ->toBe($this->record2->id); + + + # test 3 + $results = CastsBitmaskEnumsModel::query() + ->whereBitmask('preferences', 1) + ->orWhereBitmask('preferences', 24) + ->orWhereBitmask('preferences', 32) + ->get(); + + $this->assertCount(2, $results); + $this->assertEquals([$this->record1->id, $this->record2->id], $results->pluck('id')->toArray()); + expect($results) + ->toHaveCount(2) + ->and($results->pluck('id')->toArray()) + ->toBe([ + $this->record1->id, $this->record2->id + ]); + + + # test 4 + $results = CastsBitmaskEnumsModel::query() + ->whereBitmask('preferences', 1) + ->orWhereBitmask('preferences', 3) + ->get(); + + expect($results)->toHaveCount(0); + + + # test 5 + $results = CastsBitmaskEnumsModel::query() + ->whereBitmask('preferences', 1) + ->orWhereBitmask('preferences', 0) + ->get(); + + $this->assertCount(1, $results); + $this->assertEquals($this->record4->id, $results->first()->id); + expect($results) + ->toHaveCount(1) + ->and($results->first()->id) + ->toBe($this->record4->id); + + + # test 6 + $results = CastsBitmaskEnumsModel::query() + ->whereBitmask('preferences', 1) + ->orWhereBitmask('preferences', 24) + ->orWhereBitmask('preferences', 32) + ->orWhereBitmask('preferences', 8) + ->get(); + + expect($results) + ->toHaveCount(3) + ->and($results->pluck('id')->toArray()) + ->toBe([ + $this->record1->id, $this->record2->id, $this->record3->id + ]); + +}); + +test('orWhereBitmask applies correct condition for bitmask value', function () { + # test 1 + $results = CastsBitmaskEnumsModel::query() + ->whereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::LogActivity + ) + ) + ->orWhereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::DarkMode + ) + ) + ->get(); + + expect($results) + ->toHaveCount(2) + ->and($results->pluck('id')->toArray()) + ->toBe([ + $this->record1->id, $this->record3->id + ]); + + + # test 2 + $results = CastsBitmaskEnumsModel::query() + ->whereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::LogActivity, + BitmaskPreferenceEnum::PushNotification, + ) + ) + ->orWhereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::DataExport + ) + ) + ->get(); + + expect($results) + ->toHaveCount(1) + ->and($results->first()->id) + ->toBe($this->record2->id); + + + # test 3 + $results = CastsBitmaskEnumsModel::query() + ->whereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::LogActivity, + ) + ) + ->orWhereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::AutoUpdates, + BitmaskPreferenceEnum::DarkMode, + ) + ) + ->orWhereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::DataExport + ) + ) + ->get(); + + expect($results) + ->toHaveCount(2) + ->and($results->pluck('id')->toArray()) + ->toBe([ + $this->record1->id, $this->record2->id + ]); + + + # test 4 + $results = CastsBitmaskEnumsModel::query() + ->whereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::LogActivity, + ) + ) + ->orWhereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::LogActivity, + BitmaskPreferenceEnum::PushNotification, + ) + ) + ->get(); + + expect($results)->toHaveCount(0); + + + # test 5 + $results = CastsBitmaskEnumsModel::query() + ->whereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::LogActivity, + ) + ) + ->orWhereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask() + ) + ->get(); + + expect($results) + ->toHaveCount(1) + ->and($results->first()->id) + ->toBe($this->record4->id); + + + # test 6 + $results = CastsBitmaskEnumsModel::query() + ->whereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::LogActivity, + ) + ) + ->orWhereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::DarkMode, + BitmaskPreferenceEnum::AutoUpdates, + ) + ) + ->orWhereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::DataExport + ) + ) + ->orWhereBitmask( + column: 'preferences', + value: BitmaskPreferenceEnum::mask( + BitmaskPreferenceEnum::DarkMode + ) + ) + ->get(); + + expect($results) + ->toHaveCount(3) + ->and($results->pluck('id')->toArray()) + ->toBe([ + $this->record1->id, $this->record2->id, $this->record3->id + ]); +});