diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fee533..e590ccd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,15 @@ name: CI on: [pull_request] jobs: tests: - name: Laravel Dt0 (PHP ${{ matrix.php-versions }}) + name: Laravel Dt0 (PHP ${{ matrix.php-versions }} / Orchestra ${{ matrix.orchestra-versions }}) runs-on: ubuntu-latest strategy: matrix: php-versions: [ '8.1', '8.2' ] + orchestra-versions: [ '8.0', '9.0' ] + exclude: + - php-versions: 8.1 + orchestra-versions: 9.0 steps: - name: Checkout uses: actions/checkout@v3 @@ -31,6 +35,12 @@ jobs: - name: Remove composer.lock run: rm -f composer.lock + - name: Remove pint dependency + run: composer remove "laravel/pint" --dev --no-update + + - name: Install Orchestra ${{ matrix.orchestra-versions }} + run: composer require "orchestra/testbench:^${{ matrix.orchestra-versions }}" --dev --no-update + - name: Install Composer dependencies run: composer install --no-progress --prefer-dist --optimize-autoloader diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 531f8d8..ac17596 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -29,8 +29,8 @@ jobs: uses: actions/cache@v3 with: path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- + key: 8.1-composer-${{ hashFiles('**/composer.json') }} + restore-keys: 8.1-composer- - name: Remove composer.lock run: rm -f composer.lock @@ -44,9 +44,7 @@ jobs: - name: Compute Coverage run: vendor/bin/phpunit --coverage-clover ./coverage.xml - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4.0.1 with: - files: ./coverage.xml - flags: unittests - name: codecov-Dt0 + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index 2fc7c65..78e4523 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,145 @@ # Laravel Dt0 -[![CI](https://github.com/fab2s/laravel-dt0/actions/workflows/ci.yml/badge.svg)](https://github.com/fab2s/laravel-dt0/actions/workflows/ci.yml) [![CI](https://github.com/fab2s/laravel-dt0/actions/workflows/ci.yml/badge.svg)](https://github.com/fab2s/laravel-dt0/actions/workflows/ci.yml) +[![CI](https://github.com/fab2s/laravel-dt0/actions/workflows/ci.yml/badge.svg)](https://github.com/fab2s/laravel-dt0/actions/workflows/ci.yml) [![QA](https://github.com/fab2s/laravel-dt0/actions/workflows/qa.yml/badge.svg)](https://github.com/fab2s/laravel-dt0/actions/workflows/qa.yml) [![codecov](https://codecov.io/gh/fab2s/laravel-dt0/graph/badge.svg?token=YE6AYEDA64)](https://codecov.io/gh/fab2s/laravel-dt0) [![Latest Stable Version](http://poser.pugx.org/fab2s/laravel-dt0/v)](https://packagist.org/packages/fab2s/laravel-dt0) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com) [![License](http://poser.pugx.org/fab2s/dt0/license)](https://packagist.org/packages/fab2s/dt0) -Laravel support for [Dt0](https://github.com/fab2s/dt0), a DTO (_Data-Transport-Object_) PHP implementation than can both secure mutability and implement convenient ways to take control over input and output in various format. +[Laravel](https://laravel.com/) support for [fab2s/dt0](https://github.com/fab2s/dt0), a [DTO](https://en.wikipedia.org/wiki/Data_transfer_object) (_Data-Transport-Object_) PHP implementation that can both secure mutability and implement convenient ways to take control over input and output in various formats. ## Installation `Dt0` can be installed using composer: ```shell -composer require "fab2s/dt0" +composer require "fab2s/laravel-dt0" ``` -Once done, you can start playing : +## Usage -```php +`Laravel Dt0` only adds Validation implementation and model attribute casting to `Dt0`. All other features will work exactly the same. Have a look at [`Dt0`](https://github.com/fab2s/dt0) to find out more. + +## Validation + +`Laravel Dt0` is able to leverage the full power of Laravel validation on each of its public properties. The validation is performed on the input data prior to any property casting or instantiation. + +`Laravel Dt0` comes with a [`Validator`](./src/Validator.php) out of the box that can leverage the full power of [laravel validation](https://laravel.com/docs/master/validation). + +To use it on any `Dt0`, just add the [`Validate`](https://github.com/fab2s/dt0/blob/main/src/Attribute/Validate.php) class attribute : + +````php +#[Validate(Validator::class)] // same as #[Validate(new Validator)] +class MyDt0 extends Dt0 { + // ... +} +```` + +### Rules can be added in three ways: + +- using the second argument of the [`Validate`](https://github.com/fab2s/dt0/blob/main/src/Attribute/Validate.php) **class attribute**: + + ````php + use fab2s\Dt0\Attribute\Rule; + use fab2s\Dt0\Attribute\Rules; + use fab2s\Dt0\Attribute\Validate; + use fab2s\Dt0\Laravel\Dt0; + use fab2s\Dt0\Laravel\Validator; + + #[Validate( + Validator::class, + new Rules( + propName: new Rule('string|size:2'), + // ... + ), + )] + class MyDt0 extends Dt0 { + public readonly string $propName; + } + ```` + +- using the [`Rules`](https://github.com/fab2s/dt0/blob/main/src/Attribute/Rules.php) **class attribute**: + + ````php + use fab2s\Dt0\Attribute\Rule; + use fab2s\Dt0\Attribute\Rules; + use fab2s\Dt0\Attribute\Validate; + use fab2s\Dt0\Laravel\Dt0; + use fab2s\Dt0\Laravel\Validator; + + #[Validate(Validator::class)] + #[Rules( + propName: new Rule(['required', 'string', 'size:2']), + // ... + )] + class MyDt0 extends Dt0 { + public readonly string $propName; + } + ```` + +- using the [`Rule`](https://github.com/fab2s/dt0/blob/main/src/Attribute/Rule.php) **property attribute**: + + ````php + use fab2s\Dt0\Attribute\Rule; + use fab2s\Dt0\Attribute\Rules; + use fab2s\Dt0\Attribute\Validate; + use fab2s\Dt0\Laravel\Dt0; + use fab2s\Dt0\Laravel\Validator; + use fab2s\Dt0\Laravel\Tests\Artifacts\Rules\Lowercase; + + #[Validate(Validator::class)] + class MyDt0 extends Dt0 { + #[Rule(new Lowercase)] // or any custom rule instance + public readonly string $propName; + } + ```` + +Combo of the above three are permitted as illustrated in [`ValidatableDt0`](./tests/Artifacts/ValidatableDt0.php). +> In case of redundancy, priority will be first in `Validate`, `Rules` then `Rule`. +> Dt0 has no opinion of the method used to define rules. They will all perform the same as they are compiled once per process and kept ready for any reuse. + +Validation is performed using `withValidation` method: + +```php // either get a Dt0 instance or a ValidationException -$dt0 = SomeValidatableDt0::withValidation(...\Illuminate\Http\Request::all()); +$dt0 = SomeValidatableDt0::withValidation(...Request::all()); ``` +## Model Attribute casting + +Should you want to use a `Dt0` as a Laravel Model attribute, you can directly cast it as your `Dt0` thanks to the generic cast [`Dt0Cast`](./src/Casts/Dt0Cast.php). + +Only requirement is for your Dt0 to extend [`fab2s\Dt0\Laravel\Dt0`](./src/Dt0.php) or to extend [`fab2s\Dt0\Dt0`](https://github.com/fab2s/dt0/blob/main/src/Dt0.php) _and_ use [`fab2s\Dt0\Laravel\LaravelDt0Trait`](./src/LaravelDt0Trait.php). + +````php +use Illuminate\Database\Eloquent\Model; + +class SomeModel extends Model +{ + protected $casts = [ + 'some_dt0' => SomeDt0::class, + 'some_nullable_dt0' => SomeNullableDt0::class.':nullable', + ]; +} + +$model = new SomeModel; + +$model->some_dt0 = '{"field":"value"}'; +// or +$model->some_dt0 = ['field' => 'value']; +// or +$model->some_dt0 = SomeDt0::from(['field' => 'value']); + +// then +$model->some_dt0->equals(SomeDt0::from('{"field":"value"}')); // true + +$model->some_dt0 = null; // throws a NotNullableException +$model->some_nullable_dt0 = null; // works + +// can thus be tried +$model->some_nullable_dt0 = SomeNullableDt0::tryFrom($anyInput); +```` + ## Requirements -`Dt0` is tested against php 8.1 and 8.2 +`Dt0` is tested against php 8.1 and 8.2 and Laravel 10 / 11 ## Contributing diff --git a/composer.json b/composer.json index 2e71749..dc38cba 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ ], "require": { "php": "^8.1", - "fab2s/dt0": "*", + "fab2s/dt0": "v0.x-dev", "illuminate/translation": "^10.0|^11.0", "illuminate/validation": "^10.0|^11.0" }, diff --git a/src/Caster/CollectionOfCaster.php b/src/Caster/CollectionOfCaster.php new file mode 100644 index 0000000..d9d69c1 --- /dev/null +++ b/src/Caster/CollectionOfCaster.php @@ -0,0 +1,69 @@ +|ScalarType|string */ + public readonly ScalarType|string $type, + ) { + if (is_string($type)) { + $logicalType = match (true) { + is_subclass_of($type, Dt0::class) => ArrayType::DT0, + is_subclass_of($type, UnitEnum::class) => ArrayType::ENUM, + default => ScalarType::tryFrom($type), + }; + } else { + $logicalType = $type; + } + + if (! $logicalType) { + throw new CasterException('[' . Dt0::classBasename(static::class) . "] $type is not an ArrayType nor a ScalarType"); + } + + $this->logicalType = $logicalType; + $this->scalarTypeCaster = $this->logicalType instanceof ScalarType ? new ScalarTypeCaster($this->logicalType) : null; + } + + public function cast(mixed $value): ?Collection + { + if (! is_iterable($value)) { + return null; + } + + $result = Collection::make(); + + foreach ($value as $item) { + $result->push(match ($this->logicalType) { + ArrayType::DT0 => $this->type::tryFrom($item), + ArrayType::ENUM => Property::tryEnum($this->type, $item), + default => $this->scalarTypeCaster->cast($item), + }); + } + + return $result; + } +} diff --git a/src/Casts/Dt0Cast.php b/src/Casts/Dt0Cast.php new file mode 100644 index 0000000..bcd8505 --- /dev/null +++ b/src/Casts/Dt0Cast.php @@ -0,0 +1,79 @@ + + */ + protected string $dt0Class; + protected bool $isNullable = false; + + /** + * @param class-string $enumClass + * @param string[] ...$options + */ + public function __construct(string $enumClass, ...$options) + { + $this->dt0Class = $enumClass; + + $this->isNullable = in_array('nullable', $options); + } + + /** + * Cast the given value. + * + * @param Model $model + * + * @throws NotNullableException + * @throws JsonException + * @throws Dt0Exception + */ + public function get($model, string $key, $value, array $attributes): ?Dt0 + { + return $this->resolve($model, $key, $value); + } + + /** + * Prepare the given value for storage. + * + * @param Model $model + * + * @throws Dt0Exception + * @throws NotNullableException + * @throws JsonException + */ + public function set($model, string $key, $value, array $attributes): ?string + { + return $this->resolve($model, $key, $value)?->toJson(); + } + + /** + * @throws Dt0Exception + * @throws NotNullableException + * @throws JsonException + */ + protected function resolve(Model $model, string $key, mixed $value): ?Dt0 + { + if ($value === null) { + return $this->isNullable ? null : throw NotNullableException::make($key, $model); + } + + return $this->dt0Class::from($value); + } +} diff --git a/src/Dt0.php b/src/Dt0.php new file mode 100644 index 0000000..4ffe4d3 --- /dev/null +++ b/src/Dt0.php @@ -0,0 +1,19 @@ +setContext([ + 'model' => $modelClass, + 'data' => $model->toArray(), + ]) + ; + } +} diff --git a/src/LaravelDt0Trait.php b/src/LaravelDt0Trait.php new file mode 100644 index 0000000..01a8d62 --- /dev/null +++ b/src/LaravelDt0Trait.php @@ -0,0 +1,21 @@ + DumbDt0::class, + 'some_nullable_dt0' => DumbDt0::class . ':nullable', + ]; +} diff --git a/tests/Artifacts/DumbDt0.php b/tests/Artifacts/DumbDt0.php new file mode 100644 index 0000000..6ae60cd --- /dev/null +++ b/tests/Artifacts/DumbDt0.php @@ -0,0 +1,19 @@ +cast($value); + + $this->assertSame(json_encode($expected), json_encode($caster->cast($value))); + } + + public function test_exception(): void + { + $this->expectException(CasterException::class); + new CollectionOfCaster('NotAType'); + } + + public static function castProvider(): array + { + return [ + [ + 'type' => DumbDt0::class, + 'value' => [ + DumbDt0::make(prop1: 'ONE', prop2: 'ONE', prop3: 'ONE'), + ['prop1' => 'TWO', 'prop2' => 'TWO', 'prop3' => 'TWO'], + '{"prop1":"three","prop2":"three","prop3":"three"}', + ], + 'expected' => collect([ + DumbDt0::make(prop1: 'ONE', prop2: 'ONE', prop3: 'ONE'), + DumbDt0::make(prop1: 'TWO', prop2: 'TWO', prop3: 'TWO'), + DumbDt0::make(prop1: 'three', prop2: 'three', prop3: 'three'), + ]), + ], + [ + 'type' => 'string', + 'value' => collect([ + 'ONE', + 'TWO', + 'three', + ]), + 'expected' => collect([ + 'ONE', + 'TWO', + 'three', + ]), + ], + [ + 'type' => 'int', + 'value' => [ + null, + '42', + 42.42, + '1337.1337', + ], + 'expected' => collect([ + 0, + 42, + 42, + 1337, + ]), + ], + [ + 'type' => ScalarType::bool, + 'value' => null, + 'expected' => null, + ], + ]; + } +} diff --git a/tests/Casts/Dt0CastTest.php b/tests/Casts/Dt0CastTest.php new file mode 100644 index 0000000..64fccc7 --- /dev/null +++ b/tests/Casts/Dt0CastTest.php @@ -0,0 +1,152 @@ + 'prop1', + 'prop2' => 'prop1', + 'prop3' => 'prop1', + ]; + + $model = new CastModel; + $model->some_dt0 = $input; + $this->assertInstanceOf(DumbDt0::class, $model->some_dt0); + + $model->some_nullable_dt0 = json_encode($input); + $this->assertInstanceOf(DumbDt0::class, $model->some_nullable_dt0); + + $model->some_dt0 = $model->some_nullable_dt0; + $this->assertInstanceOf(DumbDt0::class, $model->some_dt0); + + $model->some_nullable_dt0 = null; + $this->assertNull($model->some_nullable_dt0); + + $this->expectException(NotNullableException::class); + $model->some_dt0 = null; + } + + /** + * @param DumbDt0|class-string|null $expected + * + * @throws Dt0Exception + * @throws NotNullableException + * @throws JsonException + */ + #[DataProvider('castProvider')] + public function test_dt0_cast_get( + Dt0|string|array|null $value, + DumbDt0|string|null $expected, + array $options = [], + ): void { + $cast = new Dt0cast(DumbDt0::class, ...$options); + + switch (true) { + case is_object($expected): + $this->assertTrue($expected->equal($cast->get(new CastModel, 'key', $value, []))); + break; + case is_string($expected): + $this->expectException(NotNullableException::class); + $cast->get(new CastModel, 'key', $value, []); + break; + case $expected === null: + $this->assertNull($cast->get(new CastModel, 'key', $value, [])); + break; + } + } + + /** + * @param DumbDt0|class-string|null $expected + * + * @throws Dt0Exception + * @throws NotNullableException + * @throws JsonException + */ + #[DataProvider('castProvider')] + public function test_dt0_cast_set( + Dt0|string|array|null $value, + DumbDt0|string|null $expected, + array $options = [], + ): void { + $cast = new Dt0cast(DumbDt0::class, ...$options); + + switch (true) { + case is_object($expected): + $this->assertSame($expected->toJson(), $cast->set(new CastModel, 'key', $value, [])); + break; + case is_string($expected): + $this->expectException(NotNullableException::class); + $cast->set(new CastModel, 'key', $value, []); + break; + case $expected === null: + $this->assertSame(null, $cast->set(new CastModel, 'key', $value, [])); + break; + } + } + + /** + * @throws Dt0Exception + * @throws JsonException + */ + public static function castProvider(): array + { + $input = [ + 'prop1' => 'prop1', + 'prop2' => 'prop2', + 'prop3' => 'prop3', + ]; + + $instance = DumbDt0::fromArray($input); + + return [ + [ + 'value' => null, + 'expected' => null, + 'options' => ['nullable'], + ], + [ + 'value' => $input, + 'expected' => $instance, + 'options' => ['nullable'], + ], + [ + 'value' => $input, + 'expected' => $instance, + ], + [ + 'value' => null, + 'expected' => NotNullableException::class, + ], + [ + 'value' => json_encode($input), + 'expected' => $instance, + 'options' => ['nullable'], + ], + [ + 'value' => $instance, + 'expected' => $instance, + 'options' => ['nullable'], + ], + ]; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..41cf411 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,20 @@ +