From 86005d88d0e4d4172f5d7db9bc0ec22b10a7092f Mon Sep 17 00:00:00 2001 From: matteo Date: Sat, 4 May 2024 15:29:15 +0200 Subject: [PATCH 1/2] feat: LaravelRule Testing Numeric Preferences readme --- README.md | 103 +++++++++++------- src/Factory/PreferenceBuilder.php | 4 +- src/Rules/LaravelRule.php | 26 +++++ tests/PreferenceBasicTest.php | 24 ++++ tests/RulesTest/CombinedRulesTest.php | 13 +++ .../TestSubjects/Enums/NumericPreferences.php | 12 ++ 6 files changed, 141 insertions(+), 41 deletions(-) create mode 100644 src/Rules/LaravelRule.php create mode 100644 tests/TestSubjects/Enums/NumericPreferences.php diff --git a/README.md b/README.md index ba7b7ed..613e742 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ This Laravel package aims to store and manage user settings/preferences in a sim * [Concepts](#concepts) * [Define your preferences](#define-your-preferences) * [Create a Preference](#create-a-preference) + * [Preference Building](#preference-building) * [Working with preferences](#working-with-preferences) * [Examples](#examples) * [Casting](#casting) @@ -25,7 +26,6 @@ This Laravel package aims to store and manage user settings/preferences in a sim * [Available Rules](#available-rules) * [Custom Rules](#custom-rules) * [Policies](#policies) -* [Preference Building](#preference-building) * [Routing](#routing) * [Anantomy](#anantomy) * [Example](#config-example) @@ -118,15 +118,20 @@ php artisan migrate ### Concepts -Each preference has at least a name and a caster. For additional validation you can add you custom Rule object -> [!TIP] -> The default caster supports all major primitives, enums, objects, as well as time/datetime/date and timestamp which -> get converted with `Carbon/Carbon` +Each preference has at least a name and a caster. +Names are stored in one or more enums and are the unique identifier for that preference + +For additional validation you can add you custom Rule object. + +For additional security you can add Policies ### Define your preferences Organize them in one or more **string backed** enum. +> [!NOTE] +> while it does not need to be string backed, its way more developer friendly. Especially when interacting over the APi + Each enum gets scoped and does not conflict with other enums with the same case e.g. @@ -143,7 +148,7 @@ enum Preferences :string implements PreferenceGroup enum General :string implements PreferenceGroup { - case LANGUAGE="language"; + case LANGUAGE="language"; case THEME="theme"; } ``` @@ -166,7 +171,7 @@ public function up(): void // Or PreferenceBuilder::init(Preferences::LANGUAGE)->create() // different enums with the same value do not conflict - PreferenceBuilder::init(OtherPreferences::LANGUAGE)->create() + PreferenceBuilder::init(General::LANGUAGE)->create() // update PreferenceBuilder::init(Preferences::LANGUAGE) @@ -184,8 +189,6 @@ public function up(): void ->nullable() ->create() - - } public function down(): void @@ -239,9 +242,43 @@ return new class extends Migration { ``` +## Preference Building + +
+Check all methods available to build a Preference + +### Available Methods + +This table includes a complete list of all features available, +when building a preference. + +| Single-Mode | Bulk-Mode (array-keys) | Constrains | Description | +|-------------------------------------|---------------------------------------------|---------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| init(>name<,>cast<) | ```["name"=> >name<]``` | \>name< = instanceof PreferenceGroup | Unique identifier for the preference | +| init(>name<,>cast<) | ```["cast"=> >cast<]``` | \>cast< = instanceof CastableEnum | Caster to translate the value between all different scenarios. Currently: Api-calls as well as saving to and retrieving fron the DB | +| nullable(>nullable<) | ```["nullable"=> >nullable<]``` | \>nullable< = bool | Whether the default value can be null and if the preference can be set to null | +| withDefaultValue(>default_value<) | ```["default_value"=> >default_value<]``` | \>default_value< = mixed, but must comply with the cast & validationRule | Initial value for this preference | +| withDescription(>description<) | ```["description"=> >description<]``` | \>description< = string | Legacy code from v1.x has no actual use as of now | +| withPolicy(>policy<) | ```["policy"=> >policy<]``` | \>policy< = instanceof PreferencePolicy | Authorize actions such as update/delete etc. on certain preferences. | +| withRule(>rule<) | ```["rule"=> >rule<]``` | \>rule< = instanceof ValidationRule | Additional validation Rule, to validate values before setting them | +| setAllowedClasses(>allowed_values<) | ```["allowed_values"=> >allowed_values<]``` | \>allowed_values< = array of string classes. For non Primitive Casts only | Current use-cases:
- restrict classes of enum or object that can be set to this preference
- reconstruct the original class when sending data via api. | + +### Available helper functions + +Optionally, pass the default value as a second parameter + +```php + // quickly build a nullable Array preference + PreferenceBuilder::buildArray(VideoPreferences::CONFIG); + + PreferenceBuilder::buildString(VideoPreferences::LANGUAGE); +``` + +
+ ## Working with preferences -two things are needed: +Two things are needed: - `HasPreferences` trait to access the helper functions - `PreferenceableModel` Interface to have access to the implementation @@ -249,7 +286,7 @@ two things are needed: #### isUserAuthorized -guard function to validate if the currently logged in (if any) user has access to this model +Guard function to validate if the currently logged in (if any) user has access to this model Signature: - $user the logged in user @@ -303,7 +340,7 @@ class User extends \Illuminate\Foundation\Auth\User implements PreferenceableMod ## Casting -set the cast when creating a Preference +Set the cast when creating a Preference > [!NOTE] > a cast has 3 main jobs @@ -341,7 +378,7 @@ PreferenceBuilder::init(Preferences::LANGUAGE, Cast::ENUM) ### Custom Caster -implement `CastableEnum` +Implement `CastableEnum` > [!IMPORTANT] > The custom caster needs to be a **string backed** enum @@ -407,19 +444,20 @@ Additional validation, which can be way more complex than provided by the Cast ### Available Rules -| Rule | Example | Description | -|----------------|----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------| +| Rule | Example | Description | +|----------------|--------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------| | AndRule | `new AndRule(new BetweenRule(2.4, 5.5), new LowerThanRule(5))` | Expects `n` ValidationRule, ensures all pass | -| OrRule | `new OrRule(new BetweenRule(2.4, 5.5), new LowerThanRule(5))` | Expects `n` ValidationRule, ensures at least one passes | -| BetweenRule | `new BetweenRule(2.4, 5.5)` | For INT and FLOAT, check that the value is between min and max | -| InRule | `new InRule("it","en","de")` | Expects the value to be validated to be in that equal to one of the `n` params | -| InstanceOfRule | `new InstanceOfRule(Theme::class)` | For non primitive casts, checks the instance of the value's class to validate. Tip: goes along well with the `OrRule` | -| IsRule | `new IsRule(Type::ITERABLE)` | Expects a `Matteoc99\LaravelPreference\Enums\Type` Enum. Checks e.g. if the value is iterable | -| LowerThanRule | `new LowerThanRule(5)` | For INT and FLOAT, check that the value to be validated is less than the one passed in the constructor | +| OrRule | `new OrRule(new BetweenRule(2.4, 5.5), new LowerThanRule(5))` | Expects `n` ValidationRule, ensures at least one passes | +| LaravelRule | `new LaravelRule("required\|numeric")` | Expects a string, containing a Laravel Validation Rule | +| BetweenRule | `new BetweenRule(2.4, 5.5)` | For INT and FLOAT, check that the value is between min and max | +| InRule | `new InRule("it","en","de")` | Expects the value to be validated to be in that equal to one of the `n` params | +| InstanceOfRule | `new InstanceOfRule(Theme::class)` | For non primitive casts, checks the instance of the value's class to validate. Tip: goes along well with the `OrRule` | +| IsRule | `new IsRule(Type::ITERABLE)` | Expects a `Matteoc99\LaravelPreference\Enums\Type` Enum. Checks e.g. if the value is iterable | +| LowerThanRule | `new LowerThanRule(5)` | For INT and FLOAT, check that the value to be validated is less than the one passed in the constructor | ### Custom Rules -implement `ValidationRule` +Implement Laravel's `ValidationRule` #### Example: @@ -453,11 +491,11 @@ class MyRule implements ValidationRule ## Policies -each preference can have a Policy, should [isUserAuthorized](#isuserauthorized) not be enough for your usecase +Each preference can have a Policy, should [isUserAuthorized](#isuserauthorized) not be enough for your usecase ### Creating policies -implement `PreferencePolicy` and the 4 methods defined by the contract +Implement `PreferencePolicy` and the 4 methods defined by the contract | parameter | description | |-----------------------------|------------------------------------------------------------| @@ -480,22 +518,9 @@ implement `PreferencePolicy` and the 4 methods defined by the contract ```` -## Preference Building - -| Single-Mode | Bulk-Mode (array-keys) | Constrains | Description | -|-------------------------------------|---------------------------------------------|---------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| init(>name<,>cast<) | ```["name"=> >name<]``` | \>name< = instanceof PreferenceGroup | Unique identifier for the preference | -| init(>name<,>cast<) | ```["cast"=> >cast<]``` | \>cast< = instanceof CastableEnum | Caster to translate the value between all different scenarios. Currently: Api-calls as well as saving to and retrieving fron the DB | -| nullable(>nullable<) | ```["nullable"=> >nullable<]``` | \>nullable< = bool | Whether the default value can be null and if the preference can be set to null | -| withDefaultValue(>default_value<) | ```["default_value"=> >default_value<]``` | \>default_value< = mixed, but must comply with the cast & validationRule | Initial value for this preference | -| withDescription(>description<) | ```["description"=> >description<]``` | \>description< = string | Legacy code from v1.x has no actual use as of now | -| withPolicy(>policy<) | ```["policy"=> >policy<]``` | \>policy< = instanceof PreferencePolicy | Authorize actions such as update/delete etc. on certain preferences. | -| withRule(>rule<) | ```["rule"=> >rule<]``` | \>rule< = instanceof ValidationRule | Additional validation Rule, to validate values before setting them | -| setAllowedClasses(>allowed_values<) | ```["allowed_values"=> >allowed_values<]``` | \>allowed_values< = array of string classes. For non Primitive Casts only | Current use-cases:
- restrict classes of enum or object that can be set to this preference
- reconstruct the original class when sending data via api. | - ## Routing -off by default, enable it in the config +Off by default, enable it in the config > [!WARNING] > **(Current) limitation**: it's not possible to set object casts via API @@ -505,7 +530,7 @@ off by default, enable it in the config 'Scope': the `PreferenceableModel` Model 'Group': the `PreferenceGroup` enum -routes then get transformed to: +Routes then get transformed to: | Action | URI | Description | |-----------|---------------------------------------------------|-------------------------------------------------------------| diff --git a/src/Factory/PreferenceBuilder.php b/src/Factory/PreferenceBuilder.php index 66d3927..5cc6ba9 100644 --- a/src/Factory/PreferenceBuilder.php +++ b/src/Factory/PreferenceBuilder.php @@ -36,7 +36,7 @@ class PreferenceBuilder */ public static function buildString(PreferenceGroup $name, string $default = null): void { - self::init($name)->nullable()->withDefaultValue(null)->create(); + self::init($name)->nullable()->withDefaultValue($default)->create(); } /** @@ -58,7 +58,7 @@ public static function buildString(PreferenceGroup $name, string $default = null */ public static function buildArray(PreferenceGroup $name, array $default = null): void { - self::init($name, Cast::ARRAY)->nullable()->withDefaultValue(null)->create(); + self::init($name, Cast::ARRAY)->nullable()->withDefaultValue($default)->create(); } diff --git a/src/Rules/LaravelRule.php b/src/Rules/LaravelRule.php new file mode 100644 index 0000000..a91ee7c --- /dev/null +++ b/src/Rules/LaravelRule.php @@ -0,0 +1,26 @@ + $value], [$attribute => $this->rule]); + + if ($validator->fails()) { + $fail($validator->messages()); + } + } +} \ No newline at end of file diff --git a/tests/PreferenceBasicTest.php b/tests/PreferenceBasicTest.php index d54d408..f528407 100644 --- a/tests/PreferenceBasicTest.php +++ b/tests/PreferenceBasicTest.php @@ -7,6 +7,7 @@ use Matteoc99\LaravelPreference\Models\Preference; use Matteoc99\LaravelPreference\Rules\InRule; use Matteoc99\LaravelPreference\Tests\TestSubjects\Enums\General; +use Matteoc99\LaravelPreference\Tests\TestSubjects\Enums\NumericPreferences; use Matteoc99\LaravelPreference\Tests\TestSubjects\Enums\OtherPreferences; use Matteoc99\LaravelPreference\Tests\TestSubjects\Enums\VideoPreferences; @@ -113,5 +114,28 @@ public function init_and_delete() } + /** @test */ + public function test_numeric_backed_preferences() + { + PreferenceBuilder::buildArray(NumericPreferences::ONE); + + PreferenceBuilder::buildString(NumericPreferences::TWO); + + PreferenceBuilder::init(NumericPreferences::TWO)->updateOrCreate(); + PreferenceBuilder::init(NumericPreferences::TWO)->updateOrCreate(); + PreferenceBuilder::init(NumericPreferences::TWO)->updateOrCreate(); + PreferenceBuilder::init(NumericPreferences::TWO)->updateOrCreate(); + PreferenceBuilder::init(NumericPreferences::TWO)->updateOrCreate(); + + $this->assertDatabaseCount((new Preference())->getTable(), 3); + + $this->testUser->setPreference(NumericPreferences::TWO, "14"); + $this->testUser->setPreference(NumericPreferences::ONE, ["test" => "value"]); + + $this->assertEquals('14', $this->testUser->getPreference(NumericPreferences::TWO)); + + $this->assertEquals(["test" => "value"], $this->testUser->getPreference(NumericPreferences::ONE)); + } + } \ No newline at end of file diff --git a/tests/RulesTest/CombinedRulesTest.php b/tests/RulesTest/CombinedRulesTest.php index 34f219f..4e22714 100644 --- a/tests/RulesTest/CombinedRulesTest.php +++ b/tests/RulesTest/CombinedRulesTest.php @@ -8,6 +8,7 @@ use Matteoc99\LaravelPreference\Rules\InRule; use Matteoc99\LaravelPreference\Rules\InstanceOfRule; use Matteoc99\LaravelPreference\Rules\IsRule; +use Matteoc99\LaravelPreference\Rules\LaravelRule; use Matteoc99\LaravelPreference\Rules\LowerThanRule; use Matteoc99\LaravelPreference\Rules\OrRule; use Matteoc99\LaravelPreference\Tests\TestCase; @@ -43,7 +44,19 @@ public static function andRuleProvider(): array ]; } + public static function laravelRuleProvider(): array + { + return [ + [new LaravelRule('required|email'), 'test@example.com', true, 'Expected LaravelRule to pass for valid email.'], + [new LaravelRule('required|numeric'), '123', true, 'Expected LaravelRule to pass for numeric value.'], + [new LaravelRule('required|numeric'), 'abc', false, 'Expected LaravelRule to fail for non-numeric value.'], + [new LaravelRule('required|in:foo,bar,baz'), 'foo', true, 'Expected LaravelRule to pass for value within given options.'], + [new LaravelRule('required|in:foo,bar,baz'), 'qux', false, 'Expected LaravelRule to fail for value not within given options.'], + ]; + } + /** + * @dataProvider laravelRuleProvider * @dataProvider orRuleProvider * @dataProvider andRuleProvider */ diff --git a/tests/TestSubjects/Enums/NumericPreferences.php b/tests/TestSubjects/Enums/NumericPreferences.php new file mode 100644 index 0000000..a32c01b --- /dev/null +++ b/tests/TestSubjects/Enums/NumericPreferences.php @@ -0,0 +1,12 @@ + Date: Sun, 5 May 2024 17:44:02 +0200 Subject: [PATCH 2/2] feat: reset preferences builder state int bulk with array of builders --- README.md | 14 ++- src/Contracts/PreferenceableModel.php | 13 ++- src/Enums/PolicyAction.php | 1 + src/Exceptions/InvalidStateException.php | 26 +++++ src/Factory/PreferenceBuilder.php | 42 ++++++- src/Factory/builders/BaseBuilder.php | 89 ++++++++++++++- .../builders/ObjectPreferenceBuilder.php | 6 + src/Models/Preference.php | 4 + src/Traits/HasPreferences.php | 17 +++ src/Traits/HasState.php | 62 +++++++++++ tests/BuilderTest/BuilderStateTest.php | 103 ++++++++++++++++++ .../PreferenceBuilderBulkTest.php | 12 +- tests/TestSubjects/Models/User.php | 1 - 13 files changed, 374 insertions(+), 16 deletions(-) create mode 100644 src/Exceptions/InvalidStateException.php create mode 100644 src/Traits/HasState.php create mode 100644 tests/BuilderTest/BuilderStateTest.php rename tests/{ => BuilderTest}/PreferenceBuilderBulkTest.php (95%) diff --git a/README.md b/README.md index 613e742..99a1348 100644 --- a/README.md +++ b/README.md @@ -200,10 +200,7 @@ public function down(): void #### Bulk mode ```php -use Illuminate\Database\Migrations\Migration; -use Matteoc99\LaravelPreference\Enums\Cast; -use Matteoc99\LaravelPreference\Factory\PreferenceBuilder; -use Matteoc99\LaravelPreference\Rules\InRule; +use Illuminate\Database\Migrations\Migration;use Matteoc99\LaravelPreference\Enums\Cast;use Matteoc99\LaravelPreference\Factory\PreferenceBuilder;use Matteoc99\LaravelPreference\Rules\InRule; return new class extends Migration { @@ -236,6 +233,10 @@ return new class extends Migration { ['name' => Preferences::CONFIGURATION, 'nullable' => true // or nullable for only one configuration ], + // or an array of initialized single-mode builders + PreferenceBuilder::init(Preferences::LANGUAGE)->withRule(new InRule("en", "it", "de")), + PreferenceBuilder::init(Preferences::THEME)->withRule(new InRule("light", "dark")) + //mixing both in one array is also possible ]; } }; @@ -336,6 +337,11 @@ class User extends \Illuminate\Foundation\Auth\User implements PreferenceableMod $user->getPreferences(General::class) //or all $user->getPreferences(): Collection of UserPreferences + + + // removes all preferences set for tht user + $user->removeAllPreferences(); + ``` ## Casting diff --git a/src/Contracts/PreferenceableModel.php b/src/Contracts/PreferenceableModel.php index b02a3ed..c06cebc 100644 --- a/src/Contracts/PreferenceableModel.php +++ b/src/Contracts/PreferenceableModel.php @@ -31,6 +31,17 @@ public function getPreferences(string $group = null): Collection; */ public function removePreference(PreferenceGroup $name): int; + + /** + * Reset the model to its original state + * Remove all user's preferences. + * + * + * @return int Number of deleted records. + * @throws AuthorizationException + */ + public function removeAllPreferences(): int; + /** * Set a preference value, handling validation and persistence. * @@ -54,7 +65,7 @@ public function setPreference(PreferenceGroup $name, mixed $value): void; public function getPreference(PreferenceGroup $name, mixed $default = null): mixed; /** - * Get a user's preference value or default if not set with no casting + * Get a user's preference value or default if not set, transformed for data transfer * * @param PreferenceGroup|Preference $preference * @param string|null $default Default value if preference not set. diff --git a/src/Enums/PolicyAction.php b/src/Enums/PolicyAction.php index ffa2fc7..8c06964 100644 --- a/src/Enums/PolicyAction.php +++ b/src/Enums/PolicyAction.php @@ -4,6 +4,7 @@ enum PolicyAction { + case DELETE_ALL; case INDEX; case GET; case UPDATE; diff --git a/src/Exceptions/InvalidStateException.php b/src/Exceptions/InvalidStateException.php new file mode 100644 index 0000000..dbbc707 --- /dev/null +++ b/src/Exceptions/InvalidStateException.php @@ -0,0 +1,26 @@ +state = $state; + parent::__construct($message, 0, $previous); + } + + /** + * @return int + */ + public function getState(): int + { + return $this->state; + } +} \ No newline at end of file diff --git a/src/Factory/PreferenceBuilder.php b/src/Factory/PreferenceBuilder.php index 5cc6ba9..30c4f63 100644 --- a/src/Factory/PreferenceBuilder.php +++ b/src/Factory/PreferenceBuilder.php @@ -9,6 +9,8 @@ use Matteoc99\LaravelPreference\Contracts\CastableEnum; use Matteoc99\LaravelPreference\Contracts\PreferenceGroup; use Matteoc99\LaravelPreference\Enums\Cast; +use Matteoc99\LaravelPreference\Exceptions\InvalidStateException; +use Matteoc99\LaravelPreference\Factory\builders\BaseBuilder; use Matteoc99\LaravelPreference\Factory\builders\ObjectPreferenceBuilder; use Matteoc99\LaravelPreference\Factory\builders\PrimitivePreferenceBuilder; use Matteoc99\LaravelPreference\Models\Preference; @@ -28,6 +30,7 @@ class PreferenceBuilder * * @return void Creates the preference and commits it to the database. * + * @throws InvalidStateException * @example * ``` * PreferenceBuilder::buildString(UserPreference::TEST, "default"); @@ -49,6 +52,7 @@ public static function buildString(PreferenceGroup $name, string $default = null * * @return void Creates the preference and commits it to the database. * + * @throws InvalidStateException * @example * ``` * PreferenceBuilder::buildArray(new SettingsPreferenceGroup(), ["item1", "item2"]); @@ -99,7 +103,7 @@ public static function init(PreferenceGroup $name, CastableEnum $cast = Cast::ST * * @param PreferenceGroup $name * - * @return int, + * @return int */ public static function delete(PreferenceGroup $name): int { @@ -148,7 +152,23 @@ public static function initBulk(array $preferences, bool $nullable = false): voi throw new InvalidArgumentException("no preferences provided"); } - foreach ($preferences as $index => &$preferenceData) { + $cleanPreferences = []; + + foreach ($preferences as $index => $preferenceData) { + + if ($preferenceData instanceof BaseBuilder) { + if ($preferenceData->isStateSet(BaseBuilder::STATE_CREATED)) { + throw new InvalidStateException($preferenceData->getState() + , "The State should not be Created at this point, as its initBulk responsibility"); + } + if (!$preferenceData->isStateSet(BaseBuilder::STATE_NULLABLE_SET)) { + $preferenceData->nullable($nullable); + } + + $preferenceData->updateOrCreate(); + continue; + } + if (empty($preferenceData['cast'])) { $preferenceData['cast'] = Cast::STRING; } @@ -177,7 +197,7 @@ public static function initBulk(array $preferences, bool $nullable = false): voi $preferenceData['cast'] = serialize($preferenceData['cast']); // Ensure Defaults - $preferenceData = array_merge([ + $preferenceData = array_merge([ 'group' => 'general', 'default_value' => null, 'allowed_values' => null, @@ -186,9 +206,10 @@ public static function initBulk(array $preferences, bool $nullable = false): voi 'rule' => null, 'nullable' => false, ], $preferenceData); + $cleanPreferences[] = $preferenceData; } - Preference::upsert($preferences, ['name', 'group']); + Preference::upsert($cleanPreferences, ['name', 'group']); } /** @@ -203,6 +224,7 @@ public static function initBulk(array $preferences, bool $nullable = false): voi * @return int Returns the number of deleted preferences. * * @throws InvalidArgumentException if the preferences array is empty or if any preference lacks a required + * @throws InvalidStateException * 'name' field, or if the 'name' field does not implement PreferenceGroup. */ public static function deleteBulk(array $preferences): int @@ -213,6 +235,18 @@ public static function deleteBulk(array $preferences): int $query = Preference::query(); foreach ($preferences as $index => $preferenceData) { + + + if ($preferenceData instanceof BaseBuilder) { + if ($preferenceData->isStateSet(BaseBuilder::STATE_DELETED)) { + throw new InvalidStateException($preferenceData->getState() + , "The State should not be Deleted at this point, as its deleteBulk's responsibility"); + } + + $preferenceData->delete(); + continue; + } + if (empty($preferenceData['name']) || !($preferenceData['name'] instanceof PreferenceGroup)) { throw new InvalidArgumentException( sprintf("index: #%s name is required and must implement PreferenceGroup", $index) diff --git a/src/Factory/builders/BaseBuilder.php b/src/Factory/builders/BaseBuilder.php index 1c6e3cb..7b28363 100644 --- a/src/Factory/builders/BaseBuilder.php +++ b/src/Factory/builders/BaseBuilder.php @@ -3,32 +3,63 @@ namespace Matteoc99\LaravelPreference\Factory\builders; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Validation\ValidationException; use Matteoc99\LaravelPreference\Contracts\CastableEnum; use Matteoc99\LaravelPreference\Contracts\PreferenceGroup; use Matteoc99\LaravelPreference\Contracts\PreferencePolicy; +use Matteoc99\LaravelPreference\Exceptions\InvalidStateException; use Matteoc99\LaravelPreference\Models\Preference; +use Matteoc99\LaravelPreference\Traits\HasState; use Matteoc99\LaravelPreference\Utils\SerializeHelper; use Matteoc99\LaravelPreference\Utils\ValidationHelper; abstract class BaseBuilder { + use HasState; + protected Preference $preference; + const STATE_INITIALIZED = 1; + const STATE_CREATED = 2; + const STATE_DELETED = 4; + const STATE_NAME_SET = 8; + const STATE_CAST_SET = 16; + const STATE_POLICY_SET = 32; + const STATE_RULE_SET = 64; + const STATE_DEFAULT_SET = 128; + const STATE_DESCRIPTION_SET = 256; + const STATE_NULLABLE_SET = 512; + const STATE_ALLOWED_VALUES_SET = 1024; + + /** + * @throws InvalidStateException + */ public function __construct(PreferenceGroup $name, CastableEnum $cast) { $this->preference = new Preference(); - $this->withName($name)->withCast($cast)->nullable(false); + $this->addState(self::STATE_INITIALIZED); + + $this->withName($name)->withCast($cast); } + /** + * @throws InvalidStateException + */ private function withCast(CastableEnum $cast): static { + $this->addState(self::STATE_CAST_SET); $this->preference->cast = $cast; return $this; } + /** + * @throws InvalidStateException + */ private function withName(PreferenceGroup $name): static { + $this->addState(self::STATE_NAME_SET); + SerializeHelper::conformNameAndGroup($name, $group); $this->preference->name = $name; @@ -37,44 +68,100 @@ private function withName(PreferenceGroup $name): static } + /** + * @throws InvalidStateException + */ public function withPolicy(PreferencePolicy $policy): static { + $this->addState(self::STATE_POLICY_SET); $this->preference->policy = $policy; return $this; } + /** + * @throws InvalidStateException + */ public function withDefaultValue(mixed $value): static { + $this->addState(self::STATE_DEFAULT_SET); + $this->preference->default_value = $value; return $this; } + /** + * @throws InvalidStateException + */ public function withDescription(string $description): static { + $this->addState(self::STATE_DESCRIPTION_SET); + $this->preference->description = $description; return $this; } + /** + * @throws InvalidStateException + */ public function withRule(ValidationRule $rule): static { + $this->addState(self::STATE_RULE_SET); + $this->preference->rule = $rule; return $this; } + /** + * @throws InvalidStateException + */ public function nullable(bool $nullable = true) { + $this->addState(self::STATE_NULLABLE_SET); + $this->preference->nullable = $nullable; return $this; } + /** + * @deprecated no reason to use this over updateOrCreate, will be removed in v3.x + */ public function create(): Preference { return $this->updateOrCreate(); + + } + + /** + * @throws InvalidStateException + */ + public function delete(): int + { + + if (!$this->isStateSet(self::STATE_INITIALIZED) + || !$this->isStateSet(self::STATE_NAME_SET)) { + throw new InvalidStateException($this->getState(), "Initialize the builder before deleting the preference"); + } + + $this->addState(self::STATE_DELETED); + + + + return Preference::query() + ->where('group', $this->preference->group) + ->where('name', $this->preference->name) + ->delete(); + } + /** + * @throws InvalidStateException + * @throws ValidationException + */ public function updateOrCreate(): Preference { + $this->addState(self::STATE_CREATED); + ValidationHelper::validatePreference($this->preference); diff --git a/src/Factory/builders/ObjectPreferenceBuilder.php b/src/Factory/builders/ObjectPreferenceBuilder.php index 96f873e..0fff541 100644 --- a/src/Factory/builders/ObjectPreferenceBuilder.php +++ b/src/Factory/builders/ObjectPreferenceBuilder.php @@ -2,14 +2,20 @@ namespace Matteoc99\LaravelPreference\Factory\builders; +use Matteoc99\LaravelPreference\Exceptions\InvalidStateException; use Matteoc99\LaravelPreference\Utils\ValidationHelper; class ObjectPreferenceBuilder extends BaseBuilder { + /** + * @throws InvalidStateException + */ public function setAllowedClasses(...$classes): static { + $this->addState(self::STATE_ALLOWED_VALUES_SET); + ValidationHelper::validateAllowedClasses($this->preference->cast, $classes); diff --git a/src/Models/Preference.php b/src/Models/Preference.php index 37b7bf1..5319cf7 100644 --- a/src/Models/Preference.php +++ b/src/Models/Preference.php @@ -44,6 +44,10 @@ class Preference extends BaseModel 'allowed_values', ]; + protected $attributes = [ + 'nullable' => false, + ]; + protected $casts = [ 'created_at' => 'datetime', 'updated_at' => 'datetime', diff --git a/src/Traits/HasPreferences.php b/src/Traits/HasPreferences.php index 00a886c..f2759a4 100644 --- a/src/Traits/HasPreferences.php +++ b/src/Traits/HasPreferences.php @@ -120,6 +120,22 @@ public function removePreference(PreferenceGroup|Preference $preference): int return $this->userPreferences()->where('preference_id', $preference->id)->delete(); } + /** + * Reset the model to its original state + * Remove all user's preferences. + * + * + * @return int Number of deleted records. + * @throws AuthorizationException + */ + public function removeAllPreferences(): int + { + $this->authorize(PolicyAction::DELETE_ALL); + + return $this->userPreferences()->delete(); + } + + /** * Get all preferences for a user, optionally filtered by group. * @@ -141,6 +157,7 @@ public function getPreferences(string $group = null): Collection return $query->get(); } + /** * Validate existence of a preference and retrieve it. * diff --git a/src/Traits/HasState.php b/src/Traits/HasState.php new file mode 100644 index 0000000..b146ce7 --- /dev/null +++ b/src/Traits/HasState.php @@ -0,0 +1,62 @@ +isStateSet($state)) { + throw new InvalidStateException($this->getState(), "The model is already in the state: " . $this->getStateName($state)); + } + + $this->state |= $state; + } + + public function isStateSet($state): bool + { + return ($this->state & $state) === $state; + } + + /** + * @throws InvalidStateException + */ + protected function removeState($state): void + { + if (!$this->isStateSet($state)) { + throw new InvalidStateException($this->getState(), "The model is not in the state: " . $this->getStateName($state) . " and can not be removed. "); + } + $this->state &= ~$state; + } + + protected function getStateName($state): string + { + $reflection = new ReflectionClass($this); + $constants = $reflection->getConstants(); + + foreach ($constants as $name => $value) { + if ($state === $value) { + return $name; + } + } + + return 'Unknown State'; + } + + /** + * @return int + */ + public function getState(): int + { + return $this->state; + } + +} \ No newline at end of file diff --git a/tests/BuilderTest/BuilderStateTest.php b/tests/BuilderTest/BuilderStateTest.php new file mode 100644 index 0000000..5d5e363 --- /dev/null +++ b/tests/BuilderTest/BuilderStateTest.php @@ -0,0 +1,103 @@ +assertTrue($builder->isStateSet(BaseBuilder::STATE_INITIALIZED)); + + $this->assertTrue($builder->isStateSet(BaseBuilder::STATE_NAME_SET)); + $this->assertTrue($builder->isStateSet(BaseBuilder::STATE_CAST_SET)); + } + + /** @test */ + public function it_throws_exception_when_adding_same_state_twice() + { + $this->expectException(InvalidStateException::class); + try { + + PreferenceBuilder::init(General::LANGUAGE)->withDefaultValue("de")->withDefaultValue("en"); + } catch (InvalidStateException $e) { + $this->assertEquals(BaseBuilder::STATE_INITIALIZED + + BaseBuilder::STATE_NAME_SET + + BaseBuilder::STATE_CAST_SET + + BaseBuilder::STATE_DEFAULT_SET, $e->getState()); + throw $e; + } + } + + + /** @test */ + public function it_properly_adds_name_and_cast_state() + { + $builder = PreferenceBuilder::init(General::LANGUAGE); + $builder->withDefaultValue("en"); + $builder->withRule(new InRule("en", "it", "de")); + + $this->assertTrue($builder->isStateSet(BaseBuilder::STATE_DEFAULT_SET)); + $this->assertTrue($builder->isStateSet(BaseBuilder::STATE_RULE_SET)); + } + + /** @test */ + public function it_handles_nullable_state_correctly() + { + $builder = PreferenceBuilder::init(General::LANGUAGE); + $builder->nullable(); + + $this->assertTrue($builder->isStateSet(BaseBuilder::STATE_NULLABLE_SET)); + } + + /** @test */ + public function it_allows_adding_description_after_default_value() + { + $builder = PreferenceBuilder::init(General::LANGUAGE); + $builder->withDefaultValue("en")->withDescription("Default language setting"); + + $this->assertTrue($builder->isStateSet(BaseBuilder::STATE_DESCRIPTION_SET)); + } + + /** @test */ + public function it_creates_preference_properly_with_all_required_states() + { + $builder = PreferenceBuilder::init(General::LANGUAGE) + ->withDefaultValue("en") + ->withRule(new InRule("en", "it", "de")) + ->withDescription("Language settings"); + + $preference = $builder->updateOrCreate(); + $this->assertInstanceOf(Preference::class, $preference); + } + + /** @test */ + public function init_bulk_successfully_creates_preferences_with_mixed_input_types() + { + $preferences = [ + ['name' => General::CONFIG, 'cast' => Cast::ARRAY], + PreferenceBuilder::init(General::LANGUAGE)->withRule(new InRule("en", "it", "de")), + PreferenceBuilder::init(General::THEME)->withRule(new InRule("light", "dark")), + ]; + + PreferenceBuilder::initBulk($preferences); + + $this->assertDatabaseCount((new Preference())->getTable(), 3); + $this->assertDatabaseHas((new Preference())->getTable(), ['name' => General::LANGUAGE]); + + PreferenceBuilder::deleteBulk($preferences); + $this->assertDatabaseCount((new Preference())->getTable(), 0); + + } +} \ No newline at end of file diff --git a/tests/PreferenceBuilderBulkTest.php b/tests/BuilderTest/PreferenceBuilderBulkTest.php similarity index 95% rename from tests/PreferenceBuilderBulkTest.php rename to tests/BuilderTest/PreferenceBuilderBulkTest.php index c4361ac..dd4f4c9 100644 --- a/tests/PreferenceBuilderBulkTest.php +++ b/tests/BuilderTest/PreferenceBuilderBulkTest.php @@ -1,12 +1,13 @@ assertDatabaseCount((new Preference())->getTable(), 1); - $this->assertDatabaseHas((new Preference())->getTable(), ['name' => VideoPreferences::LANGUAGE]); + $this->assertDatabaseHas((new Preference())->getTable(), ['name' => VideoPreferences::LANGUAGE]); } /** @test */ @@ -127,13 +128,14 @@ public function init_bulk_handles_mixed_valid_and_invalid_preferences() $this->expectException(\InvalidArgumentException::class); PreferenceBuilder::initBulk($preferences); } + /** @test */ public function init_bulk_handles_deprecated_group() { $preferences = [ - ['name' => General::LANGUAGE, 'cast' => Cast::STRING,'group'=>"hi"], - ]; + ['name' => General::LANGUAGE, 'cast' => Cast::STRING, 'group' => "hi"], + ]; $this->expectException(\InvalidArgumentException::class); PreferenceBuilder::initBulk($preferences); @@ -150,7 +152,7 @@ public function init_bulk_with_all_options() PreferenceBuilder::initBulk($preferences); $this->assertDatabaseCount((new Preference())->getTable(), 2); - $this->assertEquals(true,$this->testUser->getPreference(General::LANGUAGE)); + $this->assertEquals(true, $this->testUser->getPreference(General::LANGUAGE)); PreferenceBuilder::deleteBulk($preferences); $this->assertDatabaseCount((new Preference())->getTable(), 0); diff --git a/tests/TestSubjects/Models/User.php b/tests/TestSubjects/Models/User.php index 84b836d..7f05d09 100644 --- a/tests/TestSubjects/Models/User.php +++ b/tests/TestSubjects/Models/User.php @@ -18,5 +18,4 @@ public function isUserAuthorized(?Authenticatable $user, PolicyAction $action): return $user?->id == $this->id; } - } \ No newline at end of file