diff --git a/config/data.php b/config/data.php index 744d0a50..f45936ff 100644 --- a/config/data.php +++ b/config/data.php @@ -120,6 +120,21 @@ */ 'ignore_invalid_partials' => false, + /** + * When transforming a nested chain of data objects, the package can end up in an infinite + * loop when including a recursive relationship. The max transformation depth can be + * set as a safety measure to prevent this from happening. When set to null, the + * package will not enforce a maximum depth. + */ + 'max_transformation_depth' => null, + + /** + * When the maximum transformation depth is reached, the package will throw an exception. + * You can disable this behaviour by setting this option to true which will return an + * empty array. + */ + 'throw_when_max_transformation_depth_reached' => true, + /** * When using the `make:data` command, the package will use these settings to generate * the data classes. You can override these settings by passing options to the command. diff --git a/docs/as-a-resource/transformers.md b/docs/as-a-resource/transformers.md index 6b5e2609..7d778b4e 100644 --- a/docs/as-a-resource/transformers.md +++ b/docs/as-a-resource/transformers.md @@ -154,3 +154,42 @@ ArtistData::from($artist)->transform( ); ``` +## Transformation depth + +When transforming a complicated structure of nested data objects it is possible that an infinite loop is created of data objects including each other. +To prevent this, a transformation depth can be set, when that depth is reached when transforming, either an exception will be thrown or an empty +array is returned, stopping the transformation. + +This transformation depth can be set globally in the `data.php` config file: + +```php +'max_transformation_depth' => 20, +``` + +Setting the transformation depth to `null` will disable the transformation depth check: + +```php +'max_transformation_depth' => null, +``` + +It is also possible if a `MaxTransformationDepthReached` exception should be thrown or an empty array should be returned: + +```php +'throw_when_max_transformation_depth_reached' => true, +``` + +It is also possible to set the transformation depth on a specific transformation by using a `TransformationContextFactory`: + +```php +ArtistData::from($artist)->transform( + TransformationContextFactory::create()->maxDepth(20) +); +``` + +By default, an exception will be thrown when the maximum transformation depth is reached. This can be changed to return an empty array as such: + +```php +ArtistData::from($artist)->transform( + TransformationContextFactory::create()->maxDepth(20, throw: false) +); +``` diff --git a/src/Concerns/TransformableData.php b/src/Concerns/TransformableData.php index 62834ce9..69855103 100644 --- a/src/Concerns/TransformableData.php +++ b/src/Concerns/TransformableData.php @@ -21,7 +21,10 @@ public function transform( $transformationContext = match (true) { $transformationContext instanceof TransformationContext => $transformationContext, $transformationContext instanceof TransformationContextFactory => $transformationContext->get($this), - $transformationContext === null => new TransformationContext() + $transformationContext === null => new TransformationContext( + maxDepth: config('data.max_transformation_depth'), + throwWhenMaxDepthReached: config('data.throw_when_max_transformation_depth_reached') + ) }; $resolver = match (true) { diff --git a/src/Exceptions/MaxTransformationDepthReached.php b/src/Exceptions/MaxTransformationDepthReached.php new file mode 100644 index 00000000..330f1e0a --- /dev/null +++ b/src/Exceptions/MaxTransformationDepthReached.php @@ -0,0 +1,13 @@ +maxDepth !== null && $context->depth >= $context->maxDepth; + } + + public function handleMaxDepthReached(TransformationContext $context): array + { + if ($context->throwWhenMaxDepthReached) { + throw MaxTransformationDepthReached::create($context->maxDepth); + } + + return []; + } +} diff --git a/src/Resolvers/TransformedDataCollectableResolver.php b/src/Resolvers/TransformedDataCollectableResolver.php index db653262..edddb079 100644 --- a/src/Resolvers/TransformedDataCollectableResolver.php +++ b/src/Resolvers/TransformedDataCollectableResolver.php @@ -14,6 +14,7 @@ use Spatie\LaravelData\CursorPaginatedDataCollection; use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\PaginatedDataCollection; +use Spatie\LaravelData\Resolvers\Concerns\ChecksTransformationDepth; use Spatie\LaravelData\Support\DataConfig; use Spatie\LaravelData\Support\Transformation\TransformationContext; use Spatie\LaravelData\Support\Wrapping\Wrap; @@ -22,6 +23,8 @@ class TransformedDataCollectableResolver { + use ChecksTransformationDepth; + public function __construct( protected DataConfig $dataConfig ) { @@ -31,6 +34,10 @@ public function execute( iterable $items, TransformationContext $context, ): array { + if ($this->hasReachedMaxTransformationDepth($context)) { + return $this->handleMaxDepthReached($context); + } + $wrap = $items instanceof WrappableData ? $items->getWrap() : new Wrap(WrapType::UseGlobal); diff --git a/src/Resolvers/TransformedDataResolver.php b/src/Resolvers/TransformedDataResolver.php index c32d21cb..94008b17 100644 --- a/src/Resolvers/TransformedDataResolver.php +++ b/src/Resolvers/TransformedDataResolver.php @@ -9,6 +9,7 @@ use Spatie\LaravelData\Contracts\WrappableData; use Spatie\LaravelData\Lazy; use Spatie\LaravelData\Optional; +use Spatie\LaravelData\Resolvers\Concerns\ChecksTransformationDepth; use Spatie\LaravelData\Support\DataClass; use Spatie\LaravelData\Support\DataConfig; use Spatie\LaravelData\Support\DataContainer; @@ -20,6 +21,8 @@ class TransformedDataResolver { + use ChecksTransformationDepth; + public function __construct( protected DataConfig $dataConfig, protected VisibleDataFieldsResolver $visibleDataFieldsResolver, @@ -30,6 +33,10 @@ public function execute( BaseData&TransformableData $data, TransformationContext $context, ): array { + if ($this->hasReachedMaxTransformationDepth($context)) { + return $this->handleMaxDepthReached($context); + } + $dataClass = $this->dataConfig->getDataClass($data::class); $transformed = $this->transform($data, $context, $dataClass); @@ -140,7 +147,7 @@ protected function resolvePropertyValue( protected function transformDataOrDataCollection( mixed $value, TransformationContext $currentContext, - ?TransformationContext $fieldContext + TransformationContext $fieldContext ): mixed { $wrapExecutionType = $this->resolveWrapExecutionType($value, $currentContext); @@ -215,7 +222,7 @@ protected function resolvePotentialPartialArray( array $value, ?TransformationContext $fieldContext, ): array { - if($fieldContext === null) { + if ($fieldContext === null) { return $value; } diff --git a/src/Resolvers/VisibleDataFieldsResolver.php b/src/Resolvers/VisibleDataFieldsResolver.php index 71072d7c..d75eea1b 100644 --- a/src/Resolvers/VisibleDataFieldsResolver.php +++ b/src/Resolvers/VisibleDataFieldsResolver.php @@ -43,6 +43,9 @@ public function execute( $transformationContext->mapPropertyNames, $transformationContext->wrapExecutionType, $transformationContext->transformers, + depth: $transformationContext->depth + 1, + maxDepth: $transformationContext->maxDepth, + throwWhenMaxDepthReached: $transformationContext->throwWhenMaxDepthReached, ); } } diff --git a/src/Support/Transformation/TransformationContext.php b/src/Support/Transformation/TransformationContext.php index a510cbb1..fab99702 100644 --- a/src/Support/Transformation/TransformationContext.php +++ b/src/Support/Transformation/TransformationContext.php @@ -24,6 +24,9 @@ public function __construct( public ?PartialsCollection $excludePartials = null, public ?PartialsCollection $onlyPartials = null, public ?PartialsCollection $exceptPartials = null, + public int $depth = 0, + public ?int $maxDepth = null, + public bool $throwWhenMaxDepthReached = true, ) { } diff --git a/src/Support/Transformation/TransformationContextFactory.php b/src/Support/Transformation/TransformationContextFactory.php index 879d5028..6076b6f1 100644 --- a/src/Support/Transformation/TransformationContextFactory.php +++ b/src/Support/Transformation/TransformationContextFactory.php @@ -13,6 +13,10 @@ class TransformationContextFactory { use ForwardsToPartialsDefinition; + public ?int $maxDepth; + + public bool $throwWhenMaxDepthReached; + public static function create(): self { return new self(); @@ -28,6 +32,8 @@ protected function __construct( public ?PartialsCollection $onlyPartials = null, public ?PartialsCollection $exceptPartials = null, ) { + $this->maxDepth = config('data.max_transformation_depth', null); + $this->throwWhenMaxDepthReached = config('data.throw_when_max_transformation_depth_reached', true); } public function get( @@ -90,6 +96,9 @@ public function get( $excludePartials, $onlyPartials, $exceptPartials, + depth: 0, + maxDepth: $this->maxDepth, + throwWhenMaxDepthReached: $this->throwWhenMaxDepthReached, ); } @@ -155,6 +164,14 @@ public function withTransformer(string $transformable, Transformer|string $trans return $this; } + public function maxDepth(?int $maxDepth, bool $throw = true): static + { + $this->maxDepth = $maxDepth; + $this->throwWhenMaxDepthReached = $throw; + + return $this; + } + public function mergeIncludePartials(PartialsCollection $partials): static { if ($this->includePartials === null) { diff --git a/tests/PartialsTest.php b/tests/PartialsTest.php index fb981615..a5479e60 100644 --- a/tests/PartialsTest.php +++ b/tests/PartialsTest.php @@ -1476,20 +1476,20 @@ public function __construct() } }; - // expect($dataClass->include('collection.simple')->toArray())->toMatchArray([ - // 'collection' => [ - // [ - // 'simple' => [ - // 'string' => 'Rick Astley', - // ], - // ], - // [ - // 'simple' => [ - // 'string' => 'Jon Bon Jovi', - // ], - // ], - // ], - // ]); + expect($dataClass->include('collection.simple')->toArray())->toMatchArray([ + 'collection' => [ + [ + 'simple' => [ + 'string' => 'Rick Astley', + ], + ], + [ + 'simple' => [ + 'string' => 'Jon Bon Jovi', + ], + ], + ], + ]); $nested = $dataClass->include('collection.simple')->all()['collection']; @@ -1596,6 +1596,5 @@ public function __construct() ], ], ]); - // Not really a test with expectation, we just want to check we don't end up in an infinite loop }); diff --git a/tests/Support/Transformation/TransformationContextFactoryTest.php b/tests/Support/Transformation/TransformationContextFactoryTest.php index dc8c5e36..2fb37544 100644 --- a/tests/Support/Transformation/TransformationContextFactoryTest.php +++ b/tests/Support/Transformation/TransformationContextFactoryTest.php @@ -16,6 +16,9 @@ expect($context->mapPropertyNames)->toBeTrue(); expect($context->wrapExecutionType)->toBe(WrapExecutionType::Disabled); expect($context->transformers)->toBeNull(); + expect($context->depth)->toBe(0); + expect($context->maxDepth)->toBeNull(); + expect($context->throwWhenMaxDepthReached)->toBeTrue(); }); it('can disable value transformation', function () { @@ -82,3 +85,23 @@ expect($context->transformers)->not()->toBe(null); expect($context->transformers->findTransformerForValue('Hello World'))->toBeInstanceOf(StringToUpperTransformer::class); }); + +it('can set a max transformation depth', function () { + $context = TransformationContextFactory::create() + ->maxDepth(4) + ->get(SimpleData::from('Hello World')); + + expect($context->maxDepth)->toBe(4); + expect($context->depth)->toBe(0); + expect($context->throwWhenMaxDepthReached)->toBeTrue(); +}); + +it('can set a max transformation depth without failing', function () { + $context = TransformationContextFactory::create() + ->maxDepth(4, throw: false) + ->get(SimpleData::from('Hello World')); + + expect($context->maxDepth)->toBe(4); + expect($context->depth)->toBe(0); + expect($context->throwWhenMaxDepthReached)->toBeFalse(); +}); diff --git a/tests/TransformationTest.php b/tests/TransformationTest.php index 86d8667b..d158213c 100644 --- a/tests/TransformationTest.php +++ b/tests/TransformationTest.php @@ -9,6 +9,7 @@ use Spatie\LaravelData\CursorPaginatedDataCollection; use Spatie\LaravelData\Data; use Spatie\LaravelData\DataCollection; +use Spatie\LaravelData\Exceptions\MaxTransformationDepthReached; use Spatie\LaravelData\Lazy; use Spatie\LaravelData\Optional; use Spatie\LaravelData\PaginatedDataCollection; @@ -400,3 +401,114 @@ public function transform(DataProperty $property, mixed $value, TransformationCo expect($transformed)->toBe(['array' => 'A']); })->skip(fn () => config('data.features.cast_and_transform_iterables') === false); + + +it('it possible to set the max transformation depth when transforming objects', function () { + $a = new stdClass(); + $b = new stdClass(); + + $a->b = $b; + $b->a = $a; + + class TestMaxDataObjectTransformationDepthA extends Data + { + public function __construct( + public Lazy|TestMaxDataObjectTransformationDepthB $dataB + ) { + $this->includePermanently('dataB'); + } + + public static function fromOther(stdClass $b): self + { + return new self(Lazy::create(fn () => TestMaxDataObjectTransformationDepthB::from($b->a))); + } + } + + class TestMaxDataObjectTransformationDepthB extends Data + { + public function __construct( + public Lazy|TestMaxDataObjectTransformationDepthA $dataA + ) { + $this->includePermanently('dataA'); + } + + public static function fromOther(stdClass $a): self + { + return new self(Lazy::create(fn () => TestMaxDataObjectTransformationDepthA::from($a->b))); + } + } + + expect(fn () => TestMaxDataObjectTransformationDepthB::fromOther($a)->transform( + TransformationContextFactory::create()->maxDepth(4) + ))->toThrow(MaxTransformationDepthReached::class); + + expect(TestMaxDataObjectTransformationDepthB::fromOther($a)->transform( + TransformationContextFactory::create()->maxDepth(4, throw: false) + ))->toBe([ + 'dataA' => [ + 'dataB' => [ + 'dataA' => [ + 'dataB' => [], + ], + ], + ], + ]); +}); + +it('it possible to set the max transformation depth when transforming collections', function () { + $a = new stdClass(); + $b = new stdClass(); + + $a->b = $b; + $b->a = $a; + + class TestMaxDatCollectionTransformationDepthA extends Data + { + public function __construct( + #[DataCollectionOf(TestMaxDatCollectionTransformationDepthB::class)] + public Lazy|DataCollection $ca + ) { + $this->includePermanently('ca'); + } + + public static function fromOther(stdClass $b): self + { + return new self(Lazy::create(fn () => TestMaxDatCollectionTransformationDepthB::collect([$b->a]))); + } + } + + class TestMaxDatCollectionTransformationDepthB extends Data + { + public function __construct( + #[DataCollectionOf(TestMaxDatCollectionTransformationDepthA::class)] + public Lazy|DataCollection $cb + ) { + $this->includePermanently('cb'); + } + + public static function fromOther(stdClass $a): self + { + return new self(Lazy::create(fn () => TestMaxDatCollectionTransformationDepthA::collect([$a->b]))); + } + } + + expect(fn () => TestMaxDatCollectionTransformationDepthB::fromOther($a)->transform( + TransformationContextFactory::create()->maxDepth(4) + ))->toThrow(MaxTransformationDepthReached::class); + + expect(TestMaxDatCollectionTransformationDepthB::fromOther($a)->transform( + TransformationContextFactory::create()->maxDepth(4, throw: false) + ))->toBe([ + 'cb' => [ + [ + 'ca' => [ + [ + 'cb' => [ + ['ca' => []], + ], + ], + ], + ], + ], + ]); +});