diff --git a/src/Attributes/MapName.php b/src/Attributes/MapName.php index 316e28cbe..d0f8779f1 100644 --- a/src/Attributes/MapName.php +++ b/src/Attributes/MapName.php @@ -3,11 +3,12 @@ namespace Spatie\LaravelData\Attributes; use Attribute; +use Spatie\LaravelData\Mappers\NameMapper; #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)] class MapName { - public function __construct(public string|int $input, public string|int|null $output = null) + public function __construct(public string|int|NameMapper $input, public string|int|NameMapper|null $output = null) { $this->output ??= $this->input; } diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index 53b4d6185..28ead4820 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -85,14 +85,6 @@ public static function prepareForPipeline(array $properties): array return $properties; } - public function getMorphClass(): string - { - /** @var class-string $class */ - $class = static::class; - - return app(DataConfig::class)->morphMap->getDataClassAlias($class) ?? $class; - } - public function __sleep(): array { $dataClass = app(DataConfig::class)->getDataClass(static::class); diff --git a/src/Concerns/EnumerableMethods.php b/src/Concerns/EnumerableMethods.php index a35bd680b..b5572bc84 100644 --- a/src/Concerns/EnumerableMethods.php +++ b/src/Concerns/EnumerableMethods.php @@ -13,6 +13,8 @@ trait EnumerableMethods { /** + * @deprecated In v5, use a regular Laravel collection instead + * * @param callable(TValue, TKey): TValue $through * * @return static @@ -27,6 +29,8 @@ public function through(callable $through): static } /** + * @deprecated In v5, use a regular Laravel collection instead + * * @param callable(TValue, TKey): TValue $map * * @return static @@ -37,6 +41,8 @@ public function map(callable $map): static } /** + * @deprecated In v5, use a regular Laravel collection instead + * * @param callable(TValue): bool $filter * * @return static @@ -51,6 +57,8 @@ public function filter(callable $filter): static } /** + * @deprecated In v5, use a regular Laravel collection instead + * * @param callable(TValue): bool $filter * * @return static @@ -65,6 +73,8 @@ public function reject(callable $filter): static } /** + * @deprecated In v5, use a regular Laravel collection instead + * * @template TFirstDefault * * @param null| (callable(TValue,TKey): bool) $callback @@ -78,6 +88,8 @@ public function first(callable|null $callback = null, $default = null) } /** + * @deprecated In v5, use a regular Laravel collection instead + * * @template TLastDefault * * @param null| (callable(TValue,TKey): bool) $callback @@ -91,6 +103,8 @@ public function last(callable|null $callback = null, $default = null) } /** + * @deprecated In v5, use a regular Laravel collection instead + * * @param callable(TValue, TKey): mixed $callback * * @return static @@ -103,6 +117,8 @@ public function each(callable $callback): static } /** + * @deprecated In v5, use a regular Laravel collection instead + * * @return static */ public function values(): static @@ -114,6 +130,9 @@ public function values(): static return $cloned; } + /** + * @deprecated In v5, use a regular Laravel collection instead + */ public function where(string $key, mixed $operator = null, mixed $value = null): static { $cloned = clone $this; @@ -124,6 +143,8 @@ public function where(string $key, mixed $operator = null, mixed $value = null): } /** + * @deprecated In v5, use a regular Laravel collection instead + * * @template TReduceInitial * @template TReduceReturnType * @@ -138,6 +159,8 @@ public function reduce(callable $callback, mixed $initial = null) } /** + * @deprecated In v5, use a regular Laravel collection instead + * * @param (callable(TValue, TKey): bool)|string|null $key * @param mixed $operator * @param mixed $value @@ -153,6 +176,8 @@ public function sole(callable|string|null $key = null, mixed $operator = null, m } /** + * + * * @param DataCollection $collection * * @return static diff --git a/src/Contracts/BaseData.php b/src/Contracts/BaseData.php index 3675e5503..2faf9ae83 100644 --- a/src/Contracts/BaseData.php +++ b/src/Contracts/BaseData.php @@ -51,6 +51,4 @@ public static function normalizers(): array; public static function prepareForPipeline(array $properties): array; public static function pipeline(): DataPipeline; - - public function getMorphClass(): string; } diff --git a/src/Exceptions/DataMissingFeature.php b/src/Exceptions/DataMissingFeature.php deleted file mode 100644 index d9420263f..000000000 --- a/src/Exceptions/DataMissingFeature.php +++ /dev/null @@ -1,37 +0,0 @@ -filter(fn (string $interface) => in_array($interface, class_implements($dataClass))) - ->map(fn (string $interface) => Str::afterLast($interface, '\\')) - ->map(fn (string $interface) => "`{$interface}`") - ->join(', '); - - return new self("Feature `{$featureClass}` missing in data object `{$dataClass}` implementing {$implemented}"); - } -} diff --git a/src/Exceptions/InvalidDataClassMapper.php b/src/Exceptions/InvalidDataClassMapper.php deleted file mode 100644 index 85c3cdf8c..000000000 --- a/src/Exceptions/InvalidDataClassMapper.php +++ /dev/null @@ -1,22 +0,0 @@ -className}:{$target->name}" - : $target->name; - - return new self("`MapFrom` attribute on `{$target}` should be a class implementing `{$mapperClass}`"); - } -} diff --git a/src/LaravelDataServiceProvider.php b/src/LaravelDataServiceProvider.php index be487d3c4..1143b8177 100644 --- a/src/LaravelDataServiceProvider.php +++ b/src/LaravelDataServiceProvider.php @@ -34,12 +34,7 @@ public function packageRegistered(): void fn () => $this->app->make(DataStructureCache::class)->getConfig() ?? DataConfig::createFromConfig(config('data')) ); - /** @psalm-suppress UndefinedInterfaceMethod */ $this->app->beforeResolving(BaseData::class, function ($class, $parameters, $app) { - if ($app->has($class)) { - return; - } - $app->bind( $class, fn ($container) => $class::from($container['request']) diff --git a/src/Mappers/CamelCaseMapper.php b/src/Mappers/CamelCaseMapper.php index e75c67a5a..12aaf1929 100644 --- a/src/Mappers/CamelCaseMapper.php +++ b/src/Mappers/CamelCaseMapper.php @@ -8,10 +8,6 @@ class CamelCaseMapper implements NameMapper { public function map(int|string $name): string|int { - if (! is_string($name)) { - return $name; - } - return Str::camel($name); } } diff --git a/src/Mappers/ProvidedNameMapper.php b/src/Mappers/ProvidedNameMapper.php index 9a517b885..f003cf44c 100644 --- a/src/Mappers/ProvidedNameMapper.php +++ b/src/Mappers/ProvidedNameMapper.php @@ -12,9 +12,4 @@ public function map(int|string $name): string|int { return $this->name; } - - public function inverse(): NameMapper - { - return $this; - } } diff --git a/src/Mappers/SnakeCaseMapper.php b/src/Mappers/SnakeCaseMapper.php index 81da692d0..c9c6796d2 100644 --- a/src/Mappers/SnakeCaseMapper.php +++ b/src/Mappers/SnakeCaseMapper.php @@ -8,10 +8,6 @@ class SnakeCaseMapper implements NameMapper { public function map(int|string $name): string|int { - if (! is_string($name)) { - return $name; - } - return Str::snake($name); } } diff --git a/src/Mappers/StudlyCaseMapper.php b/src/Mappers/StudlyCaseMapper.php index 56ac998fd..0232ea5e7 100644 --- a/src/Mappers/StudlyCaseMapper.php +++ b/src/Mappers/StudlyCaseMapper.php @@ -8,10 +8,6 @@ class StudlyCaseMapper implements NameMapper { public function map(int|string $name): string|int { - if (! is_string($name)) { - return $name; - } - return Str::studly($name); } } diff --git a/src/Resolvers/DataCollectableFromSomethingResolver.php b/src/Resolvers/DataCollectableFromSomethingResolver.php index 7457c0020..2d66231a3 100644 --- a/src/Resolvers/DataCollectableFromSomethingResolver.php +++ b/src/Resolvers/DataCollectableFromSomethingResolver.php @@ -52,10 +52,6 @@ public function execute( $normalizedItems = $this->normalizeItems($items, $dataClass, $creationContext); - if(! $intoType->type instanceof NamedType) { - throw new Exception('Cannot collect into a union or intersection type'); - } - return match ($intoType->kind) { DataTypeKind::DataArray => $this->normalizeToArray($normalizedItems), DataTypeKind::DataEnumerable => new $intoType->type->name($this->normalizeToArray($normalizedItems)), diff --git a/src/Resolvers/NameMappersResolver.php b/src/Resolvers/NameMappersResolver.php index a85aad0c9..c3093d957 100644 --- a/src/Resolvers/NameMappersResolver.php +++ b/src/Resolvers/NameMappersResolver.php @@ -57,7 +57,7 @@ protected function resolveOutputNameMapper( return null; } - protected function resolveMapper(string|int $value): ?NameMapper + protected function resolveMapper(string|int|NameMapper $value): ?NameMapper { $mapper = $this->resolveMapperClass($value); @@ -70,12 +70,16 @@ protected function resolveMapper(string|int $value): ?NameMapper return $mapper; } - protected function resolveMapperClass(int|string $value): NameMapper + protected function resolveMapperClass(int|string|NameMapper $value): NameMapper { if (is_int($value)) { return new ProvidedNameMapper($value); } + if($value instanceof NameMapper){ + return $value; + } + if (is_a($value, NameMapper::class, true)) { return resolve($value); } diff --git a/src/Resolvers/RequestQueryStringPartialsResolver.php b/src/Resolvers/RequestQueryStringPartialsResolver.php index 7a69959c4..ead50f690 100644 --- a/src/Resolvers/RequestQueryStringPartialsResolver.php +++ b/src/Resolvers/RequestQueryStringPartialsResolver.php @@ -75,7 +75,7 @@ protected function validateSegments( ): ?array { $allowed = $type->getAllowedPartials($dataClass); - $segment = $partialSegments[0]; + $segment = $partialSegments[0] ?? null; if ($segment instanceof AllPartialSegment) { if ($allowed === null || $allowed === ['*']) { diff --git a/src/Support/Annotations/DataCollectableAnnotationReader.php b/src/Support/Annotations/DataCollectableAnnotationReader.php index 86e4a4725..54be10543 100644 --- a/src/Support/Annotations/DataCollectableAnnotationReader.php +++ b/src/Support/Annotations/DataCollectableAnnotationReader.php @@ -23,11 +23,6 @@ class DataCollectableAnnotationReader /** @var array */ protected static array $contexts = []; - public static function create(): self - { - return new self(); - } - /** @return array */ public function getForClass(ReflectionClass $class): array { @@ -172,46 +167,6 @@ protected function resolveDataClass( return null; } - protected function resolveCollectionClass( - ReflectionProperty|ReflectionClass|ReflectionMethod $reflection, - string $class - ): ?string { - if (str_contains($class, '|')) { - foreach (explode('|', $class) as $explodedClass) { - if ($foundClass = $this->resolveCollectionClass($reflection, $explodedClass)) { - return $foundClass; - } - } - - return null; - } - - if ($class === 'array') { - return $class; - } - - $class = ltrim($class, '\\'); - - if (is_a($class, BaseDataCollectable::class, true) - || is_a($class, Enumerable::class, true) - || is_a($class, AbstractPaginator::class, true) - || is_a($class, CursorPaginator::class, true) - ) { - return $class; - } - - $class = $this->resolveFcqn($reflection, $class); - - if (is_a($class, BaseDataCollectable::class, true) - || is_a($class, Enumerable::class, true) - || is_a($class, AbstractPaginator::class, true) - || is_a($class, CursorPaginator::class, true)) { - return $class; - } - - return null; - } - protected function resolveFcqn( ReflectionProperty|ReflectionClass|ReflectionMethod $reflection, string $class diff --git a/src/Support/DataClassMorphMap.php b/src/Support/DataClassMorphMap.php index 660aa02da..d1e604a7e 100644 --- a/src/Support/DataClassMorphMap.php +++ b/src/Support/DataClassMorphMap.php @@ -30,15 +30,8 @@ public function add( /** * @param array> $map */ - public function merge(array|DataClassMorphMap $map): self + public function merge(array $map): self { - if ($map instanceof DataClassMorphMap) { - $map->map = array_merge($this->map, $map->map); - $map->reversedMap = array_merge($this->reversedMap, $map->reversedMap); - - return $this; - } - foreach ($map as $alias => $class) { $this->add($alias, $class); } diff --git a/src/Support/Factories/DataClassFactory.php b/src/Support/Factories/DataClassFactory.php index 78fa6d266..2247a219d 100644 --- a/src/Support/Factories/DataClassFactory.php +++ b/src/Support/Factories/DataClassFactory.php @@ -121,7 +121,10 @@ protected function resolveMethods( ): Collection { return collect($reflectionClass->getMethods()) ->filter(fn (ReflectionMethod $reflectionMethod) => str_starts_with($reflectionMethod->name, 'from') || str_starts_with($reflectionMethod->name, 'collect')) - ->reject(fn (ReflectionMethod $reflectionMethod) => in_array($reflectionMethod->name, ['from', 'collect', 'collection'])) + ->reject(fn (ReflectionMethod $reflectionMethod) => in_array($reflectionMethod->name, ['from', 'collect', 'collection']) + || $reflectionMethod->isStatic() === false + || $reflectionMethod->isPublic() === false + ) ->mapWithKeys( fn (ReflectionMethod $reflectionMethod) => [$reflectionMethod->name => $this->methodFactory->build($reflectionMethod, $reflectionClass)], ); diff --git a/src/Support/Factories/DataMethodFactory.php b/src/Support/Factories/DataMethodFactory.php index 6f34fa5d8..314d4e639 100644 --- a/src/Support/Factories/DataMethodFactory.php +++ b/src/Support/Factories/DataMethodFactory.php @@ -73,15 +73,6 @@ protected function resolveCustomCreationMethodType( ReflectionMethod $method, ?DataType $returnType, ): CustomCreationMethodType { - if (! $method->isStatic() - || ! $method->isPublic() - || $method->name === 'from' - || $method->name === 'collect' - || $method->name === 'collection' - ) { - return CustomCreationMethodType::None; - } - if (str_starts_with($method->name, 'from')) { return CustomCreationMethodType::Object; } diff --git a/src/Support/Factories/DataTypeFactory.php b/src/Support/Factories/DataTypeFactory.php index 689a6b9d0..322240ab9 100644 --- a/src/Support/Factories/DataTypeFactory.php +++ b/src/Support/Factories/DataTypeFactory.php @@ -42,7 +42,7 @@ public function __construct( public function buildProperty( ?ReflectionType $reflectionType, ReflectionClass|string $class, - ReflectionMethod|ReflectionProperty|ReflectionParameter|string $typeable, + ReflectionProperty|ReflectionParameter|string $typeable, ?Collection $attributes = null, ?DataCollectableAnnotation $classDefinedDataCollectableAnnotation = null, ): DataPropertyType { @@ -70,7 +70,7 @@ classDefinedDataCollectableAnnotation: $classDefinedDataCollectableAnnotation, public function build( ?ReflectionType $reflectionType, ReflectionClass|string $class, - ReflectionMethod|ReflectionProperty|ReflectionParameter|string $typeable, + ReflectionProperty|ReflectionParameter|string $typeable, ): DataType { $properties = $this->infer( reflectionType: $reflectionType, diff --git a/tests/CreationTest.php b/tests/CreationTest.php index 44e44d334..8cfe775f5 100644 --- a/tests/CreationTest.php +++ b/tests/CreationTest.php @@ -3,6 +3,7 @@ use Carbon\Carbon; use Carbon\CarbonImmutable; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Validation\ValidationException; use Spatie\LaravelData\Attributes\Computed; @@ -25,6 +26,7 @@ use Spatie\LaravelData\Tests\Fakes\Casts\ContextAwareCast; use Spatie\LaravelData\Tests\Fakes\Casts\StringToUpperCast; use Spatie\LaravelData\Tests\Fakes\ComplicatedData; +use Spatie\LaravelData\Tests\Fakes\DataCollections\CustomCursorPaginatedDataCollection; use Spatie\LaravelData\Tests\Fakes\DataCollections\CustomDataCollection; use Spatie\LaravelData\Tests\Fakes\DataCollections\CustomPaginatedDataCollection; use Spatie\LaravelData\Tests\Fakes\EnumData; @@ -183,6 +185,7 @@ }); it('can optionally create data', function () { + expect(SimpleData::optional())->toBeNull(); expect(SimpleData::optional(null))->toBeNull(); expect(new SimpleData('Hello world'))->toEqual( SimpleData::optional(['string' => 'Hello world']) @@ -760,6 +763,22 @@ public function __construct(public string $string) expect($collection)->toBeInstanceOf(CustomPaginatedDataCollection::class); }); +it('can return a custom cursor paginated data collection when collecting data', function () { + $class = new class ('') extends Data implements DeprecatedDataContract { + use WithDeprecatedCollectionMethod; + + protected static string $_cursorPaginatedCollectionClass = CustomCursorPaginatedDataCollection::class; + + public function __construct(public string $string) + { + } + }; + + $collection = $class::collection(new CursorPaginator([['string' => 'A'], ['string' => 'B']], 2)); + + expect($collection)->toBeInstanceOf(CustomCursorPaginatedDataCollection::class); +}); + it('will allow a nested data object to cast properties however it wants', function () { $model = new DummyModel(['id' => 10]); diff --git a/tests/DataTest.php b/tests/DataTest.php index 47edb71cb..3c98c93f8 100644 --- a/tests/DataTest.php +++ b/tests/DataTest.php @@ -26,6 +26,7 @@ use Spatie\LaravelData\Tests\Fakes\SimpleDto; use Spatie\LaravelData\Tests\Fakes\SimpleResource; +use Symfony\Component\VarDumper\VarDumper; use function Spatie\Snapshots\assertMatchesSnapshot; it('also works by using traits and interfaces, skipping the base data class', function () { diff --git a/tests/Fakes/DataCollections/CustomCursorPaginatedDataCollection.php b/tests/Fakes/DataCollections/CustomCursorPaginatedDataCollection.php new file mode 100644 index 000000000..202232b41 --- /dev/null +++ b/tests/Fakes/DataCollections/CustomCursorPaginatedDataCollection.php @@ -0,0 +1,10 @@ + 'Freek']); expect($data)->toEqual(new $class('Freek')); + + expect($data->toLivewire())->toEqual(['name' => 'Freek']); }); diff --git a/tests/MagicalCreationTest.php b/tests/MagicalCreationTest.php index edea8a5bf..b53f9ba66 100644 --- a/tests/MagicalCreationTest.php +++ b/tests/MagicalCreationTest.php @@ -3,10 +3,16 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Model; +use Illuminate\Pagination\CursorPaginator; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; +use Illuminate\Support\LazyCollection; +use Spatie\LaravelData\CursorPaginatedDataCollection; use Spatie\LaravelData\Data; use Spatie\LaravelData\DataCollection; +use Spatie\LaravelData\PaginatedDataCollection; use Spatie\LaravelData\Support\Creation\CreationContext; +use Spatie\LaravelData\Tests\Fakes\DataCollections\CustomDataCollection; use Spatie\LaravelData\Tests\Fakes\DataWithMultipleArgumentCreationMethod; use Spatie\LaravelData\Tests\Fakes\DummyDto; use Spatie\LaravelData\Tests\Fakes\EnumData; @@ -195,6 +201,16 @@ public static function collectCollection(Collection $items): array { return $items->all(); } + + public static function collectPaginator(LengthAwarePaginator $items): CursorPaginator + { + return new CursorPaginator($items->all(), $items->perPage()); + } + + public static function collectCursorPaginator(CursorPaginator $items): LengthAwarePaginator + { + return new LengthAwarePaginator($items->all(), $items->count(), $items->perPage()); + } }; expect($dataClass::collect(['a', 'b', 'c'])) @@ -220,6 +236,12 @@ public static function collectCollection(Collection $items): array $dataClass::from('b'), $dataClass::from('c'), ]); + + expect($dataClass::collect(new LengthAwarePaginator(['a', 'b', 'c'], 3, 15))) + ->toBeInstanceOf(CursorPaginator::class); + + expect($dataClass::collect(new CursorPaginator(['a', 'b', 'c'], 15))) + ->toBeInstanceOf(LengthAwarePaginator::class); }); it('can disable magically collecting data', function () { @@ -284,15 +306,128 @@ public static function collectArray(array $items): Collection $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); + 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), + SimpleData::from('a '.$dataClass::class), + SimpleData::from('b '.$dataClass::class), + SimpleData::from('c '.$dataClass::class), ]); }); + +it('can use a string to collect data into', function ( + string $into, + array|object $expected, +) { + expect(SimpleData::collect(['A', 'B'], $into))->toEqual($expected); +})->with(function(){ + yield 'array' => [ + 'array', + fn() => [ + SimpleData::from('A'), + SimpleData::from('B'), + ], + ]; + + yield 'laravel collection' => [ + Collection::class, + fn() => collect([ + SimpleData::from('A'), + SimpleData::from('B'), + ]), + ]; + + yield 'laravel lazy collection' => [ + LazyCollection::class, + fn() =>new LazyCollection([ + SimpleData::from('A'), + SimpleData::from('B'), + ]), + ]; + + yield 'data collection' => [ + DataCollection::class, + fn() => new DataCollection(SimpleData::class, [ + SimpleData::from('A'), + SimpleData::from('B'), + ]), + ]; + + yield 'data paginated collection' => [ + PaginatedDataCollection::class, + fn() => new PaginatedDataCollection(SimpleData::class,new LengthAwarePaginator( [ + SimpleData::from('A'), + SimpleData::from('B'), + ], 2, 15)), + ]; + + yield 'data cursor paginated collection' => [ + CursorPaginatedDataCollection::class, + fn() => new CursorPaginatedDataCollection(SimpleData::class,new CursorPaginator( [ + SimpleData::from('A'), + SimpleData::from('B'), + ], 15)), + ]; + + yield 'paginator' => [ + LengthAwarePaginator::class, + fn() => new LengthAwarePaginator([ + SimpleData::from('A'), + SimpleData::from('B'), + ], 2, 15), + ]; + + yield 'cursor paginator' => [ + CursorPaginator::class, + fn() => new CursorPaginator([ + SimpleData::from('A'), + SimpleData::from('B'), + ], 15), + ]; + + yield 'custom data collection' => [ + CustomDataCollection::class, + fn() => new CustomDataCollection(SimpleData::class, [ + SimpleData::from('A'), + SimpleData::from('B'), + ]), + ]; +}); + +it('can specifically select the correct collect method using an into return type', function () { + $dataClass = new class ('') extends SimpleData { + public static function collectArray(array $items): array + { + return array_map( + fn (SimpleData $data) => new SimpleData(strtoupper($data->string)), + $items + ); + } + + public static function collectCollection(array $items): Collection + { + return collect(array_map( + fn (SimpleData $data) => new SimpleData(strtolower($data->string)), + $items + )); + } + }; + + expect($dataClass::collect(['Hello', 'World'], 'array')) + ->toBeArray() + ->toEqual([SimpleData::from('HELLO'), SimpleData::from('WORLD')]); + + expect($dataClass::collect(['Hello', 'World'], Collection::class)) + ->toBeInstanceOf(Collection::class) + ->all()->toEqual([SimpleData::from('hello'), SimpleData::from('world')]); +}); + +it('can only collect arrays/collections/paginators', function () { + $storage = new SplObjectStorage(); + + expect(fn () => SimpleData::collect($storage))->toThrow(Exception::class, 'Unable to normalize items'); +}); diff --git a/tests/MappingTest.php b/tests/MappingTest.php index ebc2c5a83..6736b3335 100644 --- a/tests/MappingTest.php +++ b/tests/MappingTest.php @@ -2,9 +2,13 @@ use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\MapInputName; +use Spatie\LaravelData\Attributes\MapName; use Spatie\LaravelData\Attributes\MapOutputName; use Spatie\LaravelData\Data; +use Spatie\LaravelData\Mappers\CamelCaseMapper; +use Spatie\LaravelData\Mappers\ProvidedNameMapper; use Spatie\LaravelData\Mappers\SnakeCaseMapper; +use Spatie\LaravelData\Mappers\StudlyCaseMapper; use Spatie\LaravelData\Support\Transformation\TransformationContextFactory; use Spatie\LaravelData\Tests\Fakes\DataWithMapper; use Spatie\LaravelData\Tests\Fakes\SimpleData; @@ -312,3 +316,39 @@ public function __construct( 'But not too expensive!', ])); }); + +it('has a mappers built in', function (){ + $data = new class extends Data + { + #[MapName(CamelCaseMapper::class)] + public string $camel_case = 'camelCase'; + + #[MapName(SnakeCaseMapper::class)] + public string $snakeCase = 'snake_case'; + + #[MapName(StudlyCaseMapper::class)] + public string $studly_case = 'StudlyCase'; + + #[MapName(new ProvidedNameMapper('i_provided'))] + public string $provided = 'provided'; + }; + + expect($data->toArray())->toEqual([ + 'camelCase' => 'camelCase', + 'snake_case' => 'snake_case', + 'StudlyCase' => 'StudlyCase', + 'i_provided' => 'provided', + ]); + + expect($data::from([ + 'camelCase' => 'camelCase', + 'snake_case' => 'snake_case', + 'StudlyCase' => 'StudlyCase', + 'i_provided' => 'provided', + ])) + ->camel_case->toBe('camelCase') + ->snakeCase->toBe('snake_case') + ->studly_case->toBe('StudlyCase') + ->provided->toBe('provided'); +}); + diff --git a/tests/Normalizers/FormRequestNormalizerTest.php b/tests/Normalizers/FormRequestNormalizerTest.php index 7c5606c3f..eafe68276 100644 --- a/tests/Normalizers/FormRequestNormalizerTest.php +++ b/tests/Normalizers/FormRequestNormalizerTest.php @@ -1,11 +1,24 @@ set('data.normalizers', [FormRequestNormalizer::class]); + config()->set('data.normalizers', [FormRequestNormalizer::class, ArrayNormalizer::class]); +}); + +it('will not normalize any other thing than a FormRequest', function () { + $data = DataWithNullable::from([ + 'string' => 'Hello', + 'nullableString' => 'World', + ]); + + expect($data->toArray())->toEqual([ + 'string' => 'Hello', + 'nullableString' => 'World', + ]); }); it('can create a data object from FormRequest', function () { diff --git a/tests/PartialsTest.php b/tests/PartialsTest.php index d5a15a695..76b32c328 100644 --- a/tests/PartialsTest.php +++ b/tests/PartialsTest.php @@ -1296,6 +1296,46 @@ public static function allowedRequestIncludes(): ?array 'expectedPartials' => null, 'expectedResponse' => [], ]; + + yield 'with invalid partial definition' => [ + 'lazyDataAllowedIncludes' => null, + 'dataAllowedIncludes' => null, + 'includes' => '', + 'expectedPartials' => PartialsCollection::create(), + 'expectedResponse' => [], + ]; + + yield 'with non existing field' => [ + 'lazyDataAllowedIncludes' => [], + 'dataAllowedIncludes' => [], + 'includes' => 'non-existing', + 'expectedPartials' => PartialsCollection::create(), + 'expectedResponse' => [], + ]; + + yield 'with non existing nested field' => [ + 'lazyDataAllowedIncludes' => [], + 'dataAllowedIncludes' => [], + 'includes' => 'non-existing.still-non-existing', + 'expectedPartials' => PartialsCollection::create(), + 'expectedResponse' => [], + ]; + + yield 'with non allowed nested field' => [ + 'lazyDataAllowedIncludes' => [], + 'dataAllowedIncludes' => [], + 'includes' => 'nested.name', + 'expectedPartials' => PartialsCollection::create(), + 'expectedResponse' => [], + ]; + + yield 'with non allowed nested all' => [ + 'lazyDataAllowedIncludes' => [], + 'dataAllowedIncludes' => [], + 'includes' => 'nested.*', + 'expectedPartials' => PartialsCollection::create(), + 'expectedResponse' => [], + ]; }); it('can combine request and manual includes', function () { diff --git a/tests/Support/DataReturnTypeTest.php b/tests/Support/DataReturnTypeTest.php index 3c5394916..139ba8b15 100644 --- a/tests/Support/DataReturnTypeTest.php +++ b/tests/Support/DataReturnTypeTest.php @@ -10,6 +10,7 @@ use Spatie\LaravelData\Support\DataType; use Spatie\LaravelData\Support\Factories\DataReturnTypeFactory; use Spatie\LaravelData\Support\Types\NamedType; +use Spatie\LaravelData\Support\Types\UnionType; use Spatie\LaravelData\Tests\Fakes\SimpleData; class TestReturnTypeSubject @@ -33,13 +34,23 @@ public function nullableArray(): ?array { } + + public function union(): array|Collection + { + + } + + public function none() + { + + } } it('can determine the return type from reflection', function ( string $methodName, string $typeName, mixed $value, - DataType $expected + ?DataType $expected ) { $factory = app(DataReturnTypeFactory::class); @@ -115,6 +126,43 @@ public function nullableArray(): ?array ]; }); +it('will return null when a method does not have a return type', function (){ + $factory = app(DataReturnTypeFactory::class); + + $reflection = new ReflectionMethod(\TestReturnTypeSubject::class, 'none'); + + expect($factory->build($reflection, TestReturnTypeSubject::class))->toBeNull(); +}); + +it('can handle union types', function (){ + $factory = app(DataReturnTypeFactory::class); + + $reflection = new ReflectionMethod(\TestReturnTypeSubject::class, 'union'); + + expect($factory->build($reflection, TestReturnTypeSubject::class))->toEqual( + new DataType( + type: new UnionType([ + new NamedType(Collection::class, false, [ + ArrayAccess::class, + CanBeEscapedWhenCastToString::class, + Enumerable::class, + Traversable::class, + Stringable::class, + JsonSerializable::class, + Jsonable::class, + IteratorAggregate::class, + Countable::class, + Arrayable::class, + ], DataTypeKind::DataEnumerable, null, null), + new NamedType('array', true, [], DataTypeKind::DataArray, null, null), + ]), + isNullable: false, + isMixed: false, + kind: DataTypeKind::DataEnumerable, // in the future this should be an array ... + ), + ); +}); + it('will store return types in the factory as a caching mechanism', function () { $factory = app(DataReturnTypeFactory::class); diff --git a/tests/Transformers/ArrayableTransformerTest.php b/tests/Transformers/ArrayableTransformerTest.php new file mode 100644 index 000000000..defed45c1 --- /dev/null +++ b/tests/Transformers/ArrayableTransformerTest.php @@ -0,0 +1,27 @@ +transform( + FakeDataStructureFactory::property($class, 'arrayable'), + $class->arrayable, + TransformationContextFactory::create()->get($class) + ) + )->toEqual(['A', 'B']); +}); diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index 65077c325..2a692296a 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Application; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Validator as ValidatorFacade; use Illuminate\Validation\Rules\Enum; use Illuminate\Validation\Rules\Exists as LaravelExists; @@ -712,6 +713,7 @@ class TestDataWithRootReferenceFieldValidationAttribute extends Data ->assertOk(['collection' => []]) ->assertErrors(['collection' => null]) ->assertErrors([]) + ->assertErrors(['collection' => ['strings', 'here', 'instead', 'of', 'arrays']]) ->assertErrors([ 'collection' => [ ['other_string' => 'Hello World'], @@ -1986,6 +1988,44 @@ public static function redirect(FakeInjectable $injectable): string ); }); +it('can manually set the redirect route', function () { + Route::get('/never-given-up', fn () => 'Never gonna give you up')->name('never-given-up'); + + $data = new class () extends Data { + public string $name; + + public static function redirectRoute(): string + { + return 'never-given-up'; + } + }; + + DataValidationAsserter::for($data)->assertRedirect( + payload: ['name' => null], + redirect: 'http://localhost/never-given-up' + ); +}); + +it('can resolve validation dependencies for redirect route', function () { + FakeInjectable::setup('Rick Astley'); + + Route::get('/never-given-up', fn () => 'Never gonna give you up')->name('never-given-up'); + + $data = new class () extends Data { + public string $name; + + public static function redirectRoute(FakeInjectable $injectable): string + { + return $injectable->value === 'Rick Astley' ? 'never-given-up' : 'given-up'; + } + }; + + DataValidationAsserter::for($data)->assertRedirect( + payload: ['name' => null], + redirect: 'http://localhost/never-given-up' + ); +}); + it('can manually specify the validator', function () { $dataClass = new class () extends Data { public string $property;