diff --git a/docs/basic-usage.md b/docs/basic-usage.md index 25a9b48..46f62a0 100644 --- a/docs/basic-usage.md +++ b/docs/basic-usage.md @@ -64,6 +64,25 @@ $value = MyValue::from('Davey Shafik', 40); > [!WARNING] > If you have a single array argument, **and** an array [transformer](transformers.md), the transformer will be applied to the array, potentially causing unwanted side-effects. +## Creating an Empty Value Object + +To create an empty Value Object, you can use the `::empty()` method: + +```php +$value = MyValue::empty(); +``` + +This will create a new instance with all properties set either: + +- Their default value, if one is defined +- `null` if they are nullable (e.g. `?type` or `type|null`) +- A zero-value + +> [!WARNING] +> Zero values may cause side-effects, for example DateTime objects will be set to the Unix Epoch (1970-01-01 00:00:00), and Money objects will be set to zero using the current locale currency code. + +This will create a new instance with the provided properties set, and all other properties set to their default value, `null`, or zero-value as described above. + ## Type Casting Bag will cast all values to their defined type _automatically_ for all scalar types, as well as the following: diff --git a/src/Bag/Bag.php b/src/Bag/Bag.php index 978a41b..2b2a546 100644 --- a/src/Bag/Bag.php +++ b/src/Bag/Bag.php @@ -10,12 +10,15 @@ use Bag\Concerns\WithJson; use Bag\Concerns\WithOutput; use Bag\Concerns\WithValidation; +use Bag\Pipelines\EmptyPipeline; use Bag\Pipelines\InputPipeline; +use Bag\Pipelines\PartialPipeline; use Bag\Pipelines\Values\BagInput; use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Jsonable; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Support\Collection; use JsonSerializable; /** @@ -39,6 +42,13 @@ public static function from(mixed ... $values): static return InputPipeline::process($input); } + public static function empty(): static + { + $input = new BagInput(static::class, Collection::empty()); + + return EmptyPipeline::process($input); + } + public function with(mixed ...$values): static { if (count($values) === 1 && isset($values[0])) { diff --git a/src/Bag/Pipelines/EmptyPipeline.php b/src/Bag/Pipelines/EmptyPipeline.php new file mode 100644 index 0000000..f6b999d --- /dev/null +++ b/src/Bag/Pipelines/EmptyPipeline.php @@ -0,0 +1,32 @@ + $input + * @return T + */ + public static function process(BagInput $input): Bag + { + $pipeline = new Pipeline( + null, + new ProcessParameters(), + new FillDefaultValues(), + new FillBag(), + ); + + return $pipeline->process($input)->bag; + } +} diff --git a/src/Bag/Pipelines/Pipes/FillDefaultValues.php b/src/Bag/Pipelines/Pipes/FillDefaultValues.php new file mode 100644 index 0000000..f49593b --- /dev/null +++ b/src/Bag/Pipelines/Pipes/FillDefaultValues.php @@ -0,0 +1,77 @@ + $input + * @return BagInput + */ + public function __invoke(BagInput $input): BagInput + { + /** @var Collection $defaults */ + $defaults = $input->input; + /** @var Value $param */ + foreach ($input->params as $param) { + if ($defaults->has($param->name)) { + continue; + } + + /** @var ReflectionNamedType $type */ + $type = $param->property->getType(); + $parameterType = $type->getName(); + + $defaults->put($param->name, match (true) { + ($param->property instanceof ReflectionProperty) && $param->property->hasDefaultValue() => $param->property->getDefaultValue(), + ($param->property instanceof ReflectionParameter) && $param->property->isDefaultValueAvailable() => $param->property->getDefaultValue(), + $type->allowsNull() => null, + default => $this->getEmptyValue($type, $parameterType) + }); + } + + $input->values = $defaults; + + return $input; + } + + protected function getEmptyValue(ReflectionNamedType $type, string $parameterType): mixed + { + return match (true) { + $type->isBuiltin() => match ($parameterType) { + 'int' => 0, + 'float' => 0.0, + 'bool' => false, + 'string' => '', + 'array' => [], + 'object' => new stdClass(), + default => throw new TypeError('Unsupported type ' . $parameterType), + }, + \class_exists($parameterType) && \method_exists($parameterType, 'empty') => $parameterType::empty(), + is_subclass_of($parameterType, DateTimeInterface::class) => new $parameterType('1970-01-01 00:00:00'), + $parameterType === Money::class => Money::zero(NumberFormatter::create(Locale::getDefault(), NumberFormatter::CURRENCY)->getTextAttribute(NumberFormatter::CURRENCY_CODE)), + \is_subclass_of($parameterType, UnitEnum::class) => collect($parameterType::cases())->first(), + \is_subclass_of($parameterType, Model::class) => $parameterType::make(), + default => new TypeError('Unsupported type ' . $parameterType), + }; + } +} diff --git a/src/Bag/Pipelines/Pipes/ProcessParameters.php b/src/Bag/Pipelines/Pipes/ProcessParameters.php index d813845..109e8f0 100644 --- a/src/Bag/Pipelines/Pipes/ProcessParameters.php +++ b/src/Bag/Pipelines/Pipes/ProcessParameters.php @@ -15,6 +15,9 @@ use Illuminate\Support\Collection; use ReflectionParameter; +/** + * Reflect Constructor Parameters + */ readonly class ProcessParameters { /** diff --git a/tests/Feature/BagTest.php b/tests/Feature/BagTest.php index 0ef6154..e2298f4 100644 --- a/tests/Feature/BagTest.php +++ b/tests/Feature/BagTest.php @@ -3,8 +3,13 @@ declare(strict_types=1); use Bag\Bag; +use Bag\Collection; use Bag\Exceptions\AdditionalPropertiesException; +use Illuminate\Support\Collection as LaravelCollection; +use Tests\Fixtures\Collections\ExtendsBagWithCollectionCollection; +use Tests\Fixtures\Values\BagWithLotsOfTypes; use Tests\Fixtures\Values\BagWithSingleArrayParameter; +use Tests\Fixtures\Values\ExtendsTestBag; use Tests\Fixtures\Values\OptionalPropertiesBag; use Tests\Fixtures\Values\TestBag; @@ -69,7 +74,7 @@ $value = TestBag::from([ 'name' => null, 'age' => null, - 'email' => null + 'email' => null, ]); })->throws(\TypeError::class, 'Tests\Fixtures\Values\TestBag::__construct(): Argument #1 ($name) must be of type string, null given'); @@ -143,3 +148,81 @@ \ArgumentCountError::class, 'Tests\Fixtures\Values\TestBag::from(): Too many arguments passed, expected 3, got 4' ); + +test('it creates an empty bag', function () { + $value = TestBag::empty(); + + expect($value) + ->toBeInstanceOf(TestBag::class) + ->and($value->name)->toBe('') + ->and($value->age)->toBe(0) + ->and($value->email)->toBe(''); +}); + +test('it creates complex empty bag', function () { + $value = BagWithLotsOfTypes::empty(); + + expect($value) + ->toBeInstanceOf(BagWithLotsOfTypes::class) + ->and($value->name)->toBe('') + ->and($value->age)->toBe(0) + ->and($value->is_active)->toBeFalse() + ->and($value->price)->toBe(0.0) + ->and($value->items)->toBe([]) + ->and($value->object)->toBeInstanceOf(\stdClass::class) + ->and($value->mixed)->toBeNull() + ->and($value->bag)->toBeInstanceOf(TestBag::class) + ->and($value->bag->name)->toBe('') + ->and($value->bag->age)->toBe(0) + ->and($value->bag->email)->toBe('') + ->and($value->collection)->toBeInstanceOf(LaravelCollection::class) + ->and($value->collection->isEmpty())->toBeTrue() + ->and($value->nullable_string)->toBeNull() + ->and($value->nullable_int)->toBeNull() + ->and($value->nullable_bool)->toBeNull() + ->and($value->nullable_float)->toBeNull() + ->and($value->nullable_array)->toBeNull() + ->and($value->nullable_object)->toBeNull() + ->and($value->nullable_bag)->toBeNull() + ->and($value->nullable_collection)->toBeNull() + ->and($value->optional_string)->toBe('optional') + ->and($value->optional_int)->toBe(100) + ->and($value->optional_bool)->toBeTrue() + ->and($value->optional_float)->toBe(100.2) + ->and($value->optional_array)->toBe(['optional']) + ->and($value->optional_object)->toBeInstanceOf(\WeakMap::class) + ->and($value->optional_mixed)->toBeInstanceOf(\WeakMap::class) + ->and($value->optional_bag)->toBeInstanceOf(ExtendsTestBag::class) + ->and($value->optional_bag?->name)->toBe('Davey Shafik') + ->and($value->optional_bag?->age)->toBe(40) + ->and($value->optional_bag?->email)->toBe('davey@php.net') + ->and($value->optional_collection)->toBeInstanceOf(Collection::class) + ->and($value->optional_custom_collection)->toBeInstanceOf(ExtendsBagWithCollectionCollection::class); +}); + + +test('it creates partial bags', function () { + $value = TestBag::partial(name: 'Davey Shafik'); + + expect($value) + ->toBeInstanceOf(TestBag::class) + ->and($value->name)->toBe('Davey Shafik') + ->and($value->age)->toBe(0) + ->and($value->email)->toBe(''); + + $value = TestBag::partial(age: 40); + + expect($value) + ->toBeInstanceOf(TestBag::class) + ->and($value->name)->toBe('') + ->and($value->age)->toBe(40) + ->and($value->email)->toBe(''); + + $value = TestBag::partial(email: 'davey@php.net'); + + expect($value) + ->toBeInstanceOf(TestBag::class) + ->and($value->name)->toBe('') + ->and($value->age)->toBe(0) + ->and($value->email)->toBe('davey@php.net'); +})->skip(!method_exists(Bag::class, 'partial')); diff --git a/tests/Fixtures/Collections/ExtendsBagWithCollectionCollection.php b/tests/Fixtures/Collections/ExtendsBagWithCollectionCollection.php new file mode 100644 index 0000000..3dd07ac --- /dev/null +++ b/tests/Fixtures/Collections/ExtendsBagWithCollectionCollection.php @@ -0,0 +1,9 @@ +toBeInstanceOf(TestBag::class) + ->and($bag->toArray())->toBe(['name' => '', 'age' => 0, 'email' => '']); +}); + +test('it creates partial bag', function () { + $input = new BagInput(TestBag::class, collect(['email' => 'davey@php.net'])); + + $bag = EmptyPipeline::process($input); + + expect($bag) + ->toBeInstanceOf(TestBag::class) + ->and($bag->toArray())->toBe(['name' => '', 'age' => 0, 'email' => 'davey@php.net']); +}); diff --git a/tests/Unit/Pipelines/Pipes/FillDefaultValuesTest.php b/tests/Unit/Pipelines/Pipes/FillDefaultValuesTest.php new file mode 100644 index 0000000..0df3b12 --- /dev/null +++ b/tests/Unit/Pipelines/Pipes/FillDefaultValuesTest.php @@ -0,0 +1,111 @@ + $input); + $input = (new MapInput())($input, fn (BagInput $input) => $input); + + $pipe = new FillDefaultValues(); + $input = $pipe($input); + + $input = (new FillBag())($input); + + /** @var BagWithLotsOfTypes $bag */ + $bag = $input->bag; + + expect($bag) + ->toBeInstanceOf(BagWithLotsOfTypes::class) + ->and($bag->name)->toBe('') + ->and($bag->age)->toBe(0) + ->and($bag->is_active)->toBeFalse() + ->and($bag->price)->toBe(0.0) + ->and($bag->items)->toBe([]) + ->and($bag->object)->toBeInstanceOf(\stdClass::class) + ->and($bag->mixed)->toBeNull() + ->and($bag->bag)->toBeInstanceOf(TestBag::class) + ->and($bag->bag->name)->toBe('') + ->and($bag->bag->age)->toBe(0) + ->and($bag->bag->email)->toBe('') + ->and($bag->collection)->toBeInstanceOf(LaravelCollection::class) + ->and($bag->collection->isEmpty())->toBeTrue() + ->and($bag->backed_enum)->toBe(TestBackedEnum::TEST_VALUE) + ->and($bag->unit_enum)->toBe(TestUnitEnum::TEST_VALUE) + ->and($bag->money)->toBeInstanceOf(Money::class) + ->and($bag->money->isZero())->toBeTrue() + ->and($bag->money->getCurrency()->getCurrencyCode())->toBe(\NumberFormatter::create(\Locale::getDefault(), \NumberFormatter::CURRENCY)->getTextAttribute(\NumberFormatter::CURRENCY_CODE)) + ->and($bag->date_time)->toBeInstanceOf(CarbonImmutable::class) + ->and($bag->date_time->equalTo(new CarbonImmutable('1970-01-01 00:00:00')))->toBeTrue() + ->and($bag->model)->toBeInstanceOf(TestModel::class) + ->and($bag->model->toArray())->toBe(TestModel::make()->toArray()) + ->and($bag->nullable_string)->toBeNull() + ->and($bag->nullable_int)->toBeNull() + ->and($bag->nullable_bool)->toBeNull() + ->and($bag->nullable_float)->toBeNull() + ->and($bag->nullable_array)->toBeNull() + ->and($bag->nullable_object)->toBeNull() + ->and($bag->nullable_bag)->toBeNull() + ->and($bag->nullable_collection)->toBeNull() + ->and($bag->nullable_backed_enum)->toBeNull() + ->and($bag->nullable_unit_enum)->toBeNull() + ->and($bag->nullable_money)->toBeNull() + ->and($bag->nullable_date_time)->toBeNull() + ->and($bag->nullable_model)->toBeNull() + ->and($bag->optional_string)->toBe('optional') + ->and($bag->optional_int)->toBe(100) + ->and($bag->optional_bool)->toBeTrue() + ->and($bag->optional_float)->toBe(100.2) + ->and($bag->optional_array)->toBe(['optional']) + ->and($bag->optional_object)->toBeInstanceOf(\WeakMap::class) + ->and($bag->optional_mixed)->toBeInstanceOf(\WeakMap::class) + ->and($bag->optional_bag)->toBeInstanceOf(ExtendsTestBag::class) + ->and($bag->optional_bag?->name)->toBe('Davey Shafik') + ->and($bag->optional_bag?->age)->toBe(40) + ->and($bag->optional_bag?->email)->toBe('davey@php.net') + ->and($bag->optional_collection)->toBeInstanceOf(Collection::class) + ->and($bag->optional_custom_collection)->toBeInstanceOf(ExtendsBagWithCollectionCollection::class) + ->and($bag->optional_backed_enum)->toBe(TestBackedEnum::TEST_VALUE) + ->and($bag->optional_unit_enum)->toBe(TestUnitEnum::TEST_VALUE) + ->and($bag->optional_date_time)->toBeInstanceOf(CarbonImmutable::class) + ->and($bag->optional_date_time->equalTo(new CarbonImmutable('1984-05-31 00:00:00')))->toBeTrue() + ; +}); + +test('it creates partial bag instance', function () { + $input = new BagInput(TestBag::class, collect(['email' => 'davey@php.net'])); + $input = (new ProcessParameters())($input, fn (BagInput $input) => $input); + $input = (new MapInput())($input, fn (BagInput $input) => $input); + + $pipe = new FillDefaultValues(); + $input = $pipe($input); + + $input = (new FillBag())($input); + + /** @var TestBag $bag */ + $bag = $input->bag; + + expect($bag) + ->toBeInstanceOf(TestBag::class) + ->and($bag->name)->toBe('') + ->and($bag->age)->toBe(0) + ->and($bag->email)->toBe('davey@php.net'); +});