From 0253887cbcc6a91da4b0300073b7362a84497c7c Mon Sep 17 00:00:00 2001 From: fab2s Date: Sun, 28 Apr 2024 14:13:57 +0200 Subject: [PATCH] Tests --- README.md | 65 ++++++++++++++++++++++++++- src/Attribute/Cast.php | 17 ++++++- src/Attribute/Validate.php | 10 ++++- src/Exception/AttributeException.php | 14 ++++++ src/Type/Types.php | 3 ++ tests/Artifacts/DummyValidatedDt0.php | 33 ++++++++++++++ tests/Artifacts/NoOpValidator.php | 30 +++++++++++++ tests/Artifacts/TypedDt0.php | 2 + tests/Attribute/CastTest.php | 34 ++++++++++++++ tests/Attribute/CastsTest.php | 33 ++++++++++++++ tests/Attribute/RuleTest.php | 24 ++++++++++ tests/Attribute/RulesTest.php | 36 +++++++++++++++ tests/Attribute/ValidateTest.php | 30 +++++++++++++ tests/Dt0Test.php | 12 +++++ tests/Property/PropertyTest.php | 18 ++++++++ tests/Type/TypeTest.php | 13 ++++++ 16 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 src/Exception/AttributeException.php create mode 100644 tests/Artifacts/DummyValidatedDt0.php create mode 100644 tests/Artifacts/NoOpValidator.php create mode 100644 tests/Attribute/CastTest.php create mode 100644 tests/Attribute/CastsTest.php create mode 100644 tests/Attribute/RuleTest.php create mode 100644 tests/Attribute/RulesTest.php create mode 100644 tests/Attribute/ValidateTest.php diff --git a/README.md b/README.md index fea0069..1a1ce24 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,18 @@ The logic behind the scene is compiled once per process for faster reuse (single `Dt0` achieves full immutability when it hydrates `readonly` properties. As a best practice, all of your `Dt0`'s _should_ only use `public readonly` properties as part of their public interfaces. +## But why another DTO package + +It is clear that there are many DTO packages available already, with some really good ones. But none of them (so far) made it to handle full immutability. + +Mutable DTOs, with `writeable public properties`, kinda missed the purpose of providing with trust that no _accidental_ property update occurred and the peace of mind that comes with it. + +It also seems to be a good practice to promote _some thinking_ by design when you would find yourself in the need to update a DTO in any way, instead of just allowing it in a way that just _seem_ to be ok with the implementation. + +Some could argue that no one can prevent Dt0 swapping with new instances, but since you can track [object ids](https://www.php.net/manual/en/function.spl-object-id.php) when it matters, you can actually achieve complete integrity, being just impossible with other solutions. + +Should the need for even more insurance arise, you can easily add a `public readonly property` to store a cryptographic hash based on input values to sign each of your `Dt0`s and use it to make sure that nothing wrong happened. + ## Laravel [Laravel](https://laravel.com/) users may enjoy [Laravel Dt0](https://github.com/fab2s/laravel-dt0) adding proper supports for `Dt0`'s with Dt0 validation and model attribute casting. @@ -92,12 +104,49 @@ $updated->readOnlyProp; // $anotherValue `Cast` is used to define how to handle a property as a **property attribute** and `Casts` is used to set many `Cast` at once as a **class attribute**. +### Casts can be added in two ways: + +- using the [`Casts`](./src/Attribute/Casts.php) **class attribute**: + ````php + use fab2s\Dt0\Attribute\Casts; + use fab2s\Dt0\Attribute\Cast; + use fab2s\Dt0\Dt0; + + #[Casts( + new Cast(default: 'defaultFromCast', propName: 'prop1'), + // same as + prop1: new Cast(default: 'defaultFromCast'), + // ... + )] + class MyDt0 extends Dt0 { + public readonly string $prop1; + } + ```` + +- using the [`Cast`](./src/Attribute/Cast.php) **property attribute**: + ````php + use fab2s\Dt0\Attribute\Casts; + use fab2s\Dt0\Attribute\Cast; + use fab2s\Dt0\Dt0; + + class MyDt0 extends Dt0 { + #[Cast(default: 'defaultFromCast')] + public readonly string $prop1; + } + ```` + +Combo of the above two are permitted as illustrated in [`DefaultDt0`](./tests/Artifacts/DefaultDt0.php). + > In case of redundancy, priority will be first in `Casts` then `Cast`. > Dt0 has no opinion of the method used to define Casts. They will all perform the same as they are compiled once per process and kept ready for any reuse. +### Available Casters + `Dt0` comes with several [Casters](./src/Caster) ready to use. Writing your own is as easy as implementing the [`CasterInterface`](./src/Caster/CasterInterface.php) -**Available Caster documentation:** [Casters](./docs/casters.md) +They are documented in [**Casters Documentation**](./docs/casters.md) + +### Usage `Dt0` has full support out of the box without any `Caster` for [Enums](https://www.php.net/manual/en/language.types.enumerations.php) including [UnitEnum](https://www.php.net/manual/en/class.unitenum.php). @@ -215,7 +264,19 @@ The `Cast`'s `renameFrom` argument can also be an array to handle multiple incom public readonly string $prop; ```` -### What about constructors +### Default values + +`Casts` can carry a default value, even in the absence of hard property default (being impossible on readonly properties that are not promoted). + +As php does not implement the `Nil` concept (_never set_ as opposed to being `null` or actually set to `null`), `Dt0` uses a null byte (`"\0"`) as default for `Caster->default` value in order to simplify usage. The alternative would be to require to set an extra boolean argument `hasDefault` to then set a default or to not allow `null` as an actual default value. + +This implementation detail result in allowing `any` value except the `null byte` as a default property value from `Caster`. + +Should you find yourself in the rather uncommon situation where you would actually want a `null byte` as a defaults property value, you would then need to either une a `non readonly` property with this hard default, but this would break immutability, or set this property as a promoted one in your constructor to preserve `readonly` and thus immutability of your `Dt0`. + +All considered, this extra attention for a very particular case seems entirely neglectable compared to the burden of one extra argument in every other case. + +## What about constructors `Dt0`'s can have a constructor with promoted props given they properly call their parent: diff --git a/src/Attribute/Cast.php b/src/Attribute/Cast.php index 22a146a..f4982f4 100644 --- a/src/Attribute/Cast.php +++ b/src/Attribute/Cast.php @@ -12,6 +12,7 @@ use Attribute; use fab2s\Dt0\Caster\CasterInterface; use fab2s\Dt0\Dt0; +use fab2s\Dt0\Exception\AttributeException; #[Attribute(Attribute::TARGET_PROPERTY)] class Cast @@ -20,6 +21,9 @@ class Cast public readonly ?CasterInterface $in; public readonly ?CasterInterface $out; + /** + * @throws AttributeException + */ public function __construct( CasterInterface|string|null $in = null, CasterInterface|string|null $out = null, @@ -28,8 +32,17 @@ public function __construct( public readonly ?string $renameTo = null, public readonly ?string $propName = null, ) { - $this->in = $in instanceof CasterInterface ? $in : ($in ? new $in : null); - $this->out = $out instanceof CasterInterface ? $out : ($out ? new $out : null); + + foreach (['in', 'out'] as $case) { + $arg = $$case; + $this->$case = match (true) { + $arg instanceof CasterInterface => $arg, + is_subclass_of($arg, CasterInterface::class) => new $arg, + $arg === null => null, + default => throw new AttributeException("[Cast] $case Cast must implement CasterInterface"), + }; + } + $this->hasDefault = $this->default !== Dt0::DT0_NIL; } } diff --git a/src/Attribute/Validate.php b/src/Attribute/Validate.php index 1c3a142..71ddea7 100644 --- a/src/Attribute/Validate.php +++ b/src/Attribute/Validate.php @@ -10,6 +10,7 @@ namespace fab2s\Dt0\Attribute; use Attribute; +use fab2s\Dt0\Exception\AttributeException; use fab2s\Dt0\Validator\ValidatorInterface; #[Attribute(Attribute::TARGET_CLASS)] @@ -17,11 +18,18 @@ class Validate { public readonly ValidatorInterface $validator; + /** + * @throws AttributeException + */ public function __construct( /** @var ValidatorInterface|class-string $validator */ ValidatorInterface|string $validator, public readonly ?Rules $rules = null, ) { - $this->validator = $validator instanceof ValidatorInterface ? $validator : new $validator; + $this->validator = match (true) { + $validator instanceof ValidatorInterface => $validator, + is_subclass_of($validator, ValidatorInterface::class) => new $validator, + default => throw new AttributeException('[Validate] Validator must implement ValidatorInterface'), + }; } } diff --git a/src/Exception/AttributeException.php b/src/Exception/AttributeException.php new file mode 100644 index 0000000..dfa6536 --- /dev/null +++ b/src/Exception/AttributeException.php @@ -0,0 +1,14 @@ +rules[$name] = $rule; + + return $this; + } +} diff --git a/tests/Artifacts/TypedDt0.php b/tests/Artifacts/TypedDt0.php index 91c73e5..39348c4 100644 --- a/tests/Artifacts/TypedDt0.php +++ b/tests/Artifacts/TypedDt0.php @@ -12,11 +12,13 @@ use DateTime; use DateTimeImmutable; use fab2s\Dt0\Dt0; +use fab2s\Dt0\Tests\Artifacts\Enum\UnitEnum; class TypedDt0 extends Dt0 { public readonly DateTime|DateTimeImmutable $unionType; public readonly DateTime|DateTimeImmutable|null $unionTypeNullable; public readonly DateTime&DateTimeImmutable $intersectionType; + public readonly UnitEnum $unitEnum; public $unTyped = false; } diff --git a/tests/Attribute/CastTest.php b/tests/Attribute/CastTest.php new file mode 100644 index 0000000..7f5c2a3 --- /dev/null +++ b/tests/Attribute/CastTest.php @@ -0,0 +1,34 @@ +assertNull($cast->in); + $this->assertNull($cast->out); + $this->assertSame(Dt0::DT0_NIL, $cast->default); + $this->assertNull($cast->renameFrom); + $this->assertNull($cast->renameTo); + $this->assertNull($cast->propName); + $this->assertFalse($cast->hasDefault); + + $this->expectException(AttributeException::class); + new Cast(in: 'NotACaster'); + } +} diff --git a/tests/Attribute/CastsTest.php b/tests/Attribute/CastsTest.php new file mode 100644 index 0000000..d7b3b92 --- /dev/null +++ b/tests/Attribute/CastsTest.php @@ -0,0 +1,33 @@ +assertTrue($casts->hasCast('prop1')); + $this->assertTrue($casts->hasCast('prop2')); + $this->assertCount(2, $reflexion->getProperty('casters')->getValue($casts)); + } +} diff --git a/tests/Attribute/RuleTest.php b/tests/Attribute/RuleTest.php new file mode 100644 index 0000000..6c79699 --- /dev/null +++ b/tests/Attribute/RuleTest.php @@ -0,0 +1,24 @@ +assertSame('rule', $rule->rule); + $this->assertNull($rule->propName); + } +} diff --git a/tests/Attribute/RulesTest.php b/tests/Attribute/RulesTest.php new file mode 100644 index 0000000..e621d83 --- /dev/null +++ b/tests/Attribute/RulesTest.php @@ -0,0 +1,36 @@ +assertTrue($rules->hasRule('prop1')); + $this->assertSame('rule', $rules->getRule('prop1')->rule); + $this->assertTrue($rules->hasRule('prop2')); + $this->assertSame('rule', $rules->getRule('prop2')->rule); + + $this->assertCount(2, $reflexion->getProperty('rules')->getValue($rules)); + } +} diff --git a/tests/Attribute/ValidateTest.php b/tests/Attribute/ValidateTest.php new file mode 100644 index 0000000..d9768db --- /dev/null +++ b/tests/Attribute/ValidateTest.php @@ -0,0 +1,30 @@ +assertInstanceOf(ValidatorInterface::class, $validate->validator); + $this->assertNull($validate->rules); + + $this->expectException(AttributeException::class); + new Validate('NotAValidator'); + } +} diff --git a/tests/Dt0Test.php b/tests/Dt0Test.php index c98ce55..80ce00f 100644 --- a/tests/Dt0Test.php +++ b/tests/Dt0Test.php @@ -12,6 +12,7 @@ use fab2s\Dt0\Exception\Dt0Exception; use fab2s\Dt0\Tests\Artifacts\DefaultDt0; use fab2s\Dt0\Tests\Artifacts\Dt0Dt0; +use fab2s\Dt0\Tests\Artifacts\DummyValidatedDt0; use fab2s\Dt0\Tests\Artifacts\Enum\IntBackedEnum; use fab2s\Dt0\Tests\Artifacts\Enum\StringBackedEnum; use fab2s\Dt0\Tests\Artifacts\Enum\UnitEnum; @@ -60,6 +61,17 @@ public function test_with_validation_exception(): void SimpleDefaultDt0::withValidation(...[]); } + public function test_with_validation() + { + $dt0 = DummyValidatedDt0::withValidation(fromValidate: 'value1', fromRules: 'value2', fromRule: 'value3'); + + $this->assertSame([ + 'fromValidate' => 'value1', + 'fromRules' => 'value2', + 'fromRule' => 'value3', + ], $dt0->toArray()); + } + public function test_update(): void { $dto = DefaultDt0::make(stringNoCast: 'original', stringCast: 'someString'); diff --git a/tests/Property/PropertyTest.php b/tests/Property/PropertyTest.php index 8e7a313..339a56a 100644 --- a/tests/Property/PropertyTest.php +++ b/tests/Property/PropertyTest.php @@ -10,11 +10,15 @@ namespace fab2s\Dt0\Tests\Property; use fab2s\Dt0\Exception\Dt0Exception; +use fab2s\Dt0\Property\Properties; use fab2s\Dt0\Property\Property; +use fab2s\Dt0\Tests\Artifacts\DummyValidatedDt0; use fab2s\Dt0\Tests\Artifacts\Enum\IntBackedEnum; use fab2s\Dt0\Tests\Artifacts\Enum\StringBackedEnum; use fab2s\Dt0\Tests\Artifacts\Enum\UnitEnum; +use fab2s\Dt0\Tests\Artifacts\NoOpValidator; use fab2s\Dt0\Tests\TestCase; +use fab2s\Dt0\Validator\ValidatorInterface; class PropertyTest extends TestCase { @@ -35,4 +39,18 @@ public function test_enum_from() $this->expectException(Dt0Exception::class); $this->assertSame(null, Property::enumFrom(UnitEnum::class, 'notACase')); } + + public function test_validator() + { + $properties = new Properties(DummyValidatedDt0::class); + + $this->assertInstanceOf(ValidatorInterface::class, $properties->validator); + + /** @var NoOpValidator $validator */ + $validator = $properties->validator; + $this->assertCount(3, $validator->rules); + $this->assertSame('rule1', $validator->rules['fromValidate']->rule); + $this->assertSame('rule2', $validator->rules['fromRules']->rule); + $this->assertSame('rule3', $validator->rules['fromRule']->rule); + } } diff --git a/tests/Type/TypeTest.php b/tests/Type/TypeTest.php index 8c6734d..ad61d98 100644 --- a/tests/Type/TypeTest.php +++ b/tests/Type/TypeTest.php @@ -12,6 +12,7 @@ use DateTime; use DateTimeImmutable; use fab2s\Dt0\Property\Properties; +use fab2s\Dt0\Tests\Artifacts\Enum\UnitEnum; use fab2s\Dt0\Tests\Artifacts\TypedDt0; use fab2s\Dt0\Tests\TestCase; @@ -85,5 +86,17 @@ public function test_types() $this->assertTrue($intersectionTypePropTypes->has(DateTimeImmutable::class)); $this->assertTrue($intersectionTypePropTypes->has(DateTime::class)); + + $unitEnumTypeProp = $properties->get('unitEnum'); + $this->assertFalse($unitEnumTypeProp->hasDefault()); + $unitEnumTypePropTypes = $unitEnumTypeProp->types; + $this->assertCount(1, $unitEnumTypePropTypes->toArray()); + $this->assertFalse($unitEnumTypePropTypes->isUnion); + $this->assertTrue($unitEnumTypePropTypes->isReadOnly); + $this->assertFalse($unitEnumTypePropTypes->isNullable); + $this->assertFalse($unitEnumTypePropTypes->isIntersection); + + $this->assertTrue($unitEnumTypePropTypes->has(UnitEnum::class)); + $this->assertTrue(is_subclass_of(current($unitEnumTypePropTypes->getEnumFqns()), \UnitEnum::class)); } }