Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/basic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions src/Bag/Bag.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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])) {
Expand Down
32 changes: 32 additions & 0 deletions src/Bag/Pipelines/EmptyPipeline.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Bag\Pipelines;

use Bag\Bag;
use Bag\Pipelines\Pipes\FillBag;
use Bag\Pipelines\Pipes\FillDefaultValues;
use Bag\Pipelines\Pipes\ProcessParameters;
use Bag\Pipelines\Values\BagInput;
use League\Pipeline\Pipeline;

readonly class EmptyPipeline
{
/**
* @template T of Bag
* @param BagInput<T> $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;
}
}
77 changes: 77 additions & 0 deletions src/Bag/Pipelines/Pipes/FillDefaultValues.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace Bag\Pipelines\Pipes;

use Bag\Bag;
use Bag\Pipelines\Values\BagInput;
use Bag\Property\Value;
use Brick\Money\Money;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Locale;
use NumberFormatter;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionProperty;
use stdClass;
use TypeError;
use UnitEnum;

readonly class FillDefaultValues
{
/**
* @template T of Bag
* @param BagInput<T> $input
* @return BagInput<T>
*/
public function __invoke(BagInput $input): BagInput
{
/** @var Collection<array-key, mixed> $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),
};
}
}
3 changes: 3 additions & 0 deletions src/Bag/Pipelines/Pipes/ProcessParameters.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
use Illuminate\Support\Collection;
use ReflectionParameter;

/**
* Reflect Constructor Parameters
*/
readonly class ProcessParameters
{
/**
Expand Down
85 changes: 84 additions & 1 deletion tests/Feature/BagTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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'));
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Tests\Fixtures\Collections;

class ExtendsBagWithCollectionCollection extends BagWithCollectionCollection
{
}
47 changes: 34 additions & 13 deletions tests/Fixtures/Values/BagWithLotsOfTypes.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@
use Bag\Attributes\MapName;
use Bag\Bag;
use Bag\Casts\CollectionOf;
use Bag\Collection;
use Bag\Mappers\SnakeCase;
use Illuminate\Support\Collection;
use Brick\Money\Money;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection as LaravelCollection;
use Tests\Fixtures\Collections\BagWithCollectionCollection;
use Tests\Fixtures\Collections\ExtendsBagWithCollectionCollection;
use Tests\Fixtures\Enums\TestBackedEnum;
use Tests\Fixtures\Enums\TestUnitEnum;
use Tests\Fixtures\Models\TestModel;
use WeakMap;

#[MapName(SnakeCase::class)]
readonly class BagWithLotsOfTypes extends Bag
Expand All @@ -24,26 +32,39 @@ public function __construct(
public object $object,
public mixed $mixed,
public TestBag $bag,
public Collection $collection,
public LaravelCollection $collection,
public TestBackedEnum $backed_enum,
public TestUnitEnum $unit_enum,
public Money $money,
public CarbonImmutable $date_time,
public TestModel $model,
public ?string $nullable_string,
public ?int $nullable_int,
public ?bool $nullable_bool,
public ?float $nullable_float,
public ?array $nullable_array,
public ?object $nullable_object,
public ?TestBag $nullable_bag,
public ?Collection $nullable_collection,
public ?string $optional_string = null,
public ?int $optional_int = null,
public ?bool $optional_bool = null,
public ?float $optional_float = null,
public ?array $optional_array = null,
public ?object $optional_object = null,
public mixed $optional_mixed = null,
public ?TestBag $optional_bag = null,
public ?Collection $optional_collection = null,
public ?LaravelCollection $nullable_collection,
public ?TestBackedEnum $nullable_backed_enum,
public ?TestUnitEnum $nullable_unit_enum,
public ?Money $nullable_money,
public ?CarbonImmutable $nullable_date_time,
public ?TestModel $nullable_model,
public ?string $optional_string = 'optional',
public ?int $optional_int = 100,
public ?bool $optional_bool = true,
public ?float $optional_float = 100.2,
public ?array $optional_array = ['optional'],
public ?object $optional_object = new WeakMap(),
public mixed $optional_mixed = new WeakMap(),
public ?TestBag $optional_bag = new ExtendsTestBag('Davey Shafik', 40, 'davey@php.net'),
public ?LaravelCollection $optional_collection = new Collection(),
#[Cast(CollectionOf::class, TestBag::class)]
public ?BagWithCollectionCollection $optional_custom_collection = null,
public ?BagWithCollectionCollection $optional_custom_collection = new ExtendsBagWithCollectionCollection(),
public ?TestBackedEnum $optional_backed_enum = TestBackedEnum::TEST_VALUE,
public ?TestUnitEnum $optional_unit_enum = TestUnitEnum::TEST_VALUE,
public ?CarbonImmutable $optional_date_time = new CarbonImmutable('1984-05-31 00:00:00'),
) {
}
}
9 changes: 9 additions & 0 deletions tests/Fixtures/Values/ExtendsTestBag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Tests\Fixtures\Values;

readonly class ExtendsTestBag extends TestBag
{
}
Loading