diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/php-cs-fixer.yml index 2ec044836..96e9a2d0c 100644 --- a/.github/workflows/php-cs-fixer.yml +++ b/.github/workflows/php-cs-fixer.yml @@ -18,6 +18,6 @@ jobs: args: --config=.php-cs-fixer.dist.php --allow-risky=yes - name: Commit changes - uses: stefanzweifel/git-auto-commit-action@v4 + uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: Fix styling diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index 94df2c332..0cdea2336 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -21,7 +21,7 @@ jobs: release-notes: ${{ github.event.release.body }} - name: Commit updated CHANGELOG - uses: stefanzweifel/git-auto-commit-action@v4 + uses: stefanzweifel/git-auto-commit-action@v5 with: branch: main commit_message: Update CHANGELOG diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ab903245..1d5e69dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ All notable changes to `laravel-data` will be documented in this file. - Allow creating data objects using `from` without parameters - Add support for a Dto and Resource object +## 3.9.1 - 2023-10-12 + +- Add Declined and DeclinedIf validation attributes (#572) +- Add MacAddress validation attribute (#573) +- Add RequiredArrayKeys validation attribute (#574) +- Support Arrayable when casting to DataCollection (#577) +- Fetch attributes from parent classes to allow reusability (#581) +- Fix issue where non-set optional values would be transformed + ## 3.9.0 - 2023-09-15 - Fix an issue where computed values could not be set as null @@ -37,7 +46,6 @@ All notable changes to `laravel-data` will be documented in this file. - allow collection to be created passing null (#507) - add Ulid validation rule (#510) -add TARGET_PARAMETER to Attribute for improved Validation (#523) ->>>>>>> main ## 3.7.0 - 2023-07-05 diff --git a/docs/advanced-usage/validation-attributes.md b/docs/advanced-usage/validation-attributes.md index a231e8524..968f4aa33 100644 --- a/docs/advanced-usage/validation-attributes.md +++ b/docs/advanced-usage/validation-attributes.md @@ -216,6 +216,24 @@ public Carbon $closure; public Carbon $closure; ``` +## Declined + +[Docs](https://laravel.com/docs/9.x/validation#rule-declined) + +```php +#[Declined] +public bool $closure; +``` + +## DeclinedIf + +[Docs](https://laravel.com/docs/9.x/validation#rule-declined-if) + +```php +#[DeclinedIf('other_field', 'equals_this')] +public bool $closure; +``` + ## Different [Docs](https://laravel.com/docs/9.x/validation#rule-different) @@ -452,7 +470,7 @@ public string $closure; ## IPv4 -[Docs](https://laravel.com/docs/9.x/validation#rule-ipv4) +[Docs](https://laravel.com/docs/9.x/validation#ipv4) ```php #[IPv4] @@ -461,7 +479,7 @@ public string $closure; ## IPv6 -[Docs](https://laravel.com/docs/9.x/validation#rule-ipv6) +[Docs](https://laravel.com/docs/9.x/validation#ipv6) ```php #[IPv6] @@ -495,6 +513,15 @@ public int $closure; public int $closure; ``` +## MacAddress + +[Docs](https://laravel.com/docs/9.x/validation#rule-mac) + +```php +#[MacAddress] +public string $closure; +``` + ## Max [Docs](https://laravel.com/docs/9.x/validation#rule-max) @@ -759,6 +786,21 @@ public ?string $closure; public ?string $closure; ``` +## RequiredArrayKeys + +[Docs](https://laravel.com/docs/9.x/validation#rule-required-array-keys) + +```php +#[RequiredArrayKeys('a')] +public array $closure; + +#[RequiredArrayKeys(['a', 'b'])] +public array $closure; + +#[RequiredArrayKeys('a', 'b')] +public array $closure; +``` + ## Rule ```php diff --git a/src/Attributes/Validation/Declined.php b/src/Attributes/Validation/Declined.php new file mode 100644 index 000000000..878db7445 --- /dev/null +++ b/src/Attributes/Validation/Declined.php @@ -0,0 +1,19 @@ +field = $this->parseFieldReference($field); + } + + public static function keyword(): string + { + return 'declined_if'; + } + + public function parameters(): array + { + return [$this->field, $this->value]; + } + + public static function create(string ...$parameters): static + { + return parent::create( + $parameters[0], + self::parseBooleanValue($parameters[1]), + ); + } +} diff --git a/src/Attributes/Validation/MacAddress.php b/src/Attributes/Validation/MacAddress.php new file mode 100644 index 000000000..96c567704 --- /dev/null +++ b/src/Attributes/Validation/MacAddress.php @@ -0,0 +1,19 @@ +values = Arr::flatten($values); + } + + public static function keyword(): string + { + return 'required_array_keys'; + } + + public function parameters(): array + { + return [$this->values]; + } +} diff --git a/src/Support/DataClass.php b/src/Support/DataClass.php index 1264e650e..7020b780b 100644 --- a/src/Support/DataClass.php +++ b/src/Support/DataClass.php @@ -54,9 +54,7 @@ public function __construct( public static function create(ReflectionClass $class): self { - $attributes = collect($class->getAttributes()) - ->filter(fn (ReflectionAttribute $reflectionAttribute) => class_exists($reflectionAttribute->getName())) - ->map(fn (ReflectionAttribute $reflectionAttribute) => $reflectionAttribute->newInstance()); + $attributes = static::resolveAttributes($class); $methods = collect($class->getMethods()); @@ -98,6 +96,22 @@ public static function create(ReflectionClass $class): self ); } + protected static function resolveAttributes( + ReflectionClass $class + ): Collection { + $attributes = collect($class->getAttributes()) + ->filter(fn (ReflectionAttribute $reflectionAttribute) => class_exists($reflectionAttribute->getName())) + ->map(fn (ReflectionAttribute $reflectionAttribute) => $reflectionAttribute->newInstance()); + + $parent = $class->getParentClass(); + + if ($parent !== false) { + $attributes = $attributes->merge(static::resolveAttributes($parent)); + } + + return $attributes; + } + protected static function resolveMethods( ReflectionClass $reflectionClass, ): Collection { diff --git a/src/Support/EloquentCasts/DataCollectionEloquentCast.php b/src/Support/EloquentCasts/DataCollectionEloquentCast.php index d0e35b53f..4a0af6489 100644 --- a/src/Support/EloquentCasts/DataCollectionEloquentCast.php +++ b/src/Support/EloquentCasts/DataCollectionEloquentCast.php @@ -3,6 +3,7 @@ namespace Spatie\LaravelData\Support\EloquentCasts; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; +use Illuminate\Contracts\Support\Arrayable; use Spatie\LaravelData\Contracts\BaseData; use Spatie\LaravelData\Contracts\BaseDataCollectable; use Spatie\LaravelData\Contracts\TransformableData; @@ -48,6 +49,10 @@ public function set($model, string $key, $value, array $attributes): ?string $value = $value->all(); } + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } + if (! is_array($value)) { throw CannotCastData::shouldBeArray($model::class, $key); } diff --git a/src/Support/Validation/ValidationRuleFactory.php b/src/Support/Validation/ValidationRuleFactory.php index ebd06162a..4bc1f5b6e 100644 --- a/src/Support/Validation/ValidationRuleFactory.php +++ b/src/Support/Validation/ValidationRuleFactory.php @@ -23,6 +23,8 @@ use Spatie\LaravelData\Attributes\Validation\Date; use Spatie\LaravelData\Attributes\Validation\DateEquals; use Spatie\LaravelData\Attributes\Validation\DateFormat; +use Spatie\LaravelData\Attributes\Validation\Declined; +use Spatie\LaravelData\Attributes\Validation\DeclinedIf; use Spatie\LaravelData\Attributes\Validation\Different; use Spatie\LaravelData\Attributes\Validation\Digits; use Spatie\LaravelData\Attributes\Validation\DigitsBetween; @@ -49,6 +51,7 @@ use Spatie\LaravelData\Attributes\Validation\Json; use Spatie\LaravelData\Attributes\Validation\LessThan; use Spatie\LaravelData\Attributes\Validation\LessThanOrEqualTo; +use Spatie\LaravelData\Attributes\Validation\MacAddress; use Spatie\LaravelData\Attributes\Validation\Max; use Spatie\LaravelData\Attributes\Validation\Mimes; use Spatie\LaravelData\Attributes\Validation\MimeTypes; @@ -66,6 +69,7 @@ use Spatie\LaravelData\Attributes\Validation\Prohibits; use Spatie\LaravelData\Attributes\Validation\Regex; use Spatie\LaravelData\Attributes\Validation\Required; +use Spatie\LaravelData\Attributes\Validation\RequiredArrayKeys; use Spatie\LaravelData\Attributes\Validation\RequiredIf; use Spatie\LaravelData\Attributes\Validation\RequiredUnless; use Spatie\LaravelData\Attributes\Validation\RequiredWith; @@ -122,6 +126,8 @@ protected function mapping(): array Date::keyword() => Date::class, DateEquals::keyword() => DateEquals::class, DateFormat::keyword() => DateFormat::class, + Declined::keyword() => Declined::class, + DeclinedIf::keyword() => DeclinedIf::class, Different::keyword() => Different::class, Digits::keyword() => Digits::class, DigitsBetween::keyword() => DigitsBetween::class, @@ -148,6 +154,7 @@ protected function mapping(): array Json::keyword() => Json::class, LessThan::keyword() => LessThan::class, LessThanOrEqualTo::keyword() => LessThanOrEqualTo::class, + MacAddress::keyword() => MacAddress::class, Max::keyword() => Max::class, Mimes::keyword() => Mimes::class, MimeTypes::keyword() => MimeTypes::class, @@ -165,6 +172,7 @@ protected function mapping(): array Prohibits::keyword() => Prohibits::class, Regex::keyword() => Regex::class, Required::keyword() => Required::class, + RequiredArrayKeys::keyword() => RequiredArrayKeys::class, RequiredIf::keyword() => RequiredIf::class, RequiredUnless::keyword() => RequiredUnless::class, RequiredWith::keyword() => RequiredWith::class, diff --git a/src/Transformers/DataTransformer.php b/src/Transformers/DataTransformer.php new file mode 100644 index 000000000..e69de29bb diff --git a/tests/Datasets/Attributes/RulesDataset.php b/tests/Datasets/Attributes/RulesDataset.php index d91fc8f9c..ef09fa9ef 100644 --- a/tests/Datasets/Attributes/RulesDataset.php +++ b/tests/Datasets/Attributes/RulesDataset.php @@ -26,6 +26,8 @@ use Spatie\LaravelData\Attributes\Validation\Date; use Spatie\LaravelData\Attributes\Validation\DateEquals; use Spatie\LaravelData\Attributes\Validation\DateFormat; +use Spatie\LaravelData\Attributes\Validation\Declined; +use Spatie\LaravelData\Attributes\Validation\DeclinedIf; use Spatie\LaravelData\Attributes\Validation\Different; use Spatie\LaravelData\Attributes\Validation\Digits; use Spatie\LaravelData\Attributes\Validation\DigitsBetween; @@ -52,6 +54,7 @@ use Spatie\LaravelData\Attributes\Validation\Json; use Spatie\LaravelData\Attributes\Validation\LessThan; use Spatie\LaravelData\Attributes\Validation\LessThanOrEqualTo; +use Spatie\LaravelData\Attributes\Validation\MacAddress; use Spatie\LaravelData\Attributes\Validation\Max; use Spatie\LaravelData\Attributes\Validation\Mimes; use Spatie\LaravelData\Attributes\Validation\MimeTypes; @@ -69,6 +72,7 @@ use Spatie\LaravelData\Attributes\Validation\Prohibits; use Spatie\LaravelData\Attributes\Validation\Regex; use Spatie\LaravelData\Attributes\Validation\Required; +use Spatie\LaravelData\Attributes\Validation\RequiredArrayKeys; use Spatie\LaravelData\Attributes\Validation\RequiredIf; use Spatie\LaravelData\Attributes\Validation\RequiredUnless; use Spatie\LaravelData\Attributes\Validation\RequiredWith; @@ -115,6 +119,7 @@ function fixature( yield from dateEqualsAttributes(); yield from dimensionsAttributes(); yield from distinctAttributes(); + yield from declinedIfAttributes(); yield from emailAttributes(); yield from endsWithAttributes(); yield from existsAttributes(); @@ -126,6 +131,7 @@ function fixature( yield from prohibitedIfAttributes(); yield from prohibitedUnlessAttributes(); yield from prohibitsAttributes(); + yield from requiredArrayKeysAttributes(); yield from requiredIfAttributes(); yield from requiredUnlessAttributes(); yield from requiredWithAttributes(); @@ -185,6 +191,12 @@ function fixature( expected: 'date_format:Y-m-d', ); + yield fixature( + attribute: new Declined(), + expected: 'declined', + ); + + yield fixature( attribute: new Different('field'), expected: 'different:field', @@ -288,6 +300,11 @@ function fixature( expected: 'lte:field', ); + yield fixature( + attribute: new MacAddress(), + expected: 'mac_address', + ); + yield fixature( attribute: new Max(10), expected: 'max:10', @@ -586,6 +603,35 @@ function distinctAttributes(): Generator ); } +function declinedIfAttributes(): Generator +{ + yield fixature( + attribute: new DeclinedIf('value', 'string'), + expected: 'declined_if:value,string', + ); + + yield fixature( + attribute: new DeclinedIf('value', true), + expected: 'declined_if:value,true', + ); + + yield fixature( + attribute: new DeclinedIf('value', 42), + expected: 'declined_if:value,42', + ); + + yield fixature( + attribute: new DeclinedIf('value', 3.14), + expected: 'declined_if:value,3.14', + ); + + yield fixature( + attribute: new DeclinedIf('value', DummyBackedEnum::FOO), + expected: 'declined_if:value,foo', + expectCreatedAttribute: new DeclinedIf('value', 'foo') + ); +} + function emailAttributes(): Generator { yield fixature( @@ -845,6 +891,24 @@ function prohibitsAttributes(): Generator ); } +function requiredArrayKeysAttributes(): Generator +{ + yield fixature( + attribute: new RequiredArrayKeys('x'), + expected: 'required_array_keys:x', + ); + + yield fixature( + attribute: new RequiredArrayKeys(['x', 'y']), + expected: 'required_array_keys:x,y', + ); + + yield fixature( + attribute: new RequiredArrayKeys('x', 'y'), + expected: 'required_array_keys:x,y', + ); +} + function requiredIfAttributes(): Generator { yield fixature( diff --git a/tests/Support/DataClassTest.php b/tests/Support/DataClassTest.php index fed90479b..f0b1389bc 100644 --- a/tests/Support/DataClassTest.php +++ b/tests/Support/DataClassTest.php @@ -1,11 +1,17 @@ mappedDataObjects->toBeEmpty(); }); +it('resolves parent attributes', function () { + #[MapName(SnakeCaseMapper::class)] + #[WithTransformer(DateTimeInterfaceTransformer::class, 'd-m-Y')] + #[WithCast(DateTimeInterfaceCast::class, format: 'Y-m-d')] + class TestRecursiveAttributesParentData extends Data + { + } + + class TestRecursiveAttributesChildData extends TestRecursiveAttributesParentData + { + public function __construct( + public DateTimeInterface $dateTime + ) { + } + } + + $dataClass = DataClass::create(new ReflectionClass(TestRecursiveAttributesChildData::class)); + + expect($dataClass->attributes) + ->toHaveCount(3) + ->contains(fn ($attribute) => $attribute instanceof MapName)->toBeTrue() + ->contains(fn ($attribute) => $attribute instanceof WithTransformer)->toBeTrue() + ->contains(fn ($attribute) => $attribute instanceof WithCast)->toBeTrue(); +}); + #[\JetBrains\PhpStorm\Immutable] class PhpStormClassAttributeData extends Data { diff --git a/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php b/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php index c72ee1a41..3b349e87b 100644 --- a/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php +++ b/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php @@ -49,6 +49,22 @@ ]); }); +it('can save a data object as an array from a collection', function () { + DummyModelWithCasts::create([ + 'data_collection' => collect([ + ['string' => 'Hello'], + ['string' => 'World'], + ]), + ]); + + assertDatabaseHas(DummyModelWithCasts::class, [ + 'data_collection' => json_encode([ + ['string' => 'Hello'], + ['string' => 'World'], + ]), + ]); +}); + it('can load a data object', function () { DB::table('dummy_model_with_casts')->insert([ 'data_collection' => json_encode([ diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index 9917cf620..4a7d8cf49 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -2379,3 +2379,13 @@ public static function rules(ValidationContext $context): array ->assertOk(['success' => true, 'id' => 1]) ->assertErrors(['success' => true]); })->skip('V4: The rule inferrers need to be rewritten/removed for this, we need to first add attribute rules and then decide require stuff'); + +it('will not transform non set optional properties ', function () { + $dataClass = new class () extends Data { + public array|Optional $optional; + }; + + expect($dataClass::from([])->toArray())->toBeEmpty(); + expect($dataClass::from()->toArray())->toBeEmpty(); + expect((new ($dataClass::class))->toArray())->toBeEmpty(); +});