diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml new file mode 100644 index 0000000..f774682 --- /dev/null +++ b/.github/workflows/code-quality.yaml @@ -0,0 +1,87 @@ +name: Code Quality + +on: + pull_request: + push: + branches: + - main + tags-ignore: + - '*' + +jobs: + scan: + name: Static Analysis with SonarCloud + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PHP with Xdebug + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: xdebug + + - name: Install dependencies with composer + run: composer update --no-ansi --no-interaction --no-progress + + - name: Run tests with pest + run: vendor/bin/pest --coverage-clover coverage.xml + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --memory-limit=-1 --error-format=json > phpstan.json + + - name: SonarCloud Scan + uses: SonarSource/sonarqube-scan-action@master + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + phpstan: + name: Static Anaylsis with PHPStan + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PHP with Xdebug + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: xdebug + + - name: Install dependencies with composer + run: composer update --no-ansi --no-interaction --no-progress + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --memory-limit=-1 + + types: + name: Type Coverage + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PHP with Xdebug + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: xdebug + + - name: Install dependencies with composer + run: composer update --no-ansi --no-interaction --no-progress + + - name: Run type coverage with pest + run: vendor/bin/pest --type-coverage --min=98 --memory-limit=-1 diff --git a/.github/workflows/sonarcloud.yaml b/.github/workflows/sonarcloud.yaml deleted file mode 100644 index e84cf4a..0000000 --- a/.github/workflows/sonarcloud.yaml +++ /dev/null @@ -1,42 +0,0 @@ -name: SonarCloud - -on: - pull_request: - push: - branches: - - main - tags-ignore: - - '*' - -jobs: - scan: - name: SonarCloud Scan - - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup PHP with Xdebug - uses: shivammathur/setup-php@v2 - with: - php-version: '8.3' - coverage: xdebug - - - name: Install dependencies with composer - run: composer update --no-ansi --no-interaction --no-progress - - - name: Run tests with pest - run: > - vendor/bin/pest --coverage-clover coverage.xml - - - name: Run PHPStan - run: vendor/bin/phpstan analyse --memory-limit=-1 --error-format=json > phpstan.json - - - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4cc11d7..9b01fb9 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -50,6 +50,3 @@ jobs: - name: Run tests with pest run: vendor/bin/pest --parallel --coverage --min=80 - - - name: Run type coverage with pest - run: vendor/bin/pest --type-coverage --min=99 --memory-limit=-1 diff --git a/docs/basic-usage.md b/docs/basic-usage.md index 3f5b936..93cf521 100644 --- a/docs/basic-usage.md +++ b/docs/basic-usage.md @@ -66,7 +66,7 @@ $value = MyValue::from('Davey Shafik', 40); ## Type Casting -Bag will cast all values to their defined type _automatically_ for all scalar types, as well as the following: +If the input value matches the type of the property (including [union types](https://www.php.net/manual/en/language.types.type-system.php#language.types.type-system.composite.union)), it will be used as-is. Otherwise, Bag will cast all values to their defined type _automatically_ for all scalar types, as well as the following: - `Bag` objects - `\Bag\Collection` and `\Illuminate\Support\Collection` objects @@ -77,6 +77,46 @@ Bag will cast all values to their defined type _automatically_ for all scalar ty > [!TIP] > We recommend using `\Carbon\CarbonImmutable` for all date times. +## Default Values + +You can define default values for your properties by setting them in the constructor: + +```php +use Bag\Bag; + +readonly class MyValue extends Bag { + public function __construct( + public string $name, + public int $age = 40, + ) { + } +} + +$value = MyValue::from([ + 'name' => 'Davey Shafik', +])->toArray(); // ['name' => 'Davey Shafik', 'age' => 40] +``` + +## Nullable Values + +Bag will fill missing nullable values without a default value with `null`: + +```php +use Bag\Bag; + +readonly class MyValue extends Bag { + public function __construct( + public string $name, + public ?int $age, + ) { + } +} + +$value = MyValue::from([ + 'name' => 'Davey Shafik', +])->toArray(); // ['name' => 'Davey Shafik', 'age' => null] +``` + ### Modifying a Value Object Value Objects are immutable, so you cannot change their properties directly. Instead, you can create a new instance with the updated values using the `Bag->with()` or `Bag->append()` methods: diff --git a/docs/how-bag-works.md b/docs/how-bag-works.md index 9546dff..4fab4da 100644 --- a/docs/how-bag-works.md +++ b/docs/how-bag-works.md @@ -25,6 +25,7 @@ start("Bag::from($data)") --> transform(Transform Input) --> process(Process Parameters) --> variadic(Is Variadic?) +--> fillNulls(Fill Nulls) --> mapInput(Map Input) --> laravelParams(Laravel Route Parameter Binding) -- Finalized Input Values --> missing(Missing Parameters) --> missingError{Error?} @@ -51,6 +52,7 @@ click start "https://github.com/dshafik/bag/blob/main/src/Bag/Bag.php" _blank click transform "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/Transform.php" _blank click process "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/ProcessParameters.php" _blank click variadic "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/IsVariadic.php" _blank +click fillNull "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/FillNulls.php" _blank click mapInput "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/MapInput.php" _blank click laravelParams "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/LaravelRouteParameters.php" _blank click missing "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/MissingParameters.php" _blank @@ -115,6 +117,7 @@ start("Bag::validate($data)") --> transform(Transform Input) --> process(Process Parameters) --> variadic(Is Variadic?) +--> fillNulls(Fill Nulls) --> mapInput(Map Input) -- Finalized Input Values --> missing(Missing Parameters) --> missingError{Error?} missingError -- Yes --> errorMissingParameters(MissingPropertiesException) @@ -134,6 +137,7 @@ click start "https://github.com/dshafik/bag/blob/main/src/Bag/Concerns/WithValid click transform "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/Transform.php" _blank click process "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/ProcessParameters.php" _blank click variadic "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/IsVariadic.php" _blank +click fillNulls "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/FillNulls.php" _blank click mapInput "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/MapInput.php" _blank click missing "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/MissingParameters.php" _blank click extra "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/ExtraParameters.php" _blank @@ -151,6 +155,7 @@ start("Bag::from($data)") --> transform(Transform Input) --> process(Process Parameters) --> variadic(Is Variadic?) +--> fillNulls(Fill Nulls) --> mapInput(Map Input) --> laravelParams(Laravel Route Parameter Binding) -- Finalized Input Values --> missing(Missing Parameters) --> missingError{Error?} @@ -174,6 +179,7 @@ click start "https://github.com/dshafik/bag/blob/main/src/Bag/Bag.php" _blank click transform "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/Transform.php" _blank click process "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/ProcessParameters.php" _blank click variadic "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/IsVariadic.php" _blank +click fillNulls "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/FillNulls.php" _blank click mapInput "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/MapInput.php" _blank click laravelParams "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/LaravelRouteParameters.php" _blank click missing "https://github.com/dshafik/bag/blob/main/src/Bag/Pipelines/Pipes/MissingParameters.php" _blank diff --git a/src/Bag/Attributes/Cast.php b/src/Bag/Attributes/Cast.php index 02870f3..9e6bc58 100644 --- a/src/Bag/Attributes/Cast.php +++ b/src/Bag/Attributes/Cast.php @@ -8,7 +8,8 @@ use Bag\Attributes\Attribute as AttributeInterface; use Bag\Casts\CastsPropertyGet; use Bag\Casts\CastsPropertySet; -use Illuminate\Support\Collection; +use Bag\Collection; +use Illuminate\Support\Collection as LaravelCollection; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] class Cast implements AttributeInterface @@ -27,9 +28,9 @@ public function __construct(protected string $casterClassname, mixed ...$paramet } /** - * @param Collection $properties + * @param LaravelCollection $properties */ - public function cast(string $propertyType, string $propertyName, Collection $properties): mixed + public function cast(Collection $propertyType, string $propertyName, LaravelCollection $properties): mixed { /** @var CastsPropertySet $cast */ $cast = new $this->casterClassname(...$this->parameters); @@ -38,9 +39,9 @@ public function cast(string $propertyType, string $propertyName, Collection $pro } /** - * @param Collection $properties + * @param LaravelCollection $properties */ - public function transform(string $propertyName, Collection $properties): mixed + public function transform(string $propertyName, LaravelCollection $properties): mixed { /** @var CastsPropertyGet $cast */ $cast = new $this->casterClassname(...$this->parameters); diff --git a/src/Bag/Attributes/CastInput.php b/src/Bag/Attributes/CastInput.php index ce7ba30..ddf9df3 100644 --- a/src/Bag/Attributes/CastInput.php +++ b/src/Bag/Attributes/CastInput.php @@ -7,7 +7,8 @@ use Attribute; use Bag\Attributes\Attribute as AttributeInterface; use Bag\Casts\CastsPropertySet; -use Illuminate\Support\Collection; +use Bag\Collection; +use Illuminate\Support\Collection as LaravelCollection; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] class CastInput implements AttributeInterface @@ -24,9 +25,9 @@ public function __construct(protected string $casterClassname, mixed ...$paramet /** * @param class-string $propertyName - * @param Collection $properties + * @param LaravelCollection $properties */ - public function cast(string $propertyType, string $propertyName, Collection $properties): mixed + public function cast(Collection $propertyType, string $propertyName, LaravelCollection $properties): mixed { /** @var CastsPropertySet $cast */ $cast = new $this->casterClassname(...$this->parameters); diff --git a/src/Bag/Casts/CastsPropertySet.php b/src/Bag/Casts/CastsPropertySet.php index f901530..2710d0a 100644 --- a/src/Bag/Casts/CastsPropertySet.php +++ b/src/Bag/Casts/CastsPropertySet.php @@ -4,12 +4,13 @@ namespace Bag\Casts; -use Illuminate\Support\Collection; +use Bag\Collection; +use Illuminate\Support\Collection as LaravelCollection; interface CastsPropertySet { /** - * @param Collection $properties + * @param LaravelCollection $properties */ - public function set(string $propertyType, string $propertyName, Collection $properties): mixed; + public function set(Collection $propertyTypes, string $propertyName, LaravelCollection $properties): mixed; } diff --git a/src/Bag/Casts/CollectionOf.php b/src/Bag/Casts/CollectionOf.php index 7b2a716..0efbf16 100644 --- a/src/Bag/Casts/CollectionOf.php +++ b/src/Bag/Casts/CollectionOf.php @@ -5,12 +5,14 @@ namespace Bag\Casts; use Bag\Bag; +use Bag\Collection; use Bag\Exceptions\BagNotFoundException; use Bag\Exceptions\InvalidBag; use Bag\Exceptions\InvalidCollection; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Collection as LaravelCollection; use Override; +use ReflectionNamedType; class CollectionOf implements CastsPropertySet { @@ -29,12 +31,15 @@ public function __construct(public string $valueClassname) } /** - * @param class-string> $propertyType + * @param Collection $propertyTypes * @param LaravelCollection|iterable<(int|string), mixed>|null>> $properties */ #[Override] - public function set(string $propertyType, string $propertyName, LaravelCollection $properties): mixed + public function set(Collection $propertyTypes, string $propertyName, LaravelCollection $properties): mixed { + /** @var class-string> $propertyType */ + $propertyType = $propertyTypes->first(); + if ($propertyType !== LaravelCollection::class && ! \is_subclass_of($propertyType, LaravelCollection::class, true)) { throw new InvalidCollection(sprintf('The property "%s" must be a subclass of %s', $propertyName, LaravelCollection::class)); } diff --git a/src/Bag/Casts/DateTime.php b/src/Bag/Casts/DateTime.php index 213be44..803d07f 100644 --- a/src/Bag/Casts/DateTime.php +++ b/src/Bag/Casts/DateTime.php @@ -4,12 +4,14 @@ namespace Bag\Casts; +use Bag\Collection; use Carbon\Exceptions\InvalidFormatException; use DateMalformedStringException; use DateTimeImmutable; use DateTimeInterface; -use Illuminate\Support\Collection; +use Illuminate\Support\Collection as LaravelCollection; use Override; +use ReflectionNamedType; /** * @template T of DateTimeInterface @@ -25,9 +27,9 @@ public function __construct(protected string $format = 'Y-m-d H:i:s', protected #[Override] /** - * @param Collection $properties + * @param LaravelCollection $properties */ - public function get(string $propertyName, Collection $properties): mixed + public function get(string $propertyName, LaravelCollection $properties): mixed { /** @var T $dateTime */ $dateTime = $properties->get($propertyName); @@ -36,16 +38,18 @@ public function get(string $propertyName, Collection $properties): mixed } /** - * @param class-string $propertyType - * @param Collection $properties + * @param Collection $propertyTypes + * @param LaravelCollection $properties * @return T * @throws DateMalformedStringException */ #[Override] - public function set(string $propertyType, string $propertyName, Collection $properties): mixed + public function set(Collection $propertyTypes, string $propertyName, LaravelCollection $properties): mixed { if ($this->dateTimeClass === null) { - $this->dateTimeClass = $propertyType; + /** @var class-string $type */ + $type = $propertyTypes->first(); + $this->dateTimeClass = $type; } $value = $properties->get($propertyName); diff --git a/src/Bag/Casts/MagicCast.php b/src/Bag/Casts/MagicCast.php index c8ab5f2..df1769e 100644 --- a/src/Bag/Casts/MagicCast.php +++ b/src/Bag/Casts/MagicCast.php @@ -6,6 +6,7 @@ use BackedEnum; use Bag\Bag; +use Bag\Collection; use Carbon\Carbon; use Carbon\CarbonImmutable; use DateTimeImmutable; @@ -17,11 +18,47 @@ class MagicCast implements CastsPropertySet { + /** + * @param Collection $propertyTypes + */ #[Override] - public function set(string $propertyType, string $propertyName, LaravelCollection $properties): mixed + public function set(Collection $propertyTypes, string $propertyName, LaravelCollection $properties): mixed { $value = $properties->get($propertyName); + // Find the correct type for the property + $valueType = get_debug_type($value); + + // Exact Match to a type + if ($propertyTypes->filter(function ($type) use ($valueType, $value) { + /** @var string|class-string $type */ + return $type === $valueType || ((is_object($value) || is_string($value)) && is_a($value, $type, true)); + })->isNotEmpty()) { + return $value; + } + + // Fuzzy Matches + /** @var string $propertyType */ + $propertyType = $propertyTypes->first(function ($type) { + /** @var string $type */ + return match (true) { + is_a($type, \DateTime::class, true) => true, + is_a($type, DateTimeImmutable::class, true) => true, + is_a($type, Carbon::class, true) => true, + is_a($type, CarbonImmutable::class, true) => true, + is_a($type, Bag::class, true) => true, + (is_a($type, LaravelCollection::class, true) || is_subclass_of((string) $type, LaravelCollection::class)) => true, + is_a($type, BackedEnum::class, true) => true, + is_a($type, UnitEnum::class, true) => true, + is_subclass_of($type, Model::class) => true, + $type === 'float' => true, + $type === 'int' => true, + $type === 'string' => true, + $type === 'bool' => true, + default => false, + }; + }); + return match (true) { $value === null => null, // @phpstan-ignore cast.int @@ -39,8 +76,7 @@ public function set(string $propertyType, string $propertyName, LaravelCollectio is_a($propertyType, CarbonImmutable::class, true) ) => $propertyType::createFromFormat('U.u', (new DateTimeImmutable($value))->format('U.u')), is_subclass_of($propertyType, Bag::class, true) => $propertyType::from($value), - // @phpstan-ignore argument.templateType - (is_a($propertyType, LaravelCollection::class, true) || is_subclass_of($propertyType, LaravelCollection::class, true)) && \is_iterable($value) => $propertyType::make($value), + (is_a($propertyType, LaravelCollection::class, true) || is_subclass_of((string) $propertyType, LaravelCollection::class)) && \is_iterable($value) => $propertyType::make($value), is_subclass_of($propertyType, BackedEnum::class, true) && (is_string($value) || is_int($value)) => $propertyType::from($value), is_subclass_of($propertyType, UnitEnum::class, true) && is_string($value) => constant("{$propertyType}::{$value}"), is_subclass_of($propertyType, Model::class) && \is_scalar($value) => $propertyType::findOrFail($value), diff --git a/src/Bag/Casts/MoneyFromMinor.php b/src/Bag/Casts/MoneyFromMinor.php index 0e3b3aa..681dc00 100644 --- a/src/Bag/Casts/MoneyFromMinor.php +++ b/src/Bag/Casts/MoneyFromMinor.php @@ -5,10 +5,11 @@ namespace Bag\Casts; use BackedEnum; +use Bag\Collection; use Brick\Math\BigNumber; use Brick\Money\Exception\UnknownCurrencyException; use Brick\Money\Money as BrickMoney; -use Illuminate\Support\Collection; +use Illuminate\Support\Collection as LaravelCollection; use Override; use PrinsFrank\Standards\Currency\CurrencyAlpha3; use UnitEnum; @@ -20,7 +21,7 @@ public function __construct(protected CurrencyAlpha3|string|null $currency = nul } #[Override] - public function set(string $propertyType, string $propertyName, Collection $properties): mixed + public function set(Collection $propertyTypes, string $propertyName, LaravelCollection $properties): mixed { /** @var BigNumber|float|int|string $amount */ $amount = $properties->get($propertyName); @@ -51,7 +52,7 @@ public function set(string $propertyType, string $propertyName, Collection $prop } #[Override] - public function get(string $propertyName, Collection $properties): mixed + public function get(string $propertyName, LaravelCollection $properties): mixed { /** @var BrickMoney $money */ $money = $properties->get($propertyName); diff --git a/src/Bag/Internal/Util.php b/src/Bag/Internal/Util.php index 0280ae6..d63ebcd 100644 --- a/src/Bag/Internal/Util.php +++ b/src/Bag/Internal/Util.php @@ -4,6 +4,7 @@ namespace Bag\Internal; +use Bag\Collection; use Bag\Exceptions\InvalidPropertyType; use Illuminate\Foundation\Application; use Illuminate\Pipeline\Pipeline; @@ -16,7 +17,10 @@ class Util { - public static function getPropertyType(ReflectionParameter|ReflectionProperty $property): ReflectionNamedType + /** + * @return Collection + */ + public static function getPropertyTypes(ReflectionParameter|ReflectionProperty $property): Collection { $type = $property->getType(); if ($type === null) { @@ -28,11 +32,14 @@ public static function getPropertyType(ReflectionParameter|ReflectionProperty $p } if ($type instanceof ReflectionUnionType) { - $type = $type->getTypes()[0]; + $type = $type->getTypes(); } - /** @var ReflectionNamedType $type */ - return $type; + /** @var ReflectionNamedType[]|ReflectionNamedType $type */ + return Collection::wrap($type)->map(function ($type) { + /** @var ReflectionNamedType $type */ + return $type->getName(); + }); } public static function getPipeline(): Pipeline diff --git a/src/Bag/Pipelines/InputPipeline.php b/src/Bag/Pipelines/InputPipeline.php index 83a5f4b..867ad50 100644 --- a/src/Bag/Pipelines/InputPipeline.php +++ b/src/Bag/Pipelines/InputPipeline.php @@ -9,6 +9,7 @@ use Bag\Pipelines\Pipes\ComputedValues; use Bag\Pipelines\Pipes\ExtraParameters; use Bag\Pipelines\Pipes\FillBag; +use Bag\Pipelines\Pipes\FillNulls; use Bag\Pipelines\Pipes\IsVariadic; use Bag\Pipelines\Pipes\LaravelRouteParameters; use Bag\Pipelines\Pipes\MapInput; @@ -35,6 +36,7 @@ public static function process(BagInput $input): Bag new ProcessParameters(), new ProcessArguments(), new IsVariadic(), + new FillNulls(), new MapInput(), new LaravelRouteParameters(), new MissingProperties(), diff --git a/src/Bag/Pipelines/Pipes/FillNulls.php b/src/Bag/Pipelines/Pipes/FillNulls.php new file mode 100644 index 0000000..dfdc6b6 --- /dev/null +++ b/src/Bag/Pipelines/Pipes/FillNulls.php @@ -0,0 +1,41 @@ + $input + * @return BagInput + */ + public function __invoke(BagInput $input): BagInput + { + // Get a list of missing nullable values + $input->params->nullable()->each(function ($param) use ($input) { + /** @var Value $param */ + $hasValue = match(true) { + $input->input->has($param->name) => true, + $param->property instanceof ReflectionParameter && $param->property->isDefaultValueAvailable() => true, + $param->property instanceof ReflectionProperty && $param->property->hasDefaultValue() => true, + default => false + }; + + if ($hasValue) { + return; + } + + $input->input->put($param->name, null); + }); + + return $input; + } +} diff --git a/src/Bag/Pipelines/ValidationPipeline.php b/src/Bag/Pipelines/ValidationPipeline.php index dd5b06d..fe0dff6 100644 --- a/src/Bag/Pipelines/ValidationPipeline.php +++ b/src/Bag/Pipelines/ValidationPipeline.php @@ -6,6 +6,7 @@ use Bag\Bag; use Bag\Pipelines\Pipes\ExtraParameters; +use Bag\Pipelines\Pipes\FillNulls; use Bag\Pipelines\Pipes\IsVariadic; use Bag\Pipelines\Pipes\MapInput; use Bag\Pipelines\Pipes\MissingProperties; @@ -28,6 +29,7 @@ public static function process(BagInput $input): bool new Transform(), new ProcessParameters(), new IsVariadic(), + new FillNulls(), new MapInput(), new MissingProperties(), new ExtraParameters(), diff --git a/src/Bag/Pipelines/WithoutValidationPipeline.php b/src/Bag/Pipelines/WithoutValidationPipeline.php index cc985e6..21c71c0 100644 --- a/src/Bag/Pipelines/WithoutValidationPipeline.php +++ b/src/Bag/Pipelines/WithoutValidationPipeline.php @@ -9,6 +9,7 @@ use Bag\Pipelines\Pipes\ComputedValues; use Bag\Pipelines\Pipes\ExtraParameters; use Bag\Pipelines\Pipes\FillBag; +use Bag\Pipelines\Pipes\FillNulls; use Bag\Pipelines\Pipes\IsVariadic; use Bag\Pipelines\Pipes\LaravelRouteParameters; use Bag\Pipelines\Pipes\MapInput; @@ -34,6 +35,7 @@ public static function process(BagInput $input): Bag new ProcessParameters(), new ProcessArguments(), new IsVariadic(), + new FillNulls(), new MapInput(), new LaravelRouteParameters(), new MissingProperties(), diff --git a/src/Bag/Property/CastInput.php b/src/Bag/Property/CastInput.php index aeb94f3..c302ca5 100644 --- a/src/Bag/Property/CastInput.php +++ b/src/Bag/Property/CastInput.php @@ -8,14 +8,15 @@ use Bag\Attributes\CastInput as CastInputAttribute; use Bag\Casts\CastsPropertySet; use Bag\Casts\MagicCast; +use Bag\Collection; use Bag\Internal\Reflection; use Bag\Internal\Util; -use Illuminate\Support\Collection; +use Illuminate\Support\Collection as LaravelCollection; class CastInput { public function __construct( - protected string $propertyType, + protected Collection $propertyTypes, protected string $name, protected Cast|CastInputAttribute $caster ) { @@ -40,16 +41,16 @@ public static function create(\ReflectionParameter|\ReflectionProperty $property $cast = Reflection::getAttributeInstance($castAttribute); } - $type = Util::getPropertyType($property); + $types = Collection::wrap(Util::getPropertyTypes($property)); - return new self(propertyType: $type->getName(), name: $property->name, caster: $cast ?? new CastInputAttribute(MagicCast::class)); + return new self(propertyTypes: $types, name: $property->name, caster: $cast ?? new CastInputAttribute(MagicCast::class)); } /** - * @param Collection $properties + * @param LaravelCollection $properties */ - public function __invoke(Collection $properties): mixed + public function __invoke(LaravelCollection $properties): mixed { - return $this->caster->cast(propertyType: $this->propertyType, propertyName: $this->name, properties: $properties); + return $this->caster->cast(propertyType: $this->propertyTypes, propertyName: $this->name, properties: $properties); } } diff --git a/src/Bag/Property/CastOutput.php b/src/Bag/Property/CastOutput.php index 717813f..8bbb34c 100644 --- a/src/Bag/Property/CastOutput.php +++ b/src/Bag/Property/CastOutput.php @@ -9,7 +9,7 @@ use Bag\Casts\CastsPropertyGet; use Bag\Internal\Reflection; use Bag\Internal\Util; -use Illuminate\Support\Collection; +use Illuminate\Support\Collection as LaravelCollection; class CastOutput { @@ -40,16 +40,17 @@ public static function create(\ReflectionParameter|\ReflectionProperty $property } $name = $property->getName(); - $type = Util::getPropertyType($property); + /** @var string $type */ + $type = Util::getPropertyTypes($property)->first(); /** @var CastOutputAttribute|null $cast */ - return new self(propertyType: $type->getName(), name: $name, caster: $cast); + return new self(propertyType: $type, name: $name, caster: $cast); } /** - * @param Collection $properties + * @param LaravelCollection $properties */ - public function __invoke(Collection $properties): mixed + public function __invoke(LaravelCollection $properties): mixed { if ($this->caster === null) { return $properties->get($this->name); diff --git a/src/Bag/Property/Value.php b/src/Bag/Property/Value.php index 4568226..96f21f9 100644 --- a/src/Bag/Property/Value.php +++ b/src/Bag/Property/Value.php @@ -5,24 +5,26 @@ namespace Bag\Property; use Bag\Bag; +use Bag\Collection; use Bag\Internal\Util; -use Illuminate\Support\Collection; +use Illuminate\Support\Collection as LaravelCollection; use ReflectionClass; -use ReflectionNamedType; use ReflectionParameter; use ReflectionProperty; class Value { /** - * @param ReflectionClass> $bag + * @param ReflectionClass> $bag + * @param Collection $type */ public function __construct( public ReflectionClass $bag, public ReflectionProperty|ReflectionParameter $property, - public ReflectionNamedType $type, + public Collection $type, public string $name, public bool $required, + public bool $allowsNull, public MapCollection $maps, public CastInput $inputCast, public CastOutput $outputCast, @@ -32,7 +34,7 @@ public function __construct( } /** - * @param ReflectionClass> $bag + * @param ReflectionClass> $bag */ public static function create( ReflectionClass $bag, @@ -40,7 +42,7 @@ public static function create( ): self { $name = $property->getName(); - $type = Util::getPropertyType($property); + $type = Util::getPropertyTypes($property); return new self( bag: $bag, @@ -48,6 +50,7 @@ public static function create( type: $type, name: $name, required: self::isRequired($property), + allowsNull: self::allowsNull($property), maps: MapCollection::create(bagClass: $bag, property: $property), inputCast: CastInput::create(property: $property), outputCast: CastOutput::create(property: $property), @@ -78,4 +81,13 @@ protected static function isVariadic(ReflectionProperty|ReflectionParameter $pro return false; } + + protected static function allowsNull(ReflectionParameter|ReflectionProperty $property): bool + { + if ($property instanceof ReflectionParameter) { + return $property->allowsNull(); + } + + return $property->getType()?->allowsNull() ?? true; + } } diff --git a/src/Bag/Property/ValueCollection.php b/src/Bag/Property/ValueCollection.php index 662621c..debcb92 100644 --- a/src/Bag/Property/ValueCollection.php +++ b/src/Bag/Property/ValueCollection.php @@ -16,6 +16,11 @@ public function required(): static return $this->where('required', true); } + public function nullable(): static + { + return $this->where('allowsNull', true); + } + /** * @return Collection> */ diff --git a/tests/Feature/BagTest.php b/tests/Feature/BagTest.php index fb0f8e9..3325a6f 100644 --- a/tests/Feature/BagTest.php +++ b/tests/Feature/BagTest.php @@ -4,8 +4,12 @@ use Bag\Bag; use Bag\Exceptions\AdditionalPropertiesException; +use Tests\Fixtures\Enums\TestBackedEnum; use Tests\Fixtures\Values\BagWithSingleArrayParameter; +use Tests\Fixtures\Values\BagWithUnionTypes; +use Tests\Fixtures\Values\NullablePropertiesBag; use Tests\Fixtures\Values\OptionalPropertiesBag; +use Tests\Fixtures\Values\OptionalPropertiesWithDefaultsBag; use Tests\Fixtures\Values\TestBag; covers(Bag::class); @@ -126,6 +130,33 @@ ->and($value->bag)->toBeNull(); }); +test('it uses default values', function () { + $value = OptionalPropertiesWithDefaultsBag::from(); + + expect($value->name)->toBe('Davey Shafik') + ->and($value->age)->toBe(40) + ->and($value->email)->toBe('davey@php.net') + ->and($value->bag)->toBeNull(); +}); + +test('it sets nullable without input', function () { + $value = NullablePropertiesBag::from(); + + expect($value->name)->toBeNull() + ->and($value->age)->toBeNull() + ->and($value->email)->toBeNull() + ->and($value->bag)->toBeNull(); +}); + +test('it allows nullables with no values', function () { + $value = OptionalPropertiesBag::from(); + + expect($value->name)->toBeNull() + ->and($value->age)->toBeNull() + ->and($value->email)->toBeNull() + ->and($value->bag)->toBeNull(); +}); + test('it accepts named params', function () { $value = TestBag::from(name: 'Davey Shafik', age: 40, email: 'davey@php.net'); @@ -173,3 +204,15 @@ \ArgumentCountError::class, 'Tests\Fixtures\Values\TestBag::from(): Too many arguments passed, expected 3, got 4' ); + +test('union types', function () { + $value = BagWithUnionTypes::from(name: 'Davey Shafik', age: 40, email: 'davey@php.net'); + expect($value->name)->toBe('Davey Shafik') + ->and($value->age)->toBe(40) + ->and($value->email)->toBe('davey@php.net'); + + $value = BagWithUnionTypes::from(name: TestBackedEnum::TEST_VALUE, age: '40', email: false); + expect($value->name)->toBe(TestBackedEnum::TEST_VALUE) + ->and($value->age)->toBe('40') + ->and($value->email)->toBe(false); +}); diff --git a/tests/Fixtures/Casts/CastInputOnly.php b/tests/Fixtures/Casts/CastInputOnly.php index 9307a41..a7ce493 100644 --- a/tests/Fixtures/Casts/CastInputOnly.php +++ b/tests/Fixtures/Casts/CastInputOnly.php @@ -5,12 +5,13 @@ namespace Tests\Fixtures\Casts; use Bag\Casts\CastsPropertySet; -use Illuminate\Support\Collection; +use Bag\Collection; +use Illuminate\Support\Collection as LaravelCollection; use Illuminate\Support\Str; class CastInputOnly implements CastsPropertySet { - public function set(string $propertyType, string $propertyName, Collection $properties): mixed + public function set(Collection $propertyTypes, string $propertyName, LaravelCollection $properties): mixed { return Str::of($properties->get($propertyName))->upper(); } diff --git a/tests/Fixtures/Values/BagWithUnionTypes.php b/tests/Fixtures/Values/BagWithUnionTypes.php new file mode 100644 index 0000000..32f5ca2 --- /dev/null +++ b/tests/Fixtures/Values/BagWithUnionTypes.php @@ -0,0 +1,18 @@ +set(LaravelCollection::class, 'test', collect(['test' => [ + + $collection = $cast->set(Collection::wrap(LaravelCollection::class), 'test', collect(['test' => [ [ 'name' => 'Davey Shafik', 'age' => 40, @@ -47,7 +49,8 @@ test('it creates collection of bags', function () { $cast = new CollectionOf(TestBag::class); - $collection = $cast->set(Collection::class, 'test', collect(['test' => [ + + $collection = $cast->set(Collection::wrap(Collection::class), 'test', collect(['test' => [ [ 'name' => 'Davey Shafik', 'age' => 40, @@ -80,7 +83,8 @@ test('it creates custom collection of bags', function () { $cast = new CollectionOf(TestBag::class); - $collection = $cast->set(BagWithCollectionCollection::class, 'test', collect(['test' => [ + + $collection = $cast->set(Collection::wrap(BagWithCollectionCollection::class), 'test', collect(['test' => [ [ 'name' => 'Davey Shafik', 'age' => 40, @@ -113,7 +117,8 @@ test('it creates collection using existing bags', function () { $cast = new CollectionOf(TestBag::class); - $collection = $cast->set(Collection::class, 'test', collect(['test' => [ + + $collection = $cast->set(Collection::wrap(Collection::class), 'test', collect(['test' => [ TestBag::from([ 'name' => 'Davey Shafik', 'age' => 40, @@ -130,8 +135,10 @@ $this->expectException(InvalidCollection::class); $this->expectExceptionMessage('The property "test" must be a subclass of Illuminate\Support\Collection'); + $type = Collection::wrap((new ReflectionClosure(fn (\stdClass $type) => true))->getParameters()[0]->getType()); + $cast = new CollectionOf(TestBag::class); - $cast->set(\stdClass::class, 'test', collect(['test' => [ + $cast->set($type, 'test', collect(['test' => [ [ 'name' => 'Davey Shafik', 'age' => 40, @@ -149,8 +156,10 @@ $this->expectException(InvalidBag::class); $this->expectExceptionMessage('CollectionOf class "P\Tests\Unit\Casts\CollectionOfTest" must extend Bag\Bag'); + $type = Collection::wrap((new ReflectionClosure(fn (\stdClass $type) => true))->getParameters()[0]->getType()); + $cast = new CollectionOf(static::class); - $cast->set(\stdClass::class, 'test', collect(['test' => [ + $cast->set($type, 'test', collect(['test' => [ [ 'name' => 'Davey Shafik', 'age' => 40, @@ -168,8 +177,10 @@ $this->expectException(BagNotFoundException::class); $this->expectExceptionMessage('The Bag class "test-string" does not exist'); + $type = Collection::wrap((new ReflectionClosure(fn (\stdClass $type) => true))->getParameters()[0]->getType()); + $cast = new CollectionOf('test-string'); - $cast->set(\stdClass::class, 'test', collect(['test' => [ + $cast->set($type, 'test', collect(['test' => [ [ 'name' => 'Davey Shafik', 'age' => 40, diff --git a/tests/Unit/Casts/DateTimeTest.php b/tests/Unit/Casts/DateTimeTest.php index ee26bba..9a81254 100644 --- a/tests/Unit/Casts/DateTimeTest.php +++ b/tests/Unit/Casts/DateTimeTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); use Bag\Casts\DateTime; +use Bag\Collection; use Carbon\Carbon; use Carbon\CarbonImmutable; use Carbon\Exceptions\InvalidFormatException; @@ -11,7 +12,7 @@ test('it casts to datetime', function () { $cast = new DateTime(); - $datetime = $cast->set(\DateTime::class, 'test', collect(['test' => '2024-04-30 01:58:23'])); + $datetime = $cast->set(Collection::wrap(\DateTime::class), 'test', collect(['test' => '2024-04-30 01:58:23'])); expect($datetime)->toBeInstanceOf(\DateTime::class) ->and($datetime->format('Y-m-d H:i:s'))->toBe('2024-04-30 01:58:23'); @@ -20,7 +21,7 @@ test('it casts to datetime immutable', function () { $cast = new DateTime(); - $datetime = $cast->set(\DateTimeImmutable::class, 'test', collect(['test' => '2024-04-30 01:58:23'])); + $datetime = $cast->set(Collection::wrap(\DateTimeImmutable::class), 'test', collect(['test' => '2024-04-30 01:58:23'])); expect($datetime)->toBeInstanceOf(\DateTimeImmutable::class) ->and($datetime->format('Y-m-d H:i:s'))->toBe('2024-04-30 01:58:23'); @@ -29,7 +30,7 @@ test('it casts to carbon', function () { $cast = new DateTime(); - $datetime = $cast->set(Carbon::class, 'test', collect(['test' => '2024-04-30 01:58:23'])); + $datetime = $cast->set(Collection::wrap(Carbon::class), 'test', collect(['test' => '2024-04-30 01:58:23'])); expect($datetime)->toBeInstanceOf(Carbon::class) ->and($datetime->format('Y-m-d H:i:s'))->toBe('2024-04-30 01:58:23'); @@ -38,7 +39,7 @@ test('it casts to carbon immutable', function () { $cast = new DateTime(); - $datetime = $cast->set(CarbonImmutable::class, 'test', collect(['test' => '2024-04-30 01:58:23'])); + $datetime = $cast->set(Collection::wrap(CarbonImmutable::class), 'test', collect(['test' => '2024-04-30 01:58:23'])); expect($datetime)->toBeInstanceOf(CarbonImmutable::class) ->and($datetime->format('Y-m-d H:i:s'))->toBe('2024-04-30 01:58:23'); @@ -48,7 +49,7 @@ $customDateTime = new class () extends CarbonImmutable {}; $cast = new DateTime(dateTimeClass: $customDateTime::class); - $datetime = $cast->set(CarbonImmutable::class, 'test', collect(['test' => '2024-04-30 01:58:23'])); + $datetime = $cast->set(Collection::wrap(CarbonImmutable::class), 'test', collect(['test' => '2024-04-30 01:58:23'])); expect($datetime)->toBeInstanceOf($customDateTime::class) ->and($datetime->format('Y-m-d H:i:s'))->toBe('2024-04-30 01:58:23'); @@ -57,7 +58,7 @@ test('it does not cast same', function () { $cast = new DateTime(); - $datetime = $cast->set(CarbonImmutable::class, 'test', collect(['test' => new CarbonImmutable('2024-04-30 01:58:23')])); + $datetime = $cast->set(Collection::wrap(CarbonImmutable::class), 'test', collect(['test' => new CarbonImmutable('2024-04-30 01:58:23')])); expect($datetime)->toBeInstanceOf(CarbonImmutable::class) ->and($datetime->format('Y-m-d H:i:s'))->toBe('2024-04-30 01:58:23'); @@ -66,7 +67,7 @@ test('it casts incorrect date time interface', function () { $cast = new DateTime(); - $datetime = $cast->set(CarbonImmutable::class, 'test', collect(['test' => new \DateTimeImmutable('2024-04-30 01:58:23')])); + $datetime = $cast->set(Collection::wrap(CarbonImmutable::class), 'test', collect(['test' => new \DateTimeImmutable('2024-04-30 01:58:23')])); expect($datetime)->toBeInstanceOf(CarbonImmutable::class) ->and($datetime->format('Y-m-d H:i:s'))->toBe('2024-04-30 01:58:23'); @@ -75,7 +76,7 @@ test('it enforces strict mode', function () { $cast = new DateTime(strictMode: true); - $datetime = $cast->set(CarbonImmutable::class, 'test', collect(['test' => '2024-04-30 01:58:23'])); + $datetime = $cast->set(Collection::wrap(CarbonImmutable::class), 'test', collect(['test' => '2024-04-30 01:58:23'])); expect($datetime)->toBeInstanceOf(CarbonImmutable::class) ->and($datetime->format('Y-m-d H:i:s'))->toBe('2024-04-30 01:58:23'); @@ -86,12 +87,13 @@ $this->expectExceptionMessage('Not enough data available to satisfy format'); $cast = new DateTime(strictMode: true); - $cast->set(CarbonImmutable::class, 'test', collect(['test' => '2024-04-30 01:58'])); + $cast->set(Collection::wrap(CarbonImmutable::class), 'test', collect(['test' => '2024-04-30 01:58'])); }); test('it does not error in non strict mode', function () { $cast = new DateTime(strictMode: false); - $datetime = $cast->set(CarbonImmutable::class, 'test', collect(['test' => '2024-04-30 01:58'])); + + $datetime = $cast->set(Collection::wrap(CarbonImmutable::class), 'test', collect(['test' => '2024-04-30 01:58'])); expect($datetime)->toBeInstanceOf(CarbonImmutable::class) ->and($datetime->format('Y-m-d H:i:s'))->toBe('2024-04-30 01:58:00'); @@ -100,7 +102,7 @@ test('it parses custom format', function () { $cast = new DateTime(format: 'm/d/y'); - $datetime = $cast->set(CarbonImmutable::class, 'test', collect(['test' => '4/30/24'])); + $datetime = $cast->set(Collection::wrap(CarbonImmutable::class), 'test', collect(['test' => '4/30/24'])); expect($datetime->format('Y-m-d'))->toBe('2024-04-30'); }); @@ -115,7 +117,7 @@ test('it parses and outputs custom format', function () { $cast = new DateTime(format: 'm/d/y H:i:s', outputFormat: 'Y-m-d'); - $datetime = $cast->set(CarbonImmutable::class, 'test', collect(['test' => '4/30/24 01:58:23'])); + $datetime = $cast->set(Collection::wrap(CarbonImmutable::class), 'test', collect(['test' => '4/30/24 01:58:23'])); expect($datetime->format('Y-m-d'))->toBe('2024-04-30') ->and($cast->get('test', collect(['test' => $datetime])))->toBe('2024-04-30'); diff --git a/tests/Unit/Casts/MagicCastTest.php b/tests/Unit/Casts/MagicCastTest.php index fc048a6..a92e3bb 100644 --- a/tests/Unit/Casts/MagicCastTest.php +++ b/tests/Unit/Casts/MagicCastTest.php @@ -77,38 +77,38 @@ test('it casts int', function ($propertyType, $propertyName, $properties, $expected) { $cast = new MagicCast(); - $result = $cast->set($propertyType, $propertyName, $properties); + $result = $cast->set(Collection::wrap($propertyType), $propertyName, $properties); expect($result)->toBe($expected); })->with('int'); test('it casts float', function ($propertyType, $propertyName, $properties, $expected) { $cast = new MagicCast(); - $result = $cast->set($propertyType, $propertyName, $properties); + $result = $cast->set(Collection::wrap($propertyType), $propertyName, $properties); expect($result)->toBe($expected); })->with('float'); test('it casts boolean', function ($propertyType, $propertyName, $properties, $expected) { $cast = new MagicCast(); - $result = $cast->set($propertyType, $propertyName, $properties); + $result = $cast->set(Collection::wrap($propertyType), $propertyName, $properties); expect($result)->toBe($expected); })->with('bool'); test('it casts string', function ($propertyType, $propertyName, $properties, $expected) { $cast = new MagicCast(); - $result = $cast->set($propertyType, $propertyName, $properties); + $result = $cast->set(Collection::wrap($propertyType), $propertyName, $properties); expect($result)->toBe($expected); })->with('string'); test('it casts date times', function ($propertyType, $propertyName, $properties, $expectedClass, $expectedFormat) { $cast = new MagicCast(); - $result = $cast->set($propertyType, $propertyName, $properties); + $result = $cast->set(Collection::wrap($propertyType), $propertyName, $properties); expect($result)->toBeInstanceOf($expectedClass) ->and($result->format('Y-m-d H:i:s'))->toBe($expectedFormat); })->with('date_times'); test('it casts bags', function ($propertyType, $propertyName, $properties, $expectedClass, $expectedName, $expectedAge, $expectedEmail) { $cast = new MagicCast(); - $result = $cast->set($propertyType, $propertyName, $properties); + $result = $cast->set(Collection::wrap($propertyType), $propertyName, $properties); expect($result)->toBeInstanceOf($expectedClass) ->and($result->name)->toBe($expectedName) ->and($result->age)->toBe($expectedAge) @@ -117,7 +117,8 @@ test('it casts collections', function ($propertyType, $propertyName, $properties, $expectedClass, $expectedName, $expectedAge, $expectedEmail) { $cast = new MagicCast(); - $result = $cast->set($propertyType, $propertyName, $properties); + + $result = $cast->set(Collection::wrap($propertyType), $propertyName, $properties); expect($result)->toBeInstanceOf($expectedClass) ->and($result['name'])->toBe($expectedName) ->and($result['age'])->toBe($expectedAge) @@ -126,19 +127,19 @@ test('it casts unit enum', function ($propertyType, $propertyName, $properties, $expected) { $cast = new MagicCast(); - $result = $cast->set($propertyType, $propertyName, $properties); + $result = $cast->set(Collection::wrap($propertyType), $propertyName, $properties); expect($result)->toBe($expected); })->with('unit_enum'); test('it casts backed enum', function ($propertyType, $propertyName, $properties, $expected) { $cast = new MagicCast(); - $result = $cast->set($propertyType, $propertyName, $properties); + $result = $cast->set(Collection::wrap($propertyType), $propertyName, $properties); expect($result)->toBe($expected); })->with('backed_enum'); test('it casts to null if value is null', function ($propertyType) { $cast = new MagicCast(); - $result = $cast->set($propertyType, 'test', collect(['test' => null])); + $result = $cast->set(Collection::wrap($propertyType), 'test', collect(['test' => null])); expect($result)->toBeNull(); })->with('nullable'); diff --git a/tests/Unit/Casts/MoneyFromMajorTest.php b/tests/Unit/Casts/MoneyFromMajorTest.php index b2ed34c..552abf4 100644 --- a/tests/Unit/Casts/MoneyFromMajorTest.php +++ b/tests/Unit/Casts/MoneyFromMajorTest.php @@ -2,8 +2,10 @@ declare(strict_types=1); use Bag\Casts\MoneyFromMajor; +use Bag\Collection; use Brick\Money\Exception\UnknownCurrencyException; use Brick\Money\Money; +use Laravel\SerializableClosure\Support\ReflectionClosure; use PrinsFrank\Standards\Currency\CurrencyAlpha3; use Tests\Fixtures\Enums\TestCurrencyEnum; @@ -12,7 +14,9 @@ test('it does not cast money', function () { $cast = new MoneyFromMajor(currency: CurrencyAlpha3::US_Dollar); - $money = $cast->set(MoneyFromMajor::class, 'test', collect(['test' => Money::ofMinor(10000, 'CAD')])); + $type = Collection::wrap((new ReflectionClosure(fn (MoneyFromMajor $type) => true))->getParameters()[0]->getType()); + + $money = $cast->set($type, 'test', collect(['test' => Money::ofMinor(10000, 'CAD')])); /** @var Money $money */ expect($money->isEqualTo(Money::of(100, 'CAD')))->toBeTrue(); @@ -21,7 +25,9 @@ test('it casts money with backed enum currency', function () { $cast = new MoneyFromMajor(currency: CurrencyAlpha3::US_Dollar); - $money = $cast->set(MoneyFromMajor::class, 'test', collect(['test' => 100])); + $type = Collection::wrap((new ReflectionClosure(fn (MoneyFromMajor $type) => true))->getParameters()[0]->getType()); + + $money = $cast->set($type, 'test', collect(['test' => 100])); /** @var Money $money */ expect($money->isEqualTo(Money::of(100, 'USD')))->toBeTrue(); @@ -30,7 +36,9 @@ test('it casts money with string currency', function () { $cast = new MoneyFromMajor(currency: 'USD'); - $money = $cast->set(MoneyFromMajor::class, 'test', collect(['test' => 100])); + $type = Collection::wrap((new ReflectionClosure(fn (MoneyFromMajor $type) => true))->getParameters()[0]->getType()); + + $money = $cast->set($type, 'test', collect(['test' => 100])); /** @var Money $money */ expect($money->isEqualTo(Money::of(100, 'USD')))->toBeTrue(); @@ -39,7 +47,9 @@ test('it casts money with currency property as string', function () { $cast = new MoneyFromMajor(currencyProperty: 'currency'); - $money = $cast->set(MoneyFromMajor::class, 'test', collect(['test' => 100, 'currency' => 'USD'])); + $type = Collection::wrap((new ReflectionClosure(fn (MoneyFromMajor $type) => true))->getParameters()[0]->getType()); + + $money = $cast->set($type, 'test', collect(['test' => 100, 'currency' => 'USD'])); /** @var Money $money */ expect($money->isEqualTo(Money::of(100, 'USD')))->toBeTrue(); @@ -48,7 +58,9 @@ test('it casts money with currency property as backed enum', function () { $cast = new MoneyFromMajor(currencyProperty: 'currency'); - $money = $cast->set(MoneyFromMajor::class, 'test', collect(['test' => 100, 'currency' => CurrencyAlpha3::US_Dollar])); + $type = Collection::wrap((new ReflectionClosure(fn (MoneyFromMajor $type) => true))->getParameters()[0]->getType()); + + $money = $cast->set($type, 'test', collect(['test' => 100, 'currency' => CurrencyAlpha3::US_Dollar])); /** @var Money $money */ expect($money->isEqualTo(Money::of(100, 'USD')))->toBeTrue(); @@ -57,7 +69,9 @@ test('it casts money with currency property as unit enum', function () { $cast = new MoneyFromMajor(currencyProperty: 'currency'); - $money = $cast->set(MoneyFromMajor::class, 'test', collect(['test' => 100, 'currency' => TestCurrencyEnum::USD])); + $type = Collection::wrap((new ReflectionClosure(fn (MoneyFromMajor $type) => true))->getParameters()[0]->getType()); + + $money = $cast->set($type, 'test', collect(['test' => 100, 'currency' => TestCurrencyEnum::USD])); /** @var Money $money */ expect($money->isEqualTo(Money::of(100, 'USD')))->toBeTrue(); @@ -67,8 +81,10 @@ $this->expectException(UnknownCurrencyException::class); $this->expectExceptionMessage('No currency found'); + $type = Collection::wrap((new ReflectionClosure(fn (MoneyFromMajor $type) => true))->getParameters()[0]->getType()); + $cast = new MoneyFromMajor(currencyProperty: 'currency'); - $cast->set(MoneyFromMajor::class, 'test', collect(['test' => 100, 'currency' => null])); + $cast->set($type, 'test', collect(['test' => 100, 'currency' => null])); }); test('it formats output', function () { diff --git a/tests/Unit/Casts/MoneyFromMinorTest.php b/tests/Unit/Casts/MoneyFromMinorTest.php index 88c4012..2e4ca8b 100644 --- a/tests/Unit/Casts/MoneyFromMinorTest.php +++ b/tests/Unit/Casts/MoneyFromMinorTest.php @@ -2,8 +2,10 @@ declare(strict_types=1); use Bag\Casts\MoneyFromMinor; +use Bag\Collection; use Brick\Money\Exception\UnknownCurrencyException; use Brick\Money\Money; +use Laravel\SerializableClosure\Support\ReflectionClosure; use PrinsFrank\Standards\Currency\CurrencyAlpha3; use Tests\Fixtures\Enums\TestCurrencyEnum; @@ -12,7 +14,9 @@ test('it does not cast money', function () { $cast = new MoneyFromMinor(currency: CurrencyAlpha3::US_Dollar); - $money = $cast->set(MoneyFromMinor::class, 'test', collect(['test' => Money::of(100, 'CAD')])); + $type = Collection::wrap((new ReflectionClosure(fn (MoneyFromMinor $type) => true))->getParameters()[0]->getType()); + + $money = $cast->set($type, 'test', collect(['test' => Money::of(100, 'CAD')])); /** @var Money $money */ expect($money->isEqualTo(Money::of(100, 'CAD')))->toBeTrue(); @@ -21,7 +25,9 @@ test('it casts money with backed enum currency', function () { $cast = new MoneyFromMinor(currency: CurrencyAlpha3::US_Dollar); - $money = $cast->set(MoneyFromMinor::class, 'test', collect(['test' => 10000])); + $type = Collection::wrap((new ReflectionClosure(fn (MoneyFromMinor $type) => true))->getParameters()[0]->getType()); + + $money = $cast->set($type, 'test', collect(['test' => 10000])); /** @var Money $money */ expect($money->isEqualTo(Money::of(100, 'USD')))->toBeTrue(); @@ -30,7 +36,9 @@ test('it casts money with string currency', function () { $cast = new MoneyFromMinor(currency: 'USD'); - $money = $cast->set(MoneyFromMinor::class, 'test', collect(['test' => 10000])); + $type = Collection::wrap((new ReflectionClosure(fn (MoneyFromMinor $type) => true))->getParameters()[0]->getType()); + + $money = $cast->set($type, 'test', collect(['test' => 10000])); /** @var Money $money */ expect($money->isEqualTo(Money::of(100, 'USD')))->toBeTrue(); @@ -39,7 +47,9 @@ test('it casts money with currency property as string', function () { $cast = new MoneyFromMinor(currencyProperty: 'currency'); - $money = $cast->set(MoneyFromMinor::class, 'test', collect(['test' => 10000, 'currency' => 'USD'])); + $type = Collection::wrap((new ReflectionClosure(fn (MoneyFromMinor $type) => true))->getParameters()[0]->getType()); + + $money = $cast->set($type, 'test', collect(['test' => 10000, 'currency' => 'USD'])); /** @var Money $money */ expect($money->isEqualTo(Money::of(100, 'USD')))->toBeTrue(); @@ -48,7 +58,9 @@ test('it casts money with currency property as backed enum', function () { $cast = new MoneyFromMinor(currencyProperty: 'currency'); - $money = $cast->set(MoneyFromMinor::class, 'test', collect(['test' => 10000, 'currency' => CurrencyAlpha3::US_Dollar])); + $type = Collection::wrap((new ReflectionClosure(fn (MoneyFromMinor $type) => true))->getParameters()[0]->getType()); + + $money = $cast->set($type, 'test', collect(['test' => 10000, 'currency' => CurrencyAlpha3::US_Dollar])); /** @var Money $money */ expect($money->isEqualTo(Money::of(100, 'USD')))->toBeTrue(); @@ -57,7 +69,9 @@ test('it casts money with currency property as unit enum', function () { $cast = new MoneyFromMinor(currencyProperty: 'currency'); - $money = $cast->set(MoneyFromMinor::class, 'test', collect(['test' => 10000, 'currency' => TestCurrencyEnum::USD])); + $type = Collection::wrap((new ReflectionClosure(fn (MoneyFromMinor $type) => true))->getParameters()[0]->getType()); + + $money = $cast->set($type, 'test', collect(['test' => 10000, 'currency' => TestCurrencyEnum::USD])); /** @var Money $money */ expect($money->isEqualTo(Money::of(100, 'USD')))->toBeTrue(); @@ -67,8 +81,10 @@ $this->expectException(UnknownCurrencyException::class); $this->expectExceptionMessage('No currency found'); + $type = Collection::wrap((new ReflectionClosure(fn (MoneyFromMinor $type) => true))->getParameters()[0]->getType()); + $cast = new MoneyFromMinor(currencyProperty: 'currency'); - $cast->set(MoneyFromMinor::class, 'test', collect(['test' => 10000, 'currency' => null])); + $cast->set($type, 'test', collect(['test' => 10000, 'currency' => null])); }); test('it formats output', function () { diff --git a/tests/Unit/Internal/UtilTest.php b/tests/Unit/Internal/UtilTest.php index 4275a5b..b3af43f 100644 --- a/tests/Unit/Internal/UtilTest.php +++ b/tests/Unit/Internal/UtilTest.php @@ -10,27 +10,30 @@ covers(Util::class); test('it gets property type', function () { - $type = Util::getPropertyType(new \ReflectionParameter(fn (string $arg) => null, 'arg')); + $types = Util::getPropertyTypes(new \ReflectionParameter(fn (string $arg) => null, 'arg')); - expect($type->getName())->toBe('string'); + expect($types->first())->toBe('string'); }); test('it defaults to mixed type', function () { - $type = Util::getPropertyType(new \ReflectionParameter(fn ($arg) => null, 'arg')); + $types = Util::getPropertyTypes(new \ReflectionParameter(fn ($arg) => null, 'arg')); - expect($type->getName())->toBe('mixed'); + expect($types->first())->toBe('mixed'); }); -test('it uses first union type', function () { - $type = Util::getPropertyType(new \ReflectionParameter(fn (int|string|float|bool $arg) => null, 'arg')); +test('it gets all union types', function () { + $types = Util::getPropertyTypes(new \ReflectionParameter(fn (int|string|float|bool $arg) => null, 'arg')); - expect($type->getName())->toBe('string'); + expect($types) + ->toHaveCount(4) + ->and($types->toArray()) + ->toBe(['string', 'int', 'float', 'bool']); }); test('it errors on intersection type', function () { $this->expectException(InvalidPropertyType::class); $this->expectExceptionMessage('Intersection types are not supported for parameter anArgument'); - Util::getPropertyType(new \ReflectionParameter(fn (CamelCase&Stringable $anArgument) => null, 'anArgument')); + Util::getPropertyTypes(new \ReflectionParameter(fn (CamelCase&Stringable $anArgument) => null, 'anArgument')); }); test('it creates a pipeline', function () { diff --git a/tests/Unit/Pipelines/Pipes/FillNullsTest.php b/tests/Unit/Pipelines/Pipes/FillNullsTest.php new file mode 100644 index 0000000..40d48fb --- /dev/null +++ b/tests/Unit/Pipelines/Pipes/FillNullsTest.php @@ -0,0 +1,31 @@ +input->toArray())->toBe(['name' => null, 'age' => null, 'email' => null, 'bag' => null]); +}); + +test('do not fill defaults', function () { + $input = new BagInput(OptionalPropertiesWithDefaultsBag::class, collect()); + $input = (new ProcessParameters())($input); + + $pipe = new FillNulls(); + $input = $pipe($input); + + expect($input->input->toArray())->toBeEmpty(); +}); diff --git a/tests/Unit/Property/CastInputTest.php b/tests/Unit/Property/CastInputTest.php index ce21c27..abf03b5 100644 --- a/tests/Unit/Property/CastInputTest.php +++ b/tests/Unit/Property/CastInputTest.php @@ -3,9 +3,11 @@ declare(strict_types=1); use Bag\Attributes\Cast; use Bag\Casts\DateTime; +use Bag\Collection; use Bag\Property\CastInput; use Carbon\CarbonImmutable; -use Illuminate\Support\Collection; +use Illuminate\Support\Collection as LaravelCollection; +use Laravel\SerializableClosure\Support\ReflectionClosure; use Tests\Fixtures\Values\CastsDateBag; covers(CastInput::class); @@ -16,7 +18,7 @@ $castInput = CastInput::create($param); expect($castInput)->toBeInstanceOf(CastInput::class) - ->and(property($castInput, 'propertyType'))->toBe(CarbonImmutable::class) + ->and(property($castInput, 'propertyTypes')->first())->toBe(CarbonImmutable::class) ->and(property($castInput, 'name'))->toBe('date') ->and(property(property($castInput, 'caster'), 'parameters'))->toBe(['format' => 'Y-m-d']) ->and(property(property($castInput, 'caster'), 'casterClassname'))->toBe(DateTime::class); @@ -27,9 +29,11 @@ $caster->method('cast') ->willReturn('castedValue'); - $castInput = new CastInput('string', 'propertyName', $caster); + $type = Collection::wrap((new ReflectionClosure(fn (string $type) => true))->getParameters()[0]->getType()); - $properties = new Collection(['propertyName' => 'propertyValue']); + $castInput = new CastInput($type, 'propertyName', $caster); + + $properties = new LaravelCollection(['propertyName' => 'propertyValue']); expect($castInput->__invoke($properties))->toEqual('castedValue'); }); diff --git a/tests/Unit/Property/ValueCollectionTest.php b/tests/Unit/Property/ValueCollectionTest.php index f5268ff..ec54ad5 100644 --- a/tests/Unit/Property/ValueCollectionTest.php +++ b/tests/Unit/Property/ValueCollectionTest.php @@ -5,6 +5,7 @@ use Bag\Property\ValueCollection; use Tests\Fixtures\Values\BagWithTransformers; use Tests\Fixtures\Values\MappedNameClassBag; +use Tests\Fixtures\Values\NullablePropertiesBag; use Tests\Fixtures\Values\TestBag; covers(ValueCollection::class); @@ -30,6 +31,18 @@ ->and($required->keys()->all())->toBe(['name', 'age', 'email']); }); +test('it returns nullable properties', function () { + $class = new \ReflectionClass(NullablePropertiesBag::class); + $collection = ValueCollection::make($class->getConstructor()?->getParameters())->mapWithKeys(function (\ReflectionParameter $property) use ($class) { + return [$property->getName() => Value::create($class, $property)]; + }); + + expect($collection)->toHaveCount(4); + $required = $collection->nullable(); + expect($required)->toHaveCount(4) + ->and($required->keys()->all())->toBe(['name', 'age', 'email', 'bag']); +}); + test('it resolves aliases', function () { $class = new \ReflectionClass(MappedNameClassBag::class); $collection = ValueCollection::make($class->getConstructor()?->getParameters())->mapWithKeys(function (\ReflectionParameter $property) use ($class) { diff --git a/tests/Unit/Property/ValueTest.php b/tests/Unit/Property/ValueTest.php index 16d539b..b77ec98 100644 --- a/tests/Unit/Property/ValueTest.php +++ b/tests/Unit/Property/ValueTest.php @@ -20,8 +20,7 @@ ->and($value->bag->name)->toBe(ValidateMappedNameClassBag::class) ->and($value->property)->toBeInstanceOf(\ReflectionProperty::class) ->and($value->property->name)->toBe('nameGoesHere') - ->and($value->type)->toBeInstanceOf(\ReflectionNamedType::class) - ->and($value->type->getName())->toBe('string') + ->and($value->type->first())->toBe('string') ->and($value->name)->toBe('nameGoesHere') ->and($value->required)->toBeTrue() ->and($value->maps->get('input')->toArray())->toBe(['name_goes_here']) @@ -45,8 +44,7 @@ ->and($value->bag->name)->toBe(ValidateMappedNameClassBag::class) ->and($value->property)->toBeInstanceOf(\ReflectionParameter::class) ->and($value->property->name)->toBe('nameGoesHere') - ->and($value->type)->toBeInstanceOf(\ReflectionNamedType::class) - ->and($value->type->getName())->toBe('string') + ->and($value->type->first())->toBe('string') ->and($value->name)->toBe('nameGoesHere') ->and($value->required)->toBeTrue() ->and($value->maps->get('input')->toArray())->toBe(['name_goes_here'])