diff --git a/UPGRADING.md b/UPGRADING.md index a062bfa55..8460d00fc 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -28,6 +28,7 @@ The following things are required when upgrading: - If you were using internal data structures like `DataClass` and `DataProperty` then take a look at what has been changed - The `DataCollectableTransformer` and `DataTransformer` were replaced with their appropriate resolvers - If you've cached the data structures, be sure to clear the cache +- In previous versions, when trying to include, exclude, only or except certain data properties that did not exist not exception was thrown. This is now the case, these exceptions can be silenced by setting `ignore_invalid_partials` to true within the config file We advise you to take a look at the following things: - Take a look within your data objects if `DataCollection`'s, `DataPaginatedCollection`'s and `DataCursorPaginatedCollection`'s can be replaced with regular arrays, Laravel Collections and Paginator diff --git a/config/data.php b/config/data.php index a1ae3c831..d0dca3e3f 100644 --- a/config/data.php +++ b/config/data.php @@ -93,5 +93,11 @@ * method. By default, only when a request is passed the data is being validated. This * behaviour can be changed to always validate or to completely disable validation. */ - 'validation_type' => \Spatie\LaravelData\Support\Creation\ValidationType::OnlyRequests->value + 'validation_type' => \Spatie\LaravelData\Support\Creation\ValidationType::OnlyRequests->value, + + /** + * When using an invalid include, exclude, only or except partial, the package will + * throw an + */ + 'ignore_invalid_partials' => false, ]; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d314109de..5c688b1e1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -65,6 +65,11 @@ parameters: count: 1 path: src/Casts/DateTimeInterfaceCast.php + - + message: "#^PHPDoc tag @return with type Spatie\\\\LaravelData\\\\CursorPaginatedDataCollection\\ is incompatible with native type static\\(Spatie\\\\LaravelData\\\\CursorPaginatedDataCollection\\\\)\\.$#" + count: 1 + path: src/CursorPaginatedDataCollection.php + - message: "#^Instanceof between \\*NEVER\\* and Spatie\\\\LaravelData\\\\Contracts\\\\BaseDataCollectable will always evaluate to false\\.$#" count: 1 @@ -95,6 +100,11 @@ parameters: count: 2 path: src/PaginatedDataCollection.php + - + message: "#^PHPDoc tag @return with type Spatie\\\\LaravelData\\\\PaginatedDataCollection\\ is incompatible with native type static\\(Spatie\\\\LaravelData\\\\PaginatedDataCollection\\\\)\\.$#" + count: 1 + path: src/PaginatedDataCollection.php + - message: "#^Dead catch \\- ArgumentCountError is never thrown in the try block\\.$#" count: 1 diff --git a/src/Concerns/ResponsableData.php b/src/Concerns/ResponsableData.php index 68dfed0d2..1371809f7 100644 --- a/src/Concerns/ResponsableData.php +++ b/src/Concerns/ResponsableData.php @@ -59,10 +59,15 @@ public function toResponse($request) return new JsonResponse( data: $this->transform($contextFactory), - status: $request->isMethod(Request::METHOD_POST) ? Response::HTTP_CREATED : Response::HTTP_OK, + status: $this->calculateResponseStatus($request), ); } + protected function calculateResponseStatus(Request $request): int + { + return $request->isMethod(Request::METHOD_POST) ? Response::HTTP_CREATED : Response::HTTP_OK; + } + public static function allowedRequestIncludes(): ?array { return []; diff --git a/src/Exceptions/CannotPerformPartialOnDataField.php b/src/Exceptions/CannotPerformPartialOnDataField.php new file mode 100644 index 000000000..15e478691 --- /dev/null +++ b/src/Exceptions/CannotPerformPartialOnDataField.php @@ -0,0 +1,26 @@ +getVerb()} a non existing field `{$field}` on `{$dataClass->name}`.".PHP_EOL; + $message .= 'Provided transformation context:'.PHP_EOL.PHP_EOL; + $message .= (string) $transformationContext; + + return new self(message: $message, previous: $exception); + } +} diff --git a/src/Resolvers/RequestQueryStringPartialsResolver.php b/src/Resolvers/RequestQueryStringPartialsResolver.php index 7ae119587..7a69959c4 100644 --- a/src/Resolvers/RequestQueryStringPartialsResolver.php +++ b/src/Resolvers/RequestQueryStringPartialsResolver.php @@ -105,7 +105,7 @@ protected function validateSegments( ); if ($nextSegments === null) { - return [$segment]; + return [new FieldsPartialSegment([$field])]; } return [$segment, ...$nextSegments]; diff --git a/src/Resolvers/VisibleDataFieldsResolver.php b/src/Resolvers/VisibleDataFieldsResolver.php index fca33d20c..5f7579e9a 100644 --- a/src/Resolvers/VisibleDataFieldsResolver.php +++ b/src/Resolvers/VisibleDataFieldsResolver.php @@ -2,13 +2,16 @@ namespace Spatie\LaravelData\Resolvers; +use ErrorException; use Spatie\LaravelData\Contracts\BaseData; +use Spatie\LaravelData\Exceptions\CannotPerformPartialOnDataField; use Spatie\LaravelData\Lazy; use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\DataClass; use Spatie\LaravelData\Support\DataProperty; use Spatie\LaravelData\Support\Lazy\ConditionalLazy; use Spatie\LaravelData\Support\Lazy\RelationalLazy; +use Spatie\LaravelData\Support\Partials\PartialType; use Spatie\LaravelData\Support\Transformation\TransformationContext; class VisibleDataFieldsResolver @@ -45,7 +48,7 @@ public function execute( } if ($transformationContext->exceptPartials) { - $this->performExcept($fields, $transformationContext); + $this->performExcept($fields, $transformationContext, $dataClass); } if (empty($fields)) { @@ -53,7 +56,7 @@ public function execute( } if ($transformationContext->onlyPartials) { - $this->performOnly($fields, $transformationContext); + $this->performOnly($fields, $transformationContext, $dataClass); } $includedFields = $transformationContext->includePartials ? $this->resolveIncludedFields( @@ -110,7 +113,8 @@ public function execute( */ protected function performExcept( array &$fields, - TransformationContext $transformationContext + TransformationContext $transformationContext, + DataClass $dataClass, ): void { $exceptFields = []; @@ -126,7 +130,11 @@ protected function performExcept( } if ($nested = $exceptPartial->getNested()) { - $fields[$nested]->addExceptResolvedPartial($exceptPartial->next()); + try { + $fields[$nested]->addExceptResolvedPartial($exceptPartial->next()); + } catch (ErrorException $exception) { + $this->handleNonExistingNestedField($exception, PartialType::Except, $nested, $dataClass, $transformationContext); + } continue; } @@ -146,7 +154,8 @@ protected function performExcept( */ protected function performOnly( array &$fields, - TransformationContext $transformationContext + TransformationContext $transformationContext, + DataClass $dataClass, ): void { $onlyFields = null; @@ -159,8 +168,12 @@ protected function performOnly( $onlyFields ??= []; if ($nested = $onlyPartial->getNested()) { - $fields[$nested]->addOnlyResolvedPartial($onlyPartial->next()); - $onlyFields[] = $nested; + try { + $fields[$nested]->addOnlyResolvedPartial($onlyPartial->next()); + $onlyFields[] = $nested; + } catch (ErrorException $exception) { + $this->handleNonExistingNestedField($exception, PartialType::Only, $nested, $dataClass, $transformationContext); + } continue; } @@ -214,8 +227,12 @@ protected function resolveIncludedFields( } if ($nested = $includedPartial->getNested()) { - $fields[$nested]->addIncludedResolvedPartial($includedPartial->next()); - $includedFields[] = $nested; + try { + $fields[$nested]->addIncludedResolvedPartial($includedPartial->next()); + $includedFields[] = $nested; + } catch (ErrorException $exception) { + $this->handleNonExistingNestedField($exception, PartialType::Include, $nested, $dataClass, $transformationContext); + } continue; } @@ -258,7 +275,11 @@ protected function resolveExcludedFields( } if ($nested = $excludePartial->getNested()) { - $fields[$nested]->addExcludedResolvedPartial($excludePartial->next()); + try { + $fields[$nested]->addExcludedResolvedPartial($excludePartial->next()); + } catch (ErrorException $exception) { + $this->handleNonExistingNestedField($exception, PartialType::Exclude, $nested, $dataClass, $transformationContext); + } continue; } @@ -270,4 +291,29 @@ protected function resolveExcludedFields( return $excludedFields; } + + + protected function handleNonExistingNestedField( + ErrorException $exception, + PartialType $partialType, + string $field, + DataClass $dataClass, + TransformationContext $transformationContext, + ): void { + if (str_starts_with($exception->getMessage(), 'Undefined array key: ')) { + throw $exception; + } + + if(config('data.ignore_invalid_partials')){ + return; + } + + throw CannotPerformPartialOnDataField::create( + $exception, + $partialType, + $field, + $dataClass, + $transformationContext + ); + } } diff --git a/src/Support/Creation/CreationContextFactory.php b/src/Support/Creation/CreationContextFactory.php index 411cb8e80..ed1484858 100644 --- a/src/Support/Creation/CreationContextFactory.php +++ b/src/Support/Creation/CreationContextFactory.php @@ -47,7 +47,6 @@ public static function createFromConfig( return new self( dataClass: $dataClass, validationType: ValidationType::from($config['validation_type']), - // TODO: maybe also add these to config, we should do the same for transormation so maybe some config cleanup is needed mapPropertyNames: true, withoutMagicalCreation: false, ignoredMagicalMethods: null, diff --git a/src/Support/Partials/Partial.php b/src/Support/Partials/Partial.php index 2ebf5f6f5..97a567922 100644 --- a/src/Support/Partials/Partial.php +++ b/src/Support/Partials/Partial.php @@ -120,6 +120,15 @@ public function resolve(BaseData|BaseDataCollectable $data): ?ResolvedPartial return null; } + public function toArray(): array + { + return [ + 'segments' => $this->segments, + 'permanent' => $this->permanent, + 'condition' => $this->condition, + ]; + } + public function __toString(): string { return implode('.', $this->segments); diff --git a/src/Support/Partials/PartialType.php b/src/Support/Partials/PartialType.php index 9d9cf64f1..5cdac3eeb 100644 --- a/src/Support/Partials/PartialType.php +++ b/src/Support/Partials/PartialType.php @@ -21,6 +21,16 @@ public function getRequestParameterName(): string }; } + public function getVerb(): string + { + return match ($this) { + self::Include => 'include', + self::Exclude => 'exclude', + self::Only => 'only', + self::Except => 'except', + }; + } + /** * @return string[]|null */ diff --git a/src/Support/Partials/PartialsCollection.php b/src/Support/Partials/PartialsCollection.php index e11300c9e..243696478 100644 --- a/src/Support/Partials/PartialsCollection.php +++ b/src/Support/Partials/PartialsCollection.php @@ -9,4 +9,25 @@ */ class PartialsCollection extends SplObjectStorage { + public static function create(Partial ...$partials): self + { + $collection = new self(); + + foreach ($partials as $partial) { + $collection->attach($partial); + } + + return $collection; + } + + public function toArray(): array + { + $output = []; + + foreach ($this as $partial) { + $output[] = $partial->toArray(); + } + + return $output; + } } diff --git a/src/Support/Partials/ResolvedPartialsCollection.php b/src/Support/Partials/ResolvedPartialsCollection.php index 6324cdb81..c07224969 100644 --- a/src/Support/Partials/ResolvedPartialsCollection.php +++ b/src/Support/Partials/ResolvedPartialsCollection.php @@ -34,7 +34,7 @@ public function toArray(): array public function __toString(): string { - $output = "- partials:".PHP_EOL; + $output = ''; foreach ($this as $partial) { $output .= " - {$partial}".PHP_EOL; diff --git a/src/Support/Transformation/TransformationContext.php b/src/Support/Transformation/TransformationContext.php index da735de86..cde37e622 100644 --- a/src/Support/Transformation/TransformationContext.php +++ b/src/Support/Transformation/TransformationContext.php @@ -224,18 +224,22 @@ public function __toString(): string } if ($this->includePartials !== null && $this->includePartials->count() > 0) { + $output .= "- include partials:".PHP_EOL; $output .= $this->includePartials; } if ($this->excludePartials !== null && $this->excludePartials->count() > 0) { + $output .= "- exclude partials:".PHP_EOL; $output .= $this->excludePartials; } if ($this->onlyPartials !== null && $this->onlyPartials->count() > 0) { + $output .= "- only partials:".PHP_EOL; $output .= $this->onlyPartials; } if ($this->exceptPartials !== null && $this->exceptPartials->count() > 0) { + $output .= "- except partials:".PHP_EOL; $output .= $this->exceptPartials; } diff --git a/tests/CreationFactoryTest.php b/tests/CreationFactoryTest.php new file mode 100644 index 000000000..64e377757 --- /dev/null +++ b/tests/CreationFactoryTest.php @@ -0,0 +1,150 @@ +withoutMagicalCreation()->from(['hash_id' => 1, 'name' => 'Taylor']) + )->toEqual(new $data(null, 'Taylor')); + + expect($data::from(['hash_id' => 1, 'name' => 'Taylor'])) + ->toEqual(new $data(1, 'Taylor')); +}); + +it('can create data ignoring certain magical methods', function () { + $data = new class ('', '') extends Data { + public function __construct( + public ?string $id, + public string $name, + ) { + } + + public static function fromArray(array $payload) + { + return new self( + id: $payload['hash_id'] ?? null, + name: $payload['name'], + ); + } + }; + + expect( + $data::factory()->ignoreMagicalMethod('fromArray')->from(['hash_id' => 1, 'name' => 'Taylor']) + )->toEqual(new $data(null, 'Taylor')); + + expect( + $data::factory()->from(['hash_id' => 1, 'name' => 'Taylor']), + )->toEqual(new $data(1, 'Taylor')); +}); + +it('can enable the validation of non request payloads', function () { + $dataClass = new class () extends Data { + #[In('Hello World')] + public string $string; + }; + + $payload = [ + 'string' => 'nowp', + ]; + + expect($dataClass::factory()->from($payload)) + ->toBeInstanceOf(Data::class) + ->string->toEqual('nowp'); + + expect(fn () => $dataClass::factory()->alwaysValidate()->from($payload)) + ->toThrow(ValidationException::class); +}); + +it('can disable the validation request payloads', function () { + $dataClass = new class () extends Data { + #[In('Hello World')] + public string $string; + }; + + $request = request()->merge([ + 'string' => 'nowp', + ]); + + expect(fn () => $dataClass::factory()->from($request)) + ->toThrow(ValidationException::class); + + expect($dataClass::factory()->disableValidation()->from($request)) + ->toBeInstanceOf(Data::class) + ->string->toEqual('nowp'); +}); + +it('can disable property mapping', function () { + $dataClass = new class () extends Data { + #[MapInputName('firstName')] + public string $first_name; + }; + + expect($dataClass::factory()->withoutPropertyNameMapping()->from(['firstName' => 'Taylor'])) + ->toBeInstanceOf(Data::class) + ->first_name->toBeEmpty(); +}); + +it('can add a new global cast', function () { + $data = SimpleData::factory()->withCast('string', new StringToUpperCast())->from([ + 'string' => 'Hello World', + ]); + + expect($data)->string->toEqual('HELLO WORLD'); +}); + +it('can add a collection of global casts', function () { + $castCollection = new GlobalCastsCollection([ + 'string' => new StringToUpperCast(), + 'int' => new MeaningOfLifeCast(), + ]); + + $dataClass = new class extends Data { + public string $string; + + public int $int; + }; + + $data = $dataClass::factory()->withCastCollection($castCollection)->from([ + 'string' => 'Hello World', + 'int' => '123', + ]); + + expect($data)->string->toEqual('HELLO WORLD'); + expect($data)->int->toEqual(42); +}); + +it('can collect using a factory', function (){ + $collection = SimpleData::factory()->withCast('string', new StringToUpperCast())->collect([ + ['string' => 'Hello World'], + ['string' => 'Hello You'], + ], Collection::class); + + expect($collection) + ->toHaveCount(2) + ->first()->string->toEqual('HELLO WORLD') + ->last()->string->toEqual('HELLO YOU'); +}); diff --git a/tests/Fakes/Casts/MeaningOfLifeCast.php b/tests/Fakes/Casts/MeaningOfLifeCast.php new file mode 100644 index 000000000..598cc87e0 --- /dev/null +++ b/tests/Fakes/Casts/MeaningOfLifeCast.php @@ -0,0 +1,16 @@ +toEqual(new DataWithMultipleArgumentCreationMethod('Rick Astley_42')); }); -it('can disable the use of magical methods', function () { - $data = new class ('', '') extends Data { - public function __construct( - public ?string $id, - public string $name, - ) { - } - - public static function fromArray(array $payload) - { - return new self( - id: $payload['hash_id'] ?? null, - name: $payload['name'], - ); - } - }; - - expect( - $data::factory()->withoutMagicalCreation()->from(['hash_id' => 1, 'name' => 'Taylor']) - )->toEqual(new $data(null, 'Taylor')); - - expect($data::from(['hash_id' => 1, 'name' => 'Taylor'])) - ->toEqual(new $data(1, 'Taylor')); -}); - -it('can create data ignoring certain magical methods', function () { - class DummyA extends Data - { - public function __construct( - public ?string $id, - public string $name, - ) { - } - - public static function fromArray(array $payload) - { - return new self( - id: $payload['hash_id'] ?? null, - name: $payload['name'], - ); - } - } - - expect( - app(DataFromSomethingResolver::class)->execute( - DummyA::class, - CreationContextFactory::createFromConfig(DummyA::class)->ignoreMagicalMethod('fromArray')->get(), - ['hash_id' => 1, 'name' => 'Taylor'] - ) - )->toEqual(new DummyA(null, 'Taylor')); - - expect( - app(DataFromSomethingResolver::class)->execute( - DummyA::class, - CreationContextFactory::createFromConfig(DummyA::class)->get(), - ['hash_id' => 1, 'name' => 'Taylor'] - ) - )->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( diff --git a/tests/PartialsTest.php b/tests/PartialsTest.php index 76bd5e720..e23ca0937 100644 --- a/tests/PartialsTest.php +++ b/tests/PartialsTest.php @@ -6,6 +6,10 @@ use Spatie\LaravelData\Data; use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\Lazy; +use Spatie\LaravelData\Resolvers\RequestQueryStringPartialsResolver; +use Spatie\LaravelData\Support\Partials\Partial; +use Spatie\LaravelData\Support\Partials\PartialsCollection; +use Spatie\LaravelData\Support\Partials\PartialType; use Spatie\LaravelData\Tests\Fakes\CircData; use Spatie\LaravelData\Tests\Fakes\DefaultLazyData; use Spatie\LaravelData\Tests\Fakes\DummyDto; @@ -1048,153 +1052,191 @@ public function __construct( ]); }); +it('will check if partials are valid as request partials', function ( + ?array $lazyDataAllowedIncludes, + ?array $dataAllowedIncludes, + ?string $includes, + ?PartialsCollection $expectedPartials, + array $expectedResponse +) { + LazyData::setAllowedIncludes($lazyDataAllowedIncludes); + + $data = new class ( + Lazy::create(fn () => 'Hello'), + Lazy::create(fn () => LazyData::from('Hello')), + Lazy::create(fn () => LazyData::collect(['Hello', 'World'])), + ) extends Data { + public static ?array $allowedIncludes; + + public function __construct( + public Lazy|string $property, + public Lazy|LazyData $nested, + #[DataCollectionOf(LazyData::class)] + public Lazy|array $collection, + ) { + } + + public static function allowedRequestIncludes(): ?array + { + return static::$allowedIncludes; + } + }; + + $data::$allowedIncludes = $dataAllowedIncludes; + + $request = request(); + + if ($includes !== null) { + $request->merge([ + 'include' => $includes, + ]); + } + + $partials = app(RequestQueryStringPartialsResolver::class)->execute($data, $request, PartialType::Include); + + expect($partials?->toArray())->toEqual($expectedPartials?->toArray()); + expect($data->toResponse($request)->getData(assoc: true))->toEqual($expectedResponse); +})->with(function () { + yield 'disallowed property inclusion' => [ + 'lazyDataAllowedIncludes' => [], + 'dataAllowedIncludes' => [], + 'includes' => 'property', + 'expectedPartials' => PartialsCollection::create(), + 'expectedResponse' => [], + ]; + + yield 'allowed property inclusion' => [ + 'lazyDataAllowedIncludes' => [], + 'dataAllowedIncludes' => ['property'], + 'includes' => 'property', + 'expectedPartials' => PartialsCollection::create( + Partial::create('property'), + ), + 'expectedResponse' => [ + 'property' => 'Hello', + ], + ]; + + yield 'allowed data property inclusion without nesting' => [ + 'lazyDataAllowedIncludes' => [], + 'dataAllowedIncludes' => ['nested'], + 'includes' => 'nested.name', + 'expectedPartials' => PartialsCollection::create( + Partial::create('nested'), + ), + 'expectedResponse' => [ + 'nested' => [], + ], + ]; + + yield 'allowed data property inclusion with nesting' => [ + 'lazyDataAllowedIncludes' => ['name'], + 'dataAllowedIncludes' => ['nested'], + 'includes' => 'nested.name', + 'expectedPartials' => PartialsCollection::create( + Partial::create('nested.name'), + ), + 'expectedResponse' => [ + 'nested' => [ + 'name' => 'Hello', + ], + ], + ]; -// Todo: replace -//it('will correctly reduce a tree based upon allowed includes', function ( -// ?array $lazyDataAllowedIncludes, -// ?array $dataAllowedIncludes, -// ?string $requestedAllowedIncludes, -// TreeNode $expectedIncludes -//) { -// LazyData::setAllowedIncludes($lazyDataAllowedIncludes); -// -// $data = new class ( -// 'Hello', -// LazyData::from('Hello'), -// LazyData::collect(['Hello', 'World']) -// ) extends Data { -// public static ?array $allowedIncludes; -// -// public function __construct( -// public string $property, -// public LazyData $nested, -// #[DataCollectionOf(LazyData::class)] -// public array $collection, -// ) { -// } -// -// public static function allowedRequestIncludes(): ?array -// { -// return static::$allowedIncludes; -// } -// }; -// -// $data::$allowedIncludes = $dataAllowedIncludes; -// -// $request = request(); -// -// if ($requestedAllowedIncludes !== null) { -// $request->merge([ -// 'include' => $requestedAllowedIncludes, -// ]); -// } -// -// $trees = $this->resolver->execute($data, $request); -// -// expect($trees->lazyIncluded)->toEqual($expectedIncludes); -//})->with(function () { -// yield 'disallowed property inclusion' => [ -// 'lazyDataAllowedIncludes' => [], -// 'dataAllowedIncludes' => [], -// 'requestedIncludes' => 'property', -// 'expectedIncludes' => new ExcludedTreeNode(), -// ]; -// -// yield 'allowed property inclusion' => [ -// 'lazyDataAllowedIncludes' => [], -// 'dataAllowedIncludes' => ['property'], -// 'requestedIncludes' => 'property', -// 'expectedIncludes' => new PartialTreeNode([ -// 'property' => new ExcludedTreeNode(), -// ]), -// ]; -// -// yield 'allowed data property inclusion without nesting' => [ -// 'lazyDataAllowedIncludes' => [], -// 'dataAllowedIncludes' => ['nested'], -// 'requestedIncludes' => 'nested.name', -// 'expectedIncludes' => new PartialTreeNode([ -// 'nested' => new ExcludedTreeNode(), -// ]), -// ]; -// -// yield 'allowed data property inclusion with nesting' => [ -// 'lazyDataAllowedIncludes' => ['name'], -// 'dataAllowedIncludes' => ['nested'], -// 'requestedIncludes' => 'nested.name', -// 'expectedIncludes' => new PartialTreeNode([ -// 'nested' => new PartialTreeNode([ -// 'name' => new ExcludedTreeNode(), -// ]), -// ]), -// ]; -// -// yield 'allowed data collection property inclusion without nesting' => [ -// 'lazyDataAllowedIncludes' => [], -// 'dataAllowedIncludes' => ['collection'], -// 'requestedIncludes' => 'collection.name', -// 'expectedIncludes' => new PartialTreeNode([ -// 'collection' => new ExcludedTreeNode(), -// ]), -// ]; -// -// yield 'allowed data collection property inclusion with nesting' => [ -// 'lazyDataAllowedIncludes' => ['name'], -// 'dataAllowedIncludes' => ['collection'], -// 'requestedIncludes' => 'collection.name', -// 'expectedIncludes' => new PartialTreeNode([ -// 'collection' => new PartialTreeNode([ -// 'name' => new ExcludedTreeNode(), -// ]), -// ]), -// ]; -// -// yield 'allowed nested data property inclusion without defining allowed includes on nested' => [ -// 'lazyDataAllowedIncludes' => null, -// 'dataAllowedIncludes' => ['nested'], -// 'requestedIncludes' => 'nested.name', -// 'expectedIncludes' => new PartialTreeNode([ -// 'nested' => new PartialTreeNode([ -// 'name' => new ExcludedTreeNode(), -// ]), -// ]), -// ]; -// -// yield 'allowed all nested data property inclusion without defining allowed includes on nested' => [ -// 'lazyDataAllowedIncludes' => null, -// 'dataAllowedIncludes' => ['nested'], -// 'requestedIncludes' => 'nested.*', -// 'expectedIncludes' => new PartialTreeNode([ -// 'nested' => new AllTreeNode(), -// ]), -// ]; -// -// yield 'disallowed all nested data property inclusion ' => [ -// 'lazyDataAllowedIncludes' => [], -// 'dataAllowedIncludes' => ['nested'], -// 'requestedIncludes' => 'nested.*', -// 'expectedIncludes' => new PartialTreeNode([ -// 'nested' => new ExcludedTreeNode(), -// ]), -// ]; -// -// yield 'multi property inclusion' => [ -// 'lazyDataAllowedIncludes' => null, -// 'dataAllowedIncludes' => ['nested', 'property'], -// 'requestedIncludes' => 'nested.*,property', -// 'expectedIncludes' => new PartialTreeNode([ -// 'property' => new ExcludedTreeNode(), -// 'nested' => new AllTreeNode(), -// ]), -// ]; -// -// yield 'without property inclusion' => [ -// 'lazyDataAllowedIncludes' => null, -// 'dataAllowedIncludes' => ['nested', 'property'], -// 'requestedIncludes' => null, -// 'expectedIncludes' => new DisabledTreeNode(), -// ]; -//}); + yield 'allowed data collection property inclusion without nesting' => [ + 'lazyDataAllowedIncludes' => [], + 'dataAllowedIncludes' => ['collection'], + 'includes' => 'collection.name', + 'expectedPartials' => PartialsCollection::create( + Partial::create('collection'), + ), + 'expectedResponse' => [ + 'collection' => [ + [], + [] + ], + ], + ]; + + yield 'allowed data collection property inclusion with nesting' => [ + 'lazyDataAllowedIncludes' => ['name'], + 'dataAllowedIncludes' => ['collection'], + 'includes' => 'collection.name', + 'expectedPartials' => PartialsCollection::create( + Partial::create('collection.name'), + ), + 'expectedResponse' => [ + 'collection' => [ + ['name' => 'Hello'], + ['name' => 'World'], + ], + ], + ]; + + yield 'allowed nested data property inclusion without defining allowed includes on nested' => [ + 'lazyDataAllowedIncludes' => null, + 'dataAllowedIncludes' => ['nested'], + 'includes' => 'nested.name', + 'expectedPartials' => PartialsCollection::create( + Partial::create('nested.name'), + ), + 'expectedResponse' => [ + 'nested' => [ + 'name' => 'Hello', + ], + ], + ]; + + yield 'allowed all nested data property inclusion without defining allowed includes on nested' => [ + 'lazyDataAllowedIncludes' => null, + 'dataAllowedIncludes' => ['nested'], + 'includes' => 'nested.*', + 'expectedPartials' => PartialsCollection::create( + Partial::create('nested.*'), + ), + 'expectedResponse' => [ + 'nested' => [ + 'name' => 'Hello', + ], + ], + ]; + + yield 'disallowed all nested data property inclusion ' => [ + 'lazyDataAllowedIncludes' => [], + 'dataAllowedIncludes' => ['nested'], + 'includes' => 'nested.*', + 'expectedPartials' => PartialsCollection::create( + Partial::create('nested'), + ), + 'expectedResponse' => [ + 'nested' => [], + ], + ]; + + yield 'multi property inclusion' => [ + 'lazyDataAllowedIncludes' => null, + 'dataAllowedIncludes' => ['nested', 'property'], + 'includes' => 'nested.*,property', + 'expectedPartials' => PartialsCollection::create( + Partial::create('nested.*'), + Partial::create('property'), + ), + 'expectedResponse' => [ + 'property' => 'Hello', + 'nested' => [ + 'name' => 'Hello', + ], + ], + ]; + + yield 'without property inclusion' => [ + 'lazyDataAllowedIncludes' => null, + 'dataAllowedIncludes' => ['nested', 'property'], + 'includes' => null, + 'expectedPartials' => null, + 'expectedResponse' => [], + ]; +}); it('can combine request and manual includes', function () { $dataclass = new class ( diff --git a/tests/RequestTest.php b/tests/RequestTest.php index e60ac8667..5dc8be32c 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -6,17 +6,13 @@ use Illuminate\Support\Facades\Route; use Illuminate\Testing\TestResponse; use Illuminate\Validation\ValidationException; - -use function Pest\Laravel\handleExceptions; -use function Pest\Laravel\postJson; - use Spatie\LaravelData\Attributes\WithoutValidation; - use Spatie\LaravelData\Data; - - use Spatie\LaravelData\Tests\Fakes\SimpleData; use Spatie\LaravelData\Tests\Fakes\SimpleDataWithExplicitValidationRuleAttributeData; +use function Pest\Laravel\handleExceptions; +use function Pest\Laravel\postJson; + function performRequest(string $string): TestResponse { @@ -61,6 +57,23 @@ function performRequest(string $string): TestResponse ->assertJson(['string' => 'Hello']); }); +it('is possible to overwrite the status response code', function () { + Route::post('/example-route', function () { + return new class(request()->input('string')) extends SimpleData { + protected function calculateResponseStatus(Request $request): int + { + return 301; + } + }; + }); + + postJson('/example-route', [ + 'string' => 'Hello', + ]) + ->assertStatus(301) + ->assertJson(['string' => 'Hello']); +}); + it('can fail validation', function () { Route::post('/example-route', function (SimpleDataWithExplicitValidationRuleAttributeData $data) { return ['email' => $data->email]; diff --git a/tests/Resolvers/VisibleDataFieldsResolverTest.php b/tests/Resolvers/VisibleDataFieldsResolverTest.php index a74d735c9..3bba1b625 100644 --- a/tests/Resolvers/VisibleDataFieldsResolverTest.php +++ b/tests/Resolvers/VisibleDataFieldsResolverTest.php @@ -4,6 +4,7 @@ use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\Hidden; use Spatie\LaravelData\Data; +use Spatie\LaravelData\Exceptions\CannotPerformPartialOnDataField; use Spatie\LaravelData\Lazy; use Spatie\LaravelData\Optional; use Spatie\LaravelData\Resolvers\VisibleDataFieldsResolver; @@ -20,6 +21,7 @@ use Spatie\LaravelData\Tests\Fakes\FakeModelData; use Spatie\LaravelData\Tests\Fakes\FakeNestedModelData; use Spatie\LaravelData\Tests\Fakes\Models\FakeNestedModel; +use Spatie\LaravelData\Tests\Fakes\SimpleData; function findVisibleFields( Data $data, @@ -231,6 +233,39 @@ public static function create(string $name): static expect($dataClass::create('Freek')->toArray()['name'])->toBeInstanceOf(Closure::class); }); +it('will fail gracefully when a nested field does not exist', function () { + $dataClass = new class () extends Data { + public Lazy|SimpleData $simple; + + public Lazy|string $string; + + public function __construct() + { + $this->simple = Lazy::create(fn () => new SimpleData('Hello')); + $this->string = Lazy::create(fn () => 'World'); + } + }; + + expect(fn () => findVisibleFields($dataClass, TransformationContextFactory::create()->include('certainly-not-simple.string')))->toThrow( + CannotPerformPartialOnDataField::class + ); + + expect(fn () => $dataClass->include('certainly-not-simple.string')->toArray())->toThrow( + CannotPerformPartialOnDataField::class + ); + + config()->set('data.ignore_invalid_partials', true); + + expect(findVisibleFields($dataClass, TransformationContextFactory::create()->include('certainly-not-simple.string', 'string'))) + ->toEqual([ + 'string' => null, + ]); + + expect($dataClass->include('certainly-not-simple.string', 'string')->toArray())->toEqual([ + 'string' => 'World', + ]); +}); + class VisibleFieldsSingleData extends Data { public function __construct( diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index 614287651..bfcdfe181 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -13,6 +13,7 @@ use Illuminate\Validation\ValidationException; use Illuminate\Validation\Validator; +use Spatie\LaravelData\Support\Creation\ValidationType; use function Pest\Laravel\mock; use function PHPUnit\Framework\assertFalse; @@ -2086,24 +2087,6 @@ public static function fromRequest(Request $request): self assertFalse(true, 'We should not end up here'); }); -it('can validate non-requests payloads', function () { - $dataClass = new class () extends Data { - #[In('Hello World')] - public string $string; - }; - - $data = $dataClass::from([ - 'string' => 'nowp', - ]); - - expect($data)->toBeInstanceOf(Data::class) - ->string->toEqual('nowp'); - - $data = $dataClass::factory()->alwaysValidate()->from([ - 'string' => 'nowp', - ]); -})->throws(ValidationException::class); - it('can the validation rules for a data object', function () { expect(MultiData::getValidationRules([]))->toEqual([ 'first' => ['required', 'string'], @@ -2342,7 +2325,7 @@ public static function rules(ValidationContext $context): array DataValidationAsserter::for($dataClass) ->assertOk(['success' => true, 'id' => 1]) ->assertErrors(['success' => true]); -})->skip('V4: The rule inferrers need to be rewritten/removed for this, we need to first add attribute rules and then decide require stuff'); +})->skip('V5: The rule inferrers need to be rewritten/removed for this, we need to first add attribute rules and then decide require stuff'); it('can validate an optional but nonexists attribute', function () { $dataClass = new class () extends Data { @@ -2355,3 +2338,19 @@ public static function rules(ValidationContext $context): array expect($dataClass::from(['property' => []])->toArray())->toBe(['property' => []]); expect($dataClass::validateAndCreate([])->toArray())->toBe([]); }); + +it('is possible to define the validation type for each data object globally using config', function (){ + $dataClass = new class () extends Data { + #[In('Hello World')] + public string $string; + }; + + expect($dataClass::from(['string' => 'Nowp'])) + ->toBeInstanceOf(Data::class) + ->string->toBe('Nowp'); + + config()->set('data.validation_type', ValidationType::Always->value); + + expect(fn() => $dataClass::from(['string' => 'Nowp'])) + ->toThrow(ValidationException::class); +});