Skip to content

Commit

Permalink
Tests
Browse files Browse the repository at this point in the history
  • Loading branch information
fab2s committed Apr 28, 2024
1 parent c84b18a commit 0253887
Show file tree
Hide file tree
Showing 16 changed files with 369 additions and 5 deletions.
65 changes: 63 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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:

Expand Down
17 changes: 15 additions & 2 deletions src/Attribute/Cast.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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;
}
}
10 changes: 9 additions & 1 deletion src/Attribute/Validate.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,26 @@
namespace fab2s\Dt0\Attribute;

use Attribute;
use fab2s\Dt0\Exception\AttributeException;
use fab2s\Dt0\Validator\ValidatorInterface;

#[Attribute(Attribute::TARGET_CLASS)]
class Validate
{
public readonly ValidatorInterface $validator;

/**
* @throws AttributeException
*/
public function __construct(
/** @var ValidatorInterface|class-string<ValidatorInterface> $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'),
};
}
}
14 changes: 14 additions & 0 deletions src/Exception/AttributeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

/*
* This file is part of fab2s/dt0.
* (c) Fabrice de Stefanis / https://github.com/fab2s/dt0
* This source file is licensed under the MIT license which you will
* find in the LICENSE file or at https://opensource.org/licenses/MIT
*/

namespace fab2s\Dt0\Exception;

class AttributeException extends Dt0Exception
{
}
3 changes: 3 additions & 0 deletions src/Type/Types.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,10 @@ protected function getTypes(): array
break;
}

// @codeCoverageIgnoreStart
// is there even a way to reach here ?
throw new LogicException('Received unknown type from ReflectionProperty::getType');
// @codeCoverageIgnoreEnd
}

return $types;
Expand Down
33 changes: 33 additions & 0 deletions tests/Artifacts/DummyValidatedDt0.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

/*
* This file is part of fab2s/dt0.
* (c) Fabrice de Stefanis / https://github.com/fab2s/dt0
* This source file is licensed under the MIT license which you will
* find in the LICENSE file or at https://opensource.org/licenses/MIT
*/

namespace fab2s\Dt0\Tests\Artifacts;

use fab2s\Dt0\Attribute\Rule;
use fab2s\Dt0\Attribute\Rules;
use fab2s\Dt0\Attribute\Validate;
use fab2s\Dt0\Dt0;

#[Validate(
validator: NoOpValidator::class,
rules: new Rules(
fromValidate: new Rule('rule1'),
),
)]
#[Rules(
fromRules: new Rule('rule2'),
)]
class DummyValidatedDt0 extends Dt0
{
public readonly string $fromValidate;
public readonly string $fromRules;

#[Rule('rule3')]
public readonly string $fromRule;
}
30 changes: 30 additions & 0 deletions tests/Artifacts/NoOpValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/*
* This file is part of fab2s/dt0.
* (c) Fabrice de Stefanis / https://github.com/fab2s/dt0
* This source file is licensed under the MIT license which you will
* find in the LICENSE file or at https://opensource.org/licenses/MIT
*/

namespace fab2s\Dt0\Tests\Artifacts;

use fab2s\Dt0\Attribute\Rule;
use fab2s\Dt0\Validator\ValidatorInterface;

class NoOpValidator implements ValidatorInterface
{
public array $rules = [];

public function validate(array $data): array
{
return $data;
}

public function addRule(string $name, Rule $rule): static
{
$this->rules[$name] = $rule;

return $this;
}
}
2 changes: 2 additions & 0 deletions tests/Artifacts/TypedDt0.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
34 changes: 34 additions & 0 deletions tests/Attribute/CastTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/*
* This file is part of fab2s/dt0.
* (c) Fabrice de Stefanis / https://github.com/fab2s/dt0
* This source file is licensed under the MIT license which you will
* find in the LICENSE file or at https://opensource.org/licenses/MIT
*/

namespace fab2s\Dt0\Tests\Attribute;

use fab2s\Dt0\Attribute\Cast;
use fab2s\Dt0\Dt0;
use fab2s\Dt0\Exception\AttributeException;
use fab2s\Dt0\Tests\TestCase;

class CastTest extends TestCase
{
public function test_casts()
{
$cast = new Cast;

$this->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');
}
}
33 changes: 33 additions & 0 deletions tests/Attribute/CastsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

/*
* This file is part of fab2s/dt0.
* (c) Fabrice de Stefanis / https://github.com/fab2s/dt0
* This source file is licensed under the MIT license which you will
* find in the LICENSE file or at https://opensource.org/licenses/MIT
*/

namespace fab2s\Dt0\Tests\Attribute;

use fab2s\Dt0\Attribute\Cast;
use fab2s\Dt0\Attribute\Casts;
use fab2s\Dt0\Tests\TestCase;
use ReflectionClass;

class CastsTest extends TestCase
{
public function test_casts()
{
$casts = new Casts(
new Cast(propName: 'prop1'),
new Cast(default: 'casted'),
prop2: new Cast(default: 'casted'),
);

$reflexion = new ReflectionClass($casts);

$this->assertTrue($casts->hasCast('prop1'));
$this->assertTrue($casts->hasCast('prop2'));
$this->assertCount(2, $reflexion->getProperty('casters')->getValue($casts));
}
}
24 changes: 24 additions & 0 deletions tests/Attribute/RuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

/*
* This file is part of fab2s/dt0.
* (c) Fabrice de Stefanis / https://github.com/fab2s/dt0
* This source file is licensed under the MIT license which you will
* find in the LICENSE file or at https://opensource.org/licenses/MIT
*/

namespace fab2s\Dt0\Tests\Attribute;

use fab2s\Dt0\Attribute\Rule;
use fab2s\Dt0\Tests\TestCase;

class RuleTest extends TestCase
{
public function test_casts()
{
$rule = new Rule('rule');

$this->assertSame('rule', $rule->rule);
$this->assertNull($rule->propName);
}
}
36 changes: 36 additions & 0 deletions tests/Attribute/RulesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/*
* This file is part of fab2s/dt0.
* (c) Fabrice de Stefanis / https://github.com/fab2s/dt0
* This source file is licensed under the MIT license which you will
* find in the LICENSE file or at https://opensource.org/licenses/MIT
*/

namespace fab2s\Dt0\Tests\Attribute;

use fab2s\Dt0\Attribute\Rule;
use fab2s\Dt0\Attribute\Rules;
use fab2s\Dt0\Tests\TestCase;
use ReflectionClass;

class RulesTest extends TestCase
{
public function test_casts()
{
$rules = new Rules(
new Rule(rule: 'rule', propName: 'prop1'),
new Rule(rule: 'rule'),
prop2: new Rule(rule: 'rule'),
);

$reflexion = new ReflectionClass($rules);

$this->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));
}
}
Loading

0 comments on commit 0253887

Please sign in to comment.