diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8dc27de --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + php: [8.2, 8.3, 8.4] + laravel: [11.*, 12.*] + exclude: + - php: 8.2 + laravel: 12.* + + name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, sqlite3 + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress --with="illuminate/contracts:${{ matrix.laravel }}" + + - name: Check code style + run: vendor/bin/pint --test + + - name: Run PHPStan + run: vendor/bin/phpstan + + - name: Run tests + run: vendor/bin/pest --colors=always diff --git a/.gitignore b/.gitignore index d5673e3..7d2a82b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,30 +1,13 @@ +/.phpunit.result.cache +/.phpunit.cache +/.phpstan.cache +/.php-cs-fixer.cache +/.php-cs-fixer.php +/composer.lock +/phpunit.xml /vendor/ -node_modules/ -npm-debug.log -yarn-error.log - -# Laravel 4 specific -bootstrap/compiled.php -app/storage/ - -# Laravel 5 & Lumen specific -public/storage -public/hot - -# Laravel 5 & Lumen specific with changed public path -public_html/storage -public_html/hot - -storage/*.key -.env -Homestead.yaml -Homestead.json -/.vagrant -.phpunit.result.cache - -/public/build -/storage/pail -.env.backup -.env.production -.phpactor.json -auth.json +*.swp +*.swo +/.phpactor.json +/.idea +/.claude/settings.local.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f40f7f --- /dev/null +++ b/README.md @@ -0,0 +1,229 @@ +

+ Transistor banner +

+ +# Transistor + +Laravel integration for [gksh/bitmask](https://github.com/gksh/bitmask) - providing Eloquent casting, query scopes, validation, Blade directives, and migration macros for working with bitmask flags. + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/gksh/transistor.svg?style=flat-square)](https://packagist.org/packages/gksh/transistor) +[![PHP Version](https://img.shields.io/packagist/php-v/gksh/transistor.svg?style=flat-square)](https://packagist.org/packages/gksh/transistor) +[![License](https://img.shields.io/packagist/l/gksh/transistor.svg?style=flat-square)](https://packagist.org/packages/gksh/transistor) + +## Installation + +```bash +composer require gksh/transistor +``` + +The service provider is auto-discovered by Laravel. + +## Quick Start + +```php +// 1. Generate a bitmask enum +php artisan make:bitmask-flags Permission + +// 2. Add migration macro +Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->bitmask('permissions'); +}); + +// 3. Configure your model +use Gksh\Transistor\Casts\BitmaskCast; +use Gksh\Transistor\Concerns\HasBitmaskScopes; + +class User extends Model +{ + use HasBitmaskScopes; + + protected function casts(): array + { + return [ + 'permissions' => BitmaskCast::class, + ]; + } +} + +// 4. Use it +$user->permissions = $user->permissions + ->set(Permission::Read) + ->set(Permission::Write); + +User::whereHasBitmaskFlag('permissions', Permission::Admin)->get(); +``` + +## Artisan Commands + +### make:bitmask-flags + +Generate a bitmask flags enum interactively: + +```bash +php artisan make:bitmask-flags +php artisan make:bitmask-flags Permission +``` + +Creates an enum in `app/Enums` with bit-shifted values: + +```php +namespace App\Enums; + +enum Permission: int +{ + case Read = 1 << 0; // 1 + case Write = 1 << 1; // 2 + case Delete = 1 << 2; // 4 + case Admin = 1 << 3; // 8 +} +``` + +### bitmask:inspect + +Display bitmask flags as a lookup table: + +```bash +php artisan bitmask:inspect "App\Enums\Permission" +php artisan bitmask:inspect Permission 13 +``` + +``` ++--------+---------+-----------------+ +| Case | Decimal | Binary | ++--------+---------+-----------------+ +| Read | 1 | 0 0 0 0 0 0 0 1 | +| Write | 2 | 0 0 0 0 0 0 1 0 | +| Delete | 4 | 0 0 0 0 0 1 0 0 | +| Admin | 8 | 0 0 0 0 1 0 0 0 | ++--------+---------+-----------------+ +| Value | 13 | 0 0 0 0 1 1 0 1 | ++--------+---------+-----------------+ +``` + +When a value is provided, active bits are highlighted in green. Heavily inspired by https://laracasts.com/series/lukes-larabits/episodes/18. + +## Eloquent Cast + +Cast database integers to `Bitmask` objects: + +```php +use Gksh\Transistor\Casts\BitmaskCast; + +protected function casts(): array +{ + return [ + 'permissions' => BitmaskCast::class, // 32-bit (default) + 'settings' => BitmaskCast::class.':tiny', // 8-bit (0-255) + 'features' => BitmaskCast::class.':small', // 16-bit (0-65,535) + 'options' => BitmaskCast::class.':medium', // 24-bit (0-16,777,215) + ]; +} +``` + +The cast returns a `Gksh\Bitmask\Bitmask` object with methods like `set()`, `unset()`, `toggle()`, `has()`, and `value()`. + +## Query Scopes + +Add the `HasBitmaskScopes` trait to your model: + +```php +use Gksh\Transistor\Concerns\HasBitmaskScopes; + +class User extends Model +{ + use HasBitmaskScopes; +} +``` + +### Available Scopes + +```php +// Filter where flag IS set +User::whereHasBitmaskFlag('permissions', Permission::Read)->get(); + +// Filter where ALL flags are set +User::whereHasAllBitmaskFlags('permissions', [Permission::Read, Permission::Write])->get(); + +// Filter where ANY flag is set +User::whereHasAnyBitmaskFlag('permissions', [Permission::Write, Permission::Admin])->get(); + +// Filter where flag is NOT set +User::whereDoesntHaveBitmaskFlag('permissions', Permission::Admin)->get(); + +// Filter where NONE of the flags are set +User::whereDoesntHaveAnyBitmaskFlag('permissions', [Permission::Read, Permission::Write])->get(); +``` + +Scopes can be chained: + +```php +User::whereHasBitmaskFlag('permissions', Permission::Read) + ->whereDoesntHaveBitmaskFlag('permissions', Permission::Admin) + ->get(); +``` + +## Migration Macros + +Create bitmask columns with the appropriate integer size: + +```php +Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->bitmask('permissions'); // unsigned integer (32-bit) + $table->tinyBitmask('settings'); // unsigned tinyInteger (8-bit) + $table->smallBitmask('features'); // unsigned smallInteger (16-bit) + $table->mediumBitmask('options'); // unsigned mediumInteger (24-bit) +}); +``` + +All macros set a default value of `0`. + +## Validation + +### As a Rule Object + +```php +use Gksh\Transistor\Rules\ValidBitmask; + +$validated = $request->validate([ + 'permissions' => ['required', new ValidBitmask()], + 'role_flags' => ['required', new ValidBitmask(Permission::class)], +]); +``` + +### As a String Rule + +```php +$validated = $request->validate([ + 'permissions' => 'required|bitmask', + 'role_flags' => 'required|bitmask:App\Enums\Permission', +]); +``` + +When an enum class is provided, the rule ensures the value doesn't exceed the maximum possible combination of all flags. + +## Blade Directives + +```blade +@hasBitmaskFlag($user->permissions, Permission::Admin) + Administrator +@endif + +@hasAnyBitmaskFlag($user->permissions, [Permission::Write, Permission::Admin]) + +@endif + +@hasAllBitmaskFlags($user->permissions, [Permission::Read, Permission::Write]) + Full Access +@endif +``` + +## Requirements + +- PHP 8.2+ +- Laravel 11.x or 12.x + +## License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1337493 --- /dev/null +++ b/composer.json @@ -0,0 +1,62 @@ +{ + "name": "gksh/transistor", + "description": "Laravel integration for gksh/bitmask", + "keywords": ["laravel", "bitmask", "bitwise", "flags", "eloquent", "cast"], + "license": "MIT", + "authors": [ + { + "name": "Gustavo Karkow", + "email": "karkowg@gmail.com" + } + ], + "require": { + "php": "^8.2", + "gksh/bitmask": "^1.0", + "illuminate/contracts": "^11.0|^12.0", + "illuminate/database": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0", + "laravel/prompts": "^0.1|^0.2|^0.3" + }, + "require-dev": { + "laravel/pint": "^1.18", + "orchestra/testbench": "^9.0|^10.0", + "pestphp/pest": "^3.0|^4.0", + "phpstan/phpstan": "^2.0" + }, + "autoload": { + "psr-4": { + "Gksh\\Transistor\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Gksh\\Transistor\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Gksh\\Transistor\\TransistorServiceProvider" + ] + } + }, + "scripts": { + "lint": "pint --test", + "lint:fix": "pint", + "test": "pest", + "analyse": "phpstan", + "ci": [ + "@lint", + "@analyse", + "@test" + ] + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..5547d69 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +includes: + - ./vendor/phpstan/phpstan/conf/bleedingEdge.neon + +parameters: + level: max + paths: + - src + tmpDir: .phpstan.cache + ignoreErrors: + - '#Trait .+ is used zero times and is not analysed#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..aa23c17 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,17 @@ + + + + + ./tests + + + + + ./src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..a74e070 --- /dev/null +++ b/pint.json @@ -0,0 +1,7 @@ +{ + "preset": "laravel", + "rules": { + "declare_strict_types": true, + "final_class": true + } +} diff --git a/src/Casts/BitmaskCast.php b/src/Casts/BitmaskCast.php new file mode 100644 index 0000000..739f308 --- /dev/null +++ b/src/Casts/BitmaskCast.php @@ -0,0 +1,53 @@ + + */ +final class BitmaskCast implements CastsAttributes +{ + /** + * @param 'tiny'|'small'|'medium'|'default' $size + */ + public function __construct( + protected string $size = 'default', + ) {} + + /** + * @param array $attributes + */ + public function get(Model $model, string $key, mixed $value, array $attributes): ?Bitmask + { + if ($value === null) { + return null; + } + + $intValue = is_numeric($value) ? (int) $value : 0; + + return match ($this->size) { + 'tiny' => Bitmask::tiny($intValue), + 'small' => Bitmask::small($intValue), + 'medium' => Bitmask::medium($intValue), + default => Bitmask::make($intValue), + }; + } + + /** + * @param array $attributes + */ + public function set(Model $model, string $key, mixed $value, array $attributes): ?int + { + return match (true) { + $value === null => null, + $value instanceof Bitmask => $value->value(), + default => (int) $value, + }; + } +} diff --git a/src/Commands/InspectBitmaskCommand.php b/src/Commands/InspectBitmaskCommand.php new file mode 100644 index 0000000..ad28e54 --- /dev/null +++ b/src/Commands/InspectBitmaskCommand.php @@ -0,0 +1,160 @@ +resolveEnumClass(); + + if ($enumClass === null) { + return self::FAILURE; + } + + if (! $this->validateEnum($enumClass)) { + return self::FAILURE; + } + + $value = $this->resolveValue(); + + if ($value === false) { + return self::FAILURE; + } + + $this->displayTable($enumClass, $value); + + return self::SUCCESS; + } + + /** + * @return class-string|null + */ + private function resolveEnumClass(): ?string + { + /** @var string $input */ + $input = $this->argument('enum'); + + // Try the input as-is first (FQCN) + if (enum_exists($input)) { + /** @var class-string $input */ + return $input; + } + + // Try with App\Enums namespace + $withNamespace = 'App\\Enums\\'.$input; + if (enum_exists($withNamespace)) { + /** @var class-string $withNamespace */ + return $withNamespace; + } + + $this->error("Enum class '{$input}' not found."); + + return null; + } + + /** + * @param class-string $enumClass + */ + private function validateEnum(string $enumClass): bool + { + $reflection = new ReflectionEnum($enumClass); + + if ($reflection->isBacked()) { + /** @var \ReflectionNamedType $backingType */ + $backingType = $reflection->getBackingType(); + + if ($backingType->getName() !== 'int') { + $this->error('Only integer-backed enums are supported.'); + + return false; + } + } + + return true; + } + + private function resolveValue(): int|null|false + { + $input = $this->argument('value'); + + if ($input === null) { + return null; + } + + if (! is_numeric($input) || (int) $input < 0) { + $this->error('Value must be a non-negative integer.'); + + return false; + } + + return (int) $input; + } + + /** + * @param class-string $enumClass + */ + private function displayTable(string $enumClass, ?int $value): void + { + /** @var array $cases */ + $cases = $enumClass::cases(); + + $caseValues = array_map(maskValue(...), $cases); + $maxValue = max($value ?? 0, ...$caseValues); + $bitWidth = max(8, (int) ceil(log($maxValue + 1, 2))); + + $rows = []; + + foreach ($cases as $case) { + $caseValue = maskValue($case); + $rows[] = [ + $case->name, + $caseValue, + $this->formatBinaryColored($caseValue, $bitWidth), + ]; + } + + if ($value !== null) { + $rows[] = new \Symfony\Component\Console\Helper\TableSeparator; + $rows[] = [ + 'Value', + $value, + $this->formatBinaryColored($value, $bitWidth), + ]; + } + + $this->table(['Case', 'Decimal', 'Binary'], $rows); + } + + private function formatBinaryColored(int $value, int $width): string + { + $binary = str_pad(decbin($value), $width, '0', STR_PAD_LEFT); + $bits = str_split($binary); + + $colored = array_map( + fn (string $bit): string => $bit === '1' + ? "{$bit}" + : "{$bit}", + $bits, + ); + + return implode(' ', $colored); + } +} diff --git a/src/Commands/MakeBitmaskFlagsCommand.php b/src/Commands/MakeBitmaskFlagsCommand.php new file mode 100644 index 0000000..78b84a9 --- /dev/null +++ b/src/Commands/MakeBitmaskFlagsCommand.php @@ -0,0 +1,110 @@ + */ + protected array $flags = []; + + protected function getStub(): string + { + return __DIR__.'/../stubs/bitmask-flags.stub'; + } + + protected function getDefaultNamespace($rootNamespace): string + { + return $rootNamespace.'\\Enums'; + } + + /** + * @return array> + */ + protected function promptForMissingArgumentsUsing(): array + { + return [ + 'name' => ['What should the enum be named?', 'E.g. Permission'], + ]; + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $this->collectFlags(); + } + + public function handle(): ?bool + { + if (empty($this->flags)) { + $this->error('At least one flag is required.'); + + return false; + } + + return parent::handle(); + } + + protected function collectFlags(): void + { + $this->info('Enter flag names (empty to finish):'); + + for ($index = 1; ; $index++) { + $flag = text( + label: "Flag {$index}", + placeholder: $index === 1 ? 'e.g. Read' : '', + validate: fn (string $value): ?string => $value === '' ? null : $this->validateFlagName($value), + ); + + if ($flag === '') { + return; + } + + $this->flags[] = Str::studly($flag); + } + } + + protected function buildClass($name): string + { + $stub = parent::buildClass($name); + + return str_replace('{{ cases }}', $this->buildCases(), $stub); + } + + protected function buildCases(): string + { + $cases = array_map( + fn (string $flag, int $index): string => " case {$flag} = 1 << {$index};", + $this->flags, + array_keys($this->flags), + ); + + return implode("\n", $cases); + } + + private function validateFlagName(string $value): ?string + { + $studly = Str::studly($value); + + if (in_array($studly, $this->flags, true)) { + return "The flag '{$studly}' already exists."; + } + + return null; + } +} diff --git a/src/Concerns/HasBitmaskScopes.php b/src/Concerns/HasBitmaskScopes.php new file mode 100644 index 0000000..100ac68 --- /dev/null +++ b/src/Concerns/HasBitmaskScopes.php @@ -0,0 +1,97 @@ + $query + * @return Builder + */ + public function scopeWhereHasBitmaskFlag(Builder $query, string $column, int|BackedEnum|UnitEnum $flag): Builder + { + $value = maskValue($flag); + + return $query->whereRaw("{$column} & ? = ?", [$value, $value]); + } + + /** + * Scope to filter records where the bitmask column has ALL specified flags set. + * + * @param Builder $query + * @param array $flags + * @return Builder + */ + public function scopeWhereHasAllBitmaskFlags(Builder $query, string $column, array $flags): Builder + { + $value = $this->combinedMaskValue($flags); + + return $query->whereRaw("{$column} & ? = ?", [$value, $value]); + } + + /** + * Scope to filter records where the bitmask column has ANY of the specified flags set. + * + * @param Builder $query + * @param array $flags + * @return Builder + */ + public function scopeWhereHasAnyBitmaskFlag(Builder $query, string $column, array $flags): Builder + { + $value = $this->combinedMaskValue($flags); + + return $query->whereRaw("{$column} & ? != 0", [$value]); + } + + /** + * Scope to filter records where the bitmask column does NOT have a specific flag set. + * + * @param Builder $query + * @return Builder + */ + public function scopeWhereDoesntHaveBitmaskFlag(Builder $query, string $column, int|BackedEnum|UnitEnum $flag): Builder + { + $value = maskValue($flag); + + return $query->whereRaw("{$column} & ? != ?", [$value, $value]); + } + + /** + * Scope to filter records where the bitmask column does NOT have ANY of the specified flags set. + * + * @param Builder $query + * @param array $flags + * @return Builder + */ + public function scopeWhereDoesntHaveAnyBitmaskFlag(Builder $query, string $column, array $flags): Builder + { + $value = $this->combinedMaskValue($flags); + + return $query->whereRaw("{$column} & ? = 0", [$value]); + } + + /** + * @param array $flags + */ + private function combinedMaskValue(array $flags): int + { + return array_reduce( + array_map(maskValue(...), $flags), + fn (int $carry, int $value): int => $carry | $value, + 0 + ); + } +} diff --git a/src/Rules/ValidBitmask.php b/src/Rules/ValidBitmask.php new file mode 100644 index 0000000..38dfe17 --- /dev/null +++ b/src/Rules/ValidBitmask.php @@ -0,0 +1,66 @@ +|null $enumClass + */ + public function __construct( + private readonly ?string $enumClass = null, + ) {} + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! $this->passes($attribute, $value)) { + $fail('The :attribute must be a valid bitmask value.'); + } + } + + public function passes(string $attribute, mixed $value): bool + { + if (! is_int($value) && ! is_numeric($value)) { + return false; + } + + $intValue = (int) $value; + + if ($intValue < 0) { + return false; + } + + if ($this->enumClass === null) { + return true; + } + + if (! enum_exists($this->enumClass)) { + return false; + } + + return $intValue <= $this->calculateMaxValue(); + } + + private function calculateMaxValue(): int + { + assert($this->enumClass !== null); + + /** @var array $cases */ + $cases = $this->enumClass::cases(); + + return array_reduce( + $cases, + fn (int $carry, BackedEnum|UnitEnum $case): int => $carry | maskValue($case), + 0, + ); + } +} diff --git a/src/TransistorServiceProvider.php b/src/TransistorServiceProvider.php new file mode 100644 index 0000000..05caf87 --- /dev/null +++ b/src/TransistorServiceProvider.php @@ -0,0 +1,90 @@ +registerCommands(); + $this->registerBladeDirectives(); + $this->registerValidationRules(); + $this->registerBlueprintMacros(); + } + + private function registerCommands(): void + { + if ($this->app->runningInConsole()) { + $this->commands([ + InspectBitmaskCommand::class, + MakeBitmaskFlagsCommand::class, + ]); + } + } + + private function registerBladeDirectives(): void + { + Blade::if('hasBitmaskFlag', fn (Bitmask $bitmask, int|\BackedEnum|\UnitEnum $flag): bool => $bitmask->has($flag)); + + /** @param array $flags */ + Blade::if('hasAnyBitmaskFlag', fn (Bitmask $bitmask, array $flags): bool => array_any( + $flags, + fn (int|\BackedEnum|\UnitEnum $flag): bool => $bitmask->has($flag), // @phpstan-ignore argument.type + )); + + /** @param array $flags */ + Blade::if('hasAllBitmaskFlags', fn (Bitmask $bitmask, array $flags): bool => array_all( + $flags, + fn (int|\BackedEnum|\UnitEnum $flag): bool => $bitmask->has($flag), // @phpstan-ignore argument.type + )); + } + + private function registerValidationRules(): void + { + Validator::extend('bitmask', function (string $attribute, mixed $value, array $parameters): bool { + /** @var class-string<\BackedEnum|\UnitEnum>|null $enumClass */ + $enumClass = $parameters[0] ?? null; + + return (new ValidBitmask($enumClass))->passes($attribute, $value); + }); + } + + private function registerBlueprintMacros(): void + { + Blueprint::macro('tinyBitmask', function (string $column): \Illuminate\Database\Schema\ColumnDefinition { + /** @var Blueprint $this */ + return $this->tinyInteger($column)->unsigned()->default(0); + }); + + Blueprint::macro('smallBitmask', function (string $column): \Illuminate\Database\Schema\ColumnDefinition { + /** @var Blueprint $this */ + return $this->smallInteger($column)->unsigned()->default(0); + }); + + Blueprint::macro('mediumBitmask', function (string $column): \Illuminate\Database\Schema\ColumnDefinition { + /** @var Blueprint $this */ + return $this->mediumInteger($column)->unsigned()->default(0); + }); + + Blueprint::macro('bitmask', function (string $column): \Illuminate\Database\Schema\ColumnDefinition { + /** @var Blueprint $this */ + return $this->unsignedInteger($column)->default(0); + }); + } +} diff --git a/src/stubs/bitmask-flags.stub b/src/stubs/bitmask-flags.stub new file mode 100644 index 0000000..dae0a81 --- /dev/null +++ b/src/stubs/bitmask-flags.stub @@ -0,0 +1,10 @@ +loadMigrationsFrom(__DIR__.'/database/migrations'); +}); + +it('casts integer to bitmask on retrieval', function () { + User::create(['permissions' => 5, 'settings' => 3]); + + $user = User::first(); + + expect($user->permissions) + ->toBeInstanceOf(Bitmask::class) + ->and($user->permissions->value())->toBe(5); +}); + +it('casts bitmask to integer on storage', function () { + $bitmask = Bitmask::make() + ->set(Permission::Read) + ->set(Permission::Delete); + + User::create(['permissions' => $bitmask, 'settings' => 0]); + + $this->assertDatabaseHas('users', [ + 'permissions' => 5, + ]); +}); + +it('handles null values', function () { + User::unguard(); + $user = new User(['permissions' => null, 'settings' => null]); + User::reguard(); + + expect($user->permissions)->toBeNull(); +}); + +it('respects size parameter for tiny bitmask', function () { + User::create(['permissions' => 0, 'settings' => 127]); + + $user = User::first(); + + expect($user->settings) + ->toBeInstanceOf(Bitmask::class) + ->and($user->settings->size()->value)->toBe(8); +}); + +it('preserves bitmask operations through save cycle', function () { + $user = User::create(['permissions' => 0, 'settings' => 0]); + + $user->permissions = $user->permissions + ->set(Permission::Read) + ->set(Permission::Write); + + $user->save(); + + $freshUser = User::find($user->id); + + expect($freshUser->permissions->has(Permission::Read))->toBeTrue() + ->and($freshUser->permissions->has(Permission::Write))->toBeTrue() + ->and($freshUser->permissions->has(Permission::Delete))->toBeFalse(); +}); diff --git a/tests/Fixtures/Permission.php b/tests/Fixtures/Permission.php new file mode 100644 index 0000000..bd231ec --- /dev/null +++ b/tests/Fixtures/Permission.php @@ -0,0 +1,13 @@ + BitmaskCast::class, + 'settings' => BitmaskCast::class.':tiny', + ]; + } +} diff --git a/tests/HasBitmaskScopesSpec.php b/tests/HasBitmaskScopesSpec.php new file mode 100644 index 0000000..be2cc49 --- /dev/null +++ b/tests/HasBitmaskScopesSpec.php @@ -0,0 +1,62 @@ +loadMigrationsFrom(__DIR__.'/database/migrations'); + + // Create test users with different permission combinations + User::create(['permissions' => Permission::Read->value, 'settings' => 0]); // 1 + User::create(['permissions' => Permission::Read->value | Permission::Write->value, 'settings' => 0]); // 3 + User::create(['permissions' => Permission::Admin->value, 'settings' => 0]); // 8 + User::create(['permissions' => Permission::Read->value | Permission::Admin->value, 'settings' => 0]); // 9 + User::create(['permissions' => 0, 'settings' => 0]); // 0 +}); + +it('filters by single flag', function () { + $users = User::whereHasBitmaskFlag('permissions', Permission::Read)->get(); + + expect($users)->toHaveCount(3); +}); + +it('filters by all flags', function () { + $users = User::whereHasAllBitmaskFlags('permissions', [Permission::Read, Permission::Write])->get(); + + expect($users)->toHaveCount(1) + ->and($users->first()->permissions->value())->toBe(3); +}); + +it('filters by any flag', function () { + $users = User::whereHasAnyBitmaskFlag('permissions', [Permission::Write, Permission::Admin])->get(); + + expect($users)->toHaveCount(3); +}); + +it('filters by missing flag', function () { + $users = User::whereDoesntHaveBitmaskFlag('permissions', Permission::Admin)->get(); + + expect($users)->toHaveCount(3); +}); + +it('filters by missing any flags', function () { + $users = User::whereDoesntHaveAnyBitmaskFlag('permissions', [Permission::Read, Permission::Write])->get(); + + expect($users)->toHaveCount(2); +}); + +it('works with integer values', function () { + $users = User::whereHasBitmaskFlag('permissions', 1)->get(); + + expect($users)->toHaveCount(3); +}); + +it('can chain multiple scopes', function () { + $users = User::whereHasBitmaskFlag('permissions', Permission::Read) + ->whereDoesntHaveBitmaskFlag('permissions', Permission::Admin) + ->get(); + + expect($users)->toHaveCount(2); +}); diff --git a/tests/InspectBitmaskCommandSpec.php b/tests/InspectBitmaskCommandSpec.php new file mode 100644 index 0000000..4f6ff3b --- /dev/null +++ b/tests/InspectBitmaskCommandSpec.php @@ -0,0 +1,69 @@ +artisan('bitmask:inspect', ['enum' => Permission::class]) + ->expectsTable( + ['Case', 'Decimal', 'Binary'], + [ + ['Read', '1', '0 0 0 0 0 0 0 1'], + ['Write', '2', '0 0 0 0 0 0 1 0'], + ['Delete', '4', '0 0 0 0 0 1 0 0'], + ['Admin', '8', '0 0 0 0 1 0 0 0'], + ] + ) + ->assertSuccessful(); +}); + +it('displays correct table for a unit enum', function () { + $this->artisan('bitmask:inspect', ['enum' => UnitPermission::class]) + ->expectsTable( + ['Case', 'Decimal', 'Binary'], + [ + ['Read', '1', '0 0 0 0 0 0 0 1'], + ['Write', '2', '0 0 0 0 0 0 1 0'], + ['Delete', '4', '0 0 0 0 0 1 0 0'], + ['Admin', '8', '0 0 0 0 1 0 0 0'], + ] + ) + ->assertSuccessful(); +}); + +it('displays value row with correct decimal and binary', function (int $value, string $expectedBinary) { + $this->artisan('bitmask:inspect', ['enum' => Permission::class, 'value' => $value]) + ->expectsTable( + ['Case', 'Decimal', 'Binary'], + [ + ['Read', '1', '0 0 0 0 0 0 0 1'], + ['Write', '2', '0 0 0 0 0 0 1 0'], + ['Delete', '4', '0 0 0 0 0 1 0 0'], + ['Admin', '8', '0 0 0 0 1 0 0 0'], + new TableSeparator, + ['Value', (string) $value, $expectedBinary], + ] + ) + ->assertSuccessful(); +})->with([ + 'single flag (Read)' => [1, '0 0 0 0 0 0 0 1'], + 'two flags (Read + Delete)' => [5, '0 0 0 0 0 1 0 1'], + 'three flags (Read + Write + Admin)' => [11, '0 0 0 0 1 0 1 1'], + 'all flags' => [15, '0 0 0 0 1 1 1 1'], + 'zero (no flags)' => [0, '0 0 0 0 0 0 0 0'], +]); + +it('fails for non-existent enum', function () { + $this->artisan('bitmask:inspect', ['enum' => 'NonExistentEnum']) + ->expectsOutputToContain('not found') + ->assertFailed(); +}); + +it('fails for invalid value', function () { + $this->artisan('bitmask:inspect', ['enum' => Permission::class, 'value' => 'invalid']) + ->expectsOutputToContain('non-negative integer') + ->assertFailed(); +}); diff --git a/tests/MakeBitmaskFlagsCommandSpec.php b/tests/MakeBitmaskFlagsCommandSpec.php new file mode 100644 index 0000000..7d02342 --- /dev/null +++ b/tests/MakeBitmaskFlagsCommandSpec.php @@ -0,0 +1,98 @@ +enumPath = app_path('Enums'); + + if (File::isDirectory($this->enumPath)) { + File::deleteDirectory($this->enumPath); + } +}); + +afterEach(function () { + if (File::isDirectory($this->enumPath)) { + File::deleteDirectory($this->enumPath); + } + + Prompt::fallbackWhen(false); +}); + +it('creates enum with correct structure', function () { + Prompt::fallbackWhen(true); + + $this->artisan('make:bitmask-flags', ['name' => 'TestPermission']) + ->expectsQuestion('Flag 1', 'Read') + ->expectsQuestion('Flag 2', 'Write') + ->expectsQuestion('Flag 3', '') + ->assertSuccessful(); + + $filePath = app_path('Enums/TestPermission.php'); + expect(File::exists($filePath))->toBeTrue(); + + $content = File::get($filePath); + expect($content) + ->toContain('namespace App\Enums;') + ->toContain('enum TestPermission: int') + ->toContain('case Read = 1 << 0;') + ->toContain('case Write = 1 << 1;'); +}); + +it('creates enum in Enums namespace', function () { + Prompt::fallbackWhen(true); + + $this->artisan('make:bitmask-flags', ['name' => 'NamespaceEnum']) + ->expectsQuestion('Flag 1', 'First') + ->expectsQuestion('Flag 2', '') + ->assertSuccessful(); + + $content = File::get(app_path('Enums/NamespaceEnum.php')); + expect($content)->toContain('namespace App\Enums;'); +}); + +it('fails when no flags are provided', function () { + Prompt::fallbackWhen(true); + + $this->artisan('make:bitmask-flags', ['name' => 'EmptyEnum']) + ->expectsQuestion('Flag 1', ''); + + // File should not be created when no flags provided + expect(File::exists(app_path('Enums/EmptyEnum.php')))->toBeFalse(); +}); + +it('converts flag names to studly case', function () { + Prompt::fallbackWhen(true); + + $this->artisan('make:bitmask-flags', ['name' => 'StudlyEnum']) + ->expectsQuestion('Flag 1', 'some_flag') + ->expectsQuestion('Flag 2', 'another-flag') + ->expectsQuestion('Flag 3', '') + ->assertSuccessful(); + + $content = File::get(app_path('Enums/StudlyEnum.php')); + expect($content) + ->toContain('case SomeFlag = 1 << 0;') + ->toContain('case AnotherFlag = 1 << 1;'); +}); + +it('assigns correct bit shift values', function () { + Prompt::fallbackWhen(true); + + $this->artisan('make:bitmask-flags', ['name' => 'ManyFlags']) + ->expectsQuestion('Flag 1', 'First') + ->expectsQuestion('Flag 2', 'Second') + ->expectsQuestion('Flag 3', 'Third') + ->expectsQuestion('Flag 4', 'Fourth') + ->expectsQuestion('Flag 5', '') + ->assertSuccessful(); + + $content = File::get(app_path('Enums/ManyFlags.php')); + expect($content) + ->toContain('case First = 1 << 0;') + ->toContain('case Second = 1 << 1;') + ->toContain('case Third = 1 << 2;') + ->toContain('case Fourth = 1 << 3;'); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..4dce800 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,7 @@ +in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..a406593 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,33 @@ +loadMigrationsFrom(__DIR__.'/database/migrations'); + } + + protected function getEnvironmentSetUp($app): void + { + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } +} diff --git a/tests/ValidBitmaskSpec.php b/tests/ValidBitmaskSpec.php new file mode 100644 index 0000000..1383ed7 --- /dev/null +++ b/tests/ValidBitmaskSpec.php @@ -0,0 +1,70 @@ +passes('permissions', 0))->toBeTrue() + ->and($rule->passes('permissions', 15))->toBeTrue() + ->and($rule->passes('permissions', 255))->toBeTrue(); +}); + +it('fails for negative values', function () { + $rule = new ValidBitmask; + + expect($rule->passes('permissions', -1))->toBeFalse(); +}); + +it('fails for non-numeric values', function () { + $rule = new ValidBitmask; + + expect($rule->passes('permissions', 'invalid'))->toBeFalse() + ->and($rule->passes('permissions', []))->toBeFalse() + ->and($rule->passes('permissions', null))->toBeFalse(); +}); + +it('passes for valid bitmask within enum range', function () { + $rule = new ValidBitmask(Permission::class); + + // All flags: Read(1) | Write(2) | Delete(4) | Admin(8) = 15 + expect($rule->passes('permissions', 0))->toBeTrue() + ->and($rule->passes('permissions', 1))->toBeTrue() + ->and($rule->passes('permissions', 7))->toBeTrue() + ->and($rule->passes('permissions', 15))->toBeTrue(); +}); + +it('fails for bitmask exceeding enum range', function () { + $rule = new ValidBitmask(Permission::class); + + // Max valid is 15, so 16 should fail + expect($rule->passes('permissions', 16))->toBeFalse() + ->and($rule->passes('permissions', 255))->toBeFalse(); +}); + +it('works as string validation rule', function () { + $validator = Validator::make( + ['permissions' => 7], + ['permissions' => 'bitmask:'.Permission::class] + ); + + expect($validator->passes())->toBeTrue(); + + $validator = Validator::make( + ['permissions' => 16], + ['permissions' => 'bitmask:'.Permission::class] + ); + + expect($validator->passes())->toBeFalse(); +}); + +it('accepts numeric strings', function () { + $rule = new ValidBitmask; + + expect($rule->passes('permissions', '15'))->toBeTrue() + ->and($rule->passes('permissions', '0'))->toBeTrue(); +}); diff --git a/tests/database/migrations/0001_01_01_000000_create_users_table.php b/tests/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..31db0b5 --- /dev/null +++ b/tests/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,24 @@ +id(); + $table->bitmask('permissions'); + $table->tinyBitmask('settings'); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + } +};