From 1c1bde5069d77de25132be13b24de9bbde2e5767 Mon Sep 17 00:00:00 2001 From: Ruben Van Assche Date: Tue, 16 Jan 2024 15:06:05 +0100 Subject: [PATCH] wip --- .../DataCollectableFromSomethingResolver.php | 43 +++- src/Resolvers/DataFromSomethingResolver.php | 22 +- src/Support/DataMethod.php | 4 + src/Support/DataParameter.php | 9 +- tests/CreationTest.php | 94 -------- tests/MagicalCreationTest.php | 223 +++++++++++++++++- tests/Support/DataParameterTest.php | 29 ++- 7 files changed, 303 insertions(+), 121 deletions(-) diff --git a/src/Resolvers/DataCollectableFromSomethingResolver.php b/src/Resolvers/DataCollectableFromSomethingResolver.php index 309950e7a..4a75a109d 100644 --- a/src/Resolvers/DataCollectableFromSomethingResolver.php +++ b/src/Resolvers/DataCollectableFromSomethingResolver.php @@ -78,8 +78,17 @@ protected function createFromCustomCreationMethod( $method = $this->dataConfig ->getDataClass($dataClass) ->methods - ->filter(function (DataMethod $method) use ($into, $items) { - if ($method->customCreationMethodType !== CustomCreationMethodType::Collection) { + ->filter(function (DataMethod $method) use ($creationContext, $into, $items) { + if ( + $method->customCreationMethodType !== CustomCreationMethodType::Collection + ) { + return false; + } + + if ( + $creationContext->ignoredMagicalMethods !== null + && in_array($method->name, $creationContext->ignoredMagicalMethods) + ) { return false; } @@ -87,24 +96,32 @@ protected function createFromCustomCreationMethod( return false; } - return $method->accepts([$items]); + return $method->accepts($items); }) ->first(); - if ($method !== null) { - return $dataClass::{$method->name}( - array_map($this->itemsToDataClosure($dataClass, $creationContext), $items) - ); + if ($method === null) { + return null; + } + + $payload = []; + + foreach ($method->parameters as $parameter) { + if ($parameter->isCreationContext) { + $payload[$parameter->name] = $creationContext; + } else { + $payload[$parameter->name] = $this->normalizeItems($items, $dataClass, $creationContext); + } } - return null; + return $dataClass::{$method->name}(...$payload); } protected function normalizeItems( mixed $items, string $dataClass, CreationContext $creationContext, - ): array|Paginator|AbstractPaginator|CursorPaginator|AbstractCursorPaginator { + ): array|Paginator|AbstractPaginator|CursorPaginator|AbstractCursorPaginator|Enumerable { if ($items instanceof PaginatedDataCollection || $items instanceof CursorPaginatedDataCollection || $items instanceof DataCollection @@ -120,7 +137,7 @@ protected function normalizeItems( } if ($items instanceof Enumerable) { - $items = $items->all(); + return $items->map($this->itemsToDataClosure($dataClass, $creationContext)); } if (is_array($items)) { @@ -134,8 +151,12 @@ protected function normalizeItems( } protected function normalizeToArray( - array|Paginator|AbstractPaginator|CursorPaginator|AbstractCursorPaginator $items, + array|Paginator|AbstractPaginator|CursorPaginator|AbstractCursorPaginator|Enumerable $items, ): array { + if ($items instanceof Enumerable) { + return $items->all(); + } + return is_array($items) ? $items : $items->items(); diff --git a/src/Resolvers/DataFromSomethingResolver.php b/src/Resolvers/DataFromSomethingResolver.php index 953621e0f..5317121b8 100644 --- a/src/Resolvers/DataFromSomethingResolver.php +++ b/src/Resolvers/DataFromSomethingResolver.php @@ -60,25 +60,28 @@ protected function createFromCustomCreationMethod( ->getDataClass($class) ->methods; - $methodName = null; + $method = null; foreach ($customCreationMethods as $customCreationMethod) { + if ($customCreationMethod->customCreationMethodType !== CustomCreationMethodType::Object) { + continue; + } + if ( - $customCreationMethod->customCreationMethodType === CustomCreationMethodType::Object - && $creationContext->ignoredMagicalMethods !== null + $creationContext->ignoredMagicalMethods !== null && in_array($customCreationMethod->name, $creationContext->ignoredMagicalMethods) ) { continue; } if ($customCreationMethod->accepts(...$payloads)) { - $methodName = $customCreationMethod->name; + $method = $customCreationMethod; break; } } - if ($methodName === null) { + if ($method === null) { return null; } @@ -86,10 +89,19 @@ protected function createFromCustomCreationMethod( foreach ($payloads as $payload) { if ($payload instanceof Request) { + // Solely for the purpose of validation $pipeline->execute($payload, $creationContext); } } + foreach ($method->parameters as $index => $parameter) { + if ($parameter->isCreationContext) { + $payloads[$index] = $creationContext; + } + } + + $methodName = $method->name; + return $class::$methodName(...$payloads); } } diff --git a/src/Support/DataMethod.php b/src/Support/DataMethod.php index c788ba26e..6ccca9d4f 100644 --- a/src/Support/DataMethod.php +++ b/src/Support/DataMethod.php @@ -105,6 +105,10 @@ public function accepts(mixed ...$input): bool ? $this->parameters : $this->parameters->mapWithKeys(fn (DataParameter|DataProperty $parameter) => [$parameter->name => $parameter]); + $parameters = $parameters->reject( + fn (DataParameter|DataProperty $parameter) => $parameter instanceof DataParameter && $parameter->isCreationContext + ); + if (count($input) > $parameters->count()) { return false; } diff --git a/src/Support/DataParameter.php b/src/Support/DataParameter.php index c7ed953d2..8643763da 100644 --- a/src/Support/DataParameter.php +++ b/src/Support/DataParameter.php @@ -3,6 +3,8 @@ namespace Spatie\LaravelData\Support; use ReflectionParameter; +use Spatie\LaravelData\Support\Creation\CreationContext; +use Spatie\LaravelData\Support\Types\SingleType; use Spatie\LaravelData\Support\Types\Type; class DataParameter @@ -13,6 +15,8 @@ public function __construct( public readonly bool $hasDefaultValue, public readonly mixed $defaultValue, public readonly Type $type, + // TODO: would be better if we refactor this to type, together with Castable, Lazy, etc + public readonly bool $isCreationContext, ) { } @@ -22,12 +26,15 @@ public static function create( ): self { $hasDefaultValue = $parameter->isDefaultValueAvailable(); + $type = Type::forReflection($parameter->getType(), $class); + return new self( $parameter->name, $parameter->isPromoted(), $hasDefaultValue, $hasDefaultValue ? $parameter->getDefaultValue() : null, - Type::forReflection($parameter->getType(), $class), + $type, + $type instanceof SingleType && $type->type->name === CreationContext::class ); } } diff --git a/tests/CreationTest.php b/tests/CreationTest.php index 3fa0cb133..7893413f6 100644 --- a/tests/CreationTest.php +++ b/tests/CreationTest.php @@ -579,42 +579,6 @@ public function __construct( ->toEqual('json:+{"nested":{"string":"Hello"},"string":"world","casted":"json:"}'); }); -it('can magically create a data object', function () { - $dataClass = new class ('', '') extends Data { - public function __construct( - public mixed $propertyA, - public mixed $propertyB, - ) { - } - - public static function fromStringWithDefault(string $a, string $b = 'World') - { - return new self($a, $b); - } - - public static function fromIntsWithDefault(int $a, int $b) - { - return new self($a, $b); - } - - public static function fromSimpleDara(SimpleData $data) - { - return new self($data->string, $data->string); - } - - public static function fromData(Data $data) - { - return new self('data', json_encode($data)); - } - }; - - expect($dataClass::from('Hello'))->toEqual(new $dataClass('Hello', 'World')) - ->and($dataClass::from('Hello', 'World'))->toEqual(new $dataClass('Hello', 'World')) - ->and($dataClass::from(42, 69))->toEqual(new $dataClass(42, 69)) - ->and($dataClass::from(SimpleData::from('Hello')))->toEqual(new $dataClass('Hello', 'Hello')) - ->and($dataClass::from(new EnumData(DummyBackedEnum::FOO)))->toEqual(new $dataClass('data', '{"enum":"foo"}')); -}); - it( 'will throw a custom exception when a data constructor cannot be called due to missing component', function () { @@ -762,32 +726,6 @@ public function __construct( ->toMatchArray($collectionA->toArray()); }); -it('will use magic methods when creating a collection of data objects', function () { - $dataClass = new class ('') extends Data { - public function __construct(public string $otherString) - { - } - - public static function fromSimpleData(SimpleData $simpleData): static - { - return new self($simpleData->string); - } - }; - - $collection = new DataCollection($dataClass::class, [ - SimpleData::from('A'), - SimpleData::from('B'), - ]); - - expect($collection[0]) - ->toBeInstanceOf($dataClass::class) - ->otherString->toEqual('A'); - - expect($collection[1]) - ->toBeInstanceOf($dataClass::class) - ->otherString->toEqual('B'); -}); - it('can return a custom data collection when collecting data', function () { $class = new class ('') extends Data implements DeprecatedDataContract { use WithDeprecatedCollectionMethod; @@ -823,38 +761,6 @@ public function __construct(public string $string) expect($collection)->toBeInstanceOf(CustomPaginatedDataCollection::class); }); -it('can magically collect data', function () { - class TestSomeCustomCollection extends Collection - { - } - - $dataClass = new class () extends Data { - public string $string; - - public static function fromString(string $string): self - { - $s = new self(); - - $s->string = $string; - - return $s; - } - - public static function collectArray(array $items): \TestSomeCustomCollection - { - return new \TestSomeCustomCollection($items); - } - }; - - expect($dataClass::collect(['a', 'b', 'c'])) - ->toBeInstanceOf(\TestSomeCustomCollection::class) - ->all()->toEqual([ - $dataClass::from('a'), - $dataClass::from('b'), - $dataClass::from('c'), - ]); -}); - it('will allow a nested data object to cast properties however it wants', function () { $model = new DummyModel(['id' => 10]); diff --git a/tests/MagicalCreationTest.php b/tests/MagicalCreationTest.php index 9f8b2a9bf..b894a2c85 100644 --- a/tests/MagicalCreationTest.php +++ b/tests/MagicalCreationTest.php @@ -3,15 +3,21 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; use Spatie\LaravelData\Data; +use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\Resolvers\DataFromSomethingResolver; +use Spatie\LaravelData\Support\Creation\CreationContext; use Spatie\LaravelData\Support\Creation\CreationContextFactory; use Spatie\LaravelData\Tests\Fakes\DataWithMultipleArgumentCreationMethod; use Spatie\LaravelData\Tests\Fakes\DummyDto; +use Spatie\LaravelData\Tests\Fakes\EnumData; +use Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnum; use Spatie\LaravelData\Tests\Fakes\Models\DummyModel; use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithCasts; +use Spatie\LaravelData\Tests\Fakes\SimpleData; -it('can create data from a custom method', function () { +it('can create data using a magical method', function () { $data = new class ('') extends Data { public function __construct(public string $string) { @@ -40,7 +46,43 @@ public static function fromArray(array $payload) ->and($data::from(DummyModelWithCasts::make(['string' => 'Hello World'])))->toEqual(new $data('Hello World')); }); -it('can create data from a custom method with an interface parameter', function () { +it('can magically create a data object', function () { + $dataClass = new class ('', '') extends Data { + public function __construct( + public mixed $propertyA, + public mixed $propertyB, + ) { + } + + public static function fromStringWithDefault(string $a, string $b = 'World') + { + return new self($a, $b); + } + + public static function fromIntsWithDefault(int $a, int $b) + { + return new self($a, $b); + } + + public static function fromSimpleDara(SimpleData $data) + { + return new self($data->string, $data->string); + } + + public static function fromData(Data $data) + { + return new self('data', json_encode($data)); + } + }; + + expect($dataClass::from('Hello'))->toEqual(new $dataClass('Hello', 'World')) + ->and($dataClass::from('Hello', 'World'))->toEqual(new $dataClass('Hello', 'World')) + ->and($dataClass::from(42, 69))->toEqual(new $dataClass(42, 69)) + ->and($dataClass::from(SimpleData::from('Hello')))->toEqual(new $dataClass('Hello', 'Hello')) + ->and($dataClass::from(new EnumData(DummyBackedEnum::FOO)))->toEqual(new $dataClass('data', '{"enum":"foo"}')); +}); + +it('can create data using a magical method with the interface of the value as type', function () { $data = new class ('') extends Data { public function __construct(public string $string) { @@ -64,7 +106,7 @@ public function toArray() expect($data::from($interfaceable))->toEqual(new $data('Rick Astley')); }); -it('can create data from a custom method with an inherit parameter', function () { +it('can create data using a magical method with the base class of the value as type', function () { $data = new class ('') extends Data { public function __construct(public string $string) { @@ -81,12 +123,12 @@ public static function fromModel(Model $model) expect($data::from($inherited))->toEqual(new $data('Rick Astley')); }); -it('can create data from a custom method with multiple parameters', function () { +it('can create data from a magical method with multiple parameters', function () { expect(DataWithMultipleArgumentCreationMethod::from('Rick Astley', 42)) ->toEqual(new DataWithMultipleArgumentCreationMethod('Rick Astley_42')); }); -it('can create data without custom creation methods', function () { +it('can disable the use of magical methods', function () { $data = new class ('', '') extends Data { public function __construct( public ?string $id, @@ -145,3 +187,174 @@ public static function fromArray(array $payload) ) )->toEqual(new DummyA(1, 'Taylor')); }); + +it('can inject the creation context when using a magical method', function () { + $dataClass = new class extends Data { + public function __construct( + public string $string = 'something' + ) { + } + + public static function fromArray(string $prefix, CreationContext $context) + { + return new self("{$prefix} {$context->dataClass}"); + } + }; + + expect($dataClass::from('Hi there')) + ->string->toBe('Hi there '.$dataClass::class); +}); + +it('will use magic methods when creating a collection of data objects', function () { + $dataClass = new class ('') extends Data { + public function __construct(public string $otherString) + { + } + + public static function fromSimpleData(SimpleData $simpleData): static + { + return new self($simpleData->string); + } + }; + + $collection = new DataCollection($dataClass::class, [ + SimpleData::from('A'), + SimpleData::from('B'), + ]); + + expect($collection[0]) + ->toBeInstanceOf($dataClass::class) + ->otherString->toEqual('A'); + + expect($collection[1]) + ->toBeInstanceOf($dataClass::class) + ->otherString->toEqual('B'); +}); + +it('can magically collect data', function () { + class TestSomeCustomCollection extends Collection + { + } + + $dataClass = new class () extends Data { + public string $string; + + public static function fromString(string $string): self + { + $s = new self(); + + $s->string = $string; + + return $s; + } + + public static function collectArray(array $items): \TestSomeCustomCollection + { + return new \TestSomeCustomCollection($items); + } + + public static function collectCollection(Collection $items): array + { + return $items->all(); + } + }; + + expect($dataClass::collect(['a', 'b', 'c'])) + ->toBeInstanceOf(\TestSomeCustomCollection::class) + ->all()->toEqual([ + $dataClass::from('a'), + $dataClass::from('b'), + $dataClass::from('c'), + ]); + + expect($dataClass::collect(collect(['a', 'b', 'c']))) + ->toBeArray() + ->toEqual([ + $dataClass::from('a'), + $dataClass::from('b'), + $dataClass::from('c'), + ]); + + expect($dataClass::collect(new TestSomeCustomCollection(['a', 'b', 'c']))) + ->toBeArray() + ->toEqual([ + $dataClass::from('a'), + $dataClass::from('b'), + $dataClass::from('c'), + ]); +}); + +it('can disable magically collecting data', function () { + $dataClass = new class ('') extends SimpleData { + public static function collectArray(array $items): Collection + { + return new Collection($items); + } + }; + + expect($dataClass::collect(['a', 'b', 'c'])) + ->toBeInstanceOf(Collection::class) + ->all()->toEqual([ + SimpleData::from('a'), + SimpleData::from('b'), + SimpleData::from('c'), + ]); + + expect($dataClass::factory()->withoutMagicalCreation()->collect([ + ['string' => 'a'], + ['string' => 'b'], + ['string' => 'c'] + ])) + ->toBeArray() + ->toEqual([ + new $dataClass('a'), + new $dataClass('b'), + new $dataClass('c'), + ]); +}); + +it('can disable specific magic collecting data methods', function () { + $dataClass = new class ('') extends SimpleData { + public static function collectArray(array $items): Collection + { + return new Collection($items); + } + }; + + expect($dataClass::collect(['a', 'b', 'c'])) + ->toBeInstanceOf(Collection::class) + ->all()->toEqual([ + SimpleData::from('a'), + SimpleData::from('b'), + SimpleData::from('c'), + ]); + + expect($dataClass::factory()->ignoreMagicalMethod('collectArray')->collect([ + ['string' => 'a'], + ['string' => 'b'], + ['string' => 'c'] + ])) + ->toBeArray() + ->toEqual([ + new $dataClass('a'), + new $dataClass('b'), + new $dataClass('c'), + ]); +}); + +it('can inject the creation context when collecting data with a magical method', function (){ + $dataClass = new class ('') extends SimpleData { + public static function collectArray(array $items, CreationContext $context): array + { + return array_map(fn(SimpleData $data) => new SimpleData($data->string . ' ' . $context->dataClass), $items); + } + }; + + expect($dataClass::collect(['a', 'b', 'c'])) + ->toBeArray() + ->toEqual([ + SimpleData::from('a ' . $dataClass::class), + SimpleData::from('b ' . $dataClass::class), + SimpleData::from('c ' . $dataClass::class), + ]); +}); diff --git a/tests/Support/DataParameterTest.php b/tests/Support/DataParameterTest.php index cf7c10229..bd23f78dd 100644 --- a/tests/Support/DataParameterTest.php +++ b/tests/Support/DataParameterTest.php @@ -1,15 +1,19 @@ get()) extends Data { public function __construct( string $nonPromoted, public $withoutType, public string $property, + CreationContext $creationContext, public string $propertyWithDefault = 'hello', ) { } @@ -23,7 +27,8 @@ public function __construct( ->isPromoted->toBeFalse() ->hasDefaultValue->toBeFalse() ->defaultValue->toBeNull() - ->type->toEqual(Type::forReflection($reflection->getType(), $class::class)); + ->type->toEqual(Type::forReflection($reflection->getType(), $class::class)) + ->isCreationContext->toBeFalse(); $reflection = new ReflectionParameter([$class::class, '__construct'], 'withoutType'); $parameter = DataParameter::create($reflection, $class::class); @@ -33,7 +38,8 @@ public function __construct( ->isPromoted->toBeTrue() ->hasDefaultValue->toBeFalse() ->defaultValue->toBeNull() - ->type->toEqual(Type::forReflection($reflection->getType(), $class::class)); + ->type->toEqual(Type::forReflection($reflection->getType(), $class::class)) + ->isCreationContext->toBeFalse(); $reflection = new ReflectionParameter([$class::class, '__construct'], 'property'); $parameter = DataParameter::create($reflection, $class::class); @@ -43,7 +49,19 @@ public function __construct( ->isPromoted->toBeTrue() ->hasDefaultValue->toBeFalse() ->defaultValue->toBeNull() - ->type->toEqual(Type::forReflection($reflection->getType(), $class::class)); + ->type->toEqual(Type::forReflection($reflection->getType(), $class::class)) + ->isCreationContext->toBeFalse(); + + $reflection = new ReflectionParameter([$class::class, '__construct'], 'creationContext'); + $parameter = DataParameter::create($reflection, $class::class); + + expect($parameter) + ->name->toEqual('creationContext') + ->isPromoted->toBeFalse() + ->hasDefaultValue->toBeFalse() + ->defaultValue->toBeNull() + ->type->toEqual(Type::forReflection($reflection->getType(), $class::class)) + ->isCreationContext->toBeTrue(); $reflection = new ReflectionParameter([$class::class, '__construct'], 'propertyWithDefault'); $parameter = DataParameter::create($reflection, $class::class); @@ -53,5 +71,6 @@ public function __construct( ->isPromoted->toBeTrue() ->hasDefaultValue->toBeTrue() ->defaultValue->toEqual('hello') - ->type->toEqual(Type::forReflection($reflection->getType(), $class::class)); + ->type->toEqual(Type::forReflection($reflection->getType(), $class::class)) + ->isCreationContext->toBeFalse(); });