Skip to content

Commit

Permalink
Merge pull request #699 from spatie/feature/transformation-depth
Browse files Browse the repository at this point in the history
Add support for transformation max depths
  • Loading branch information
rubenvanassche authored Mar 13, 2024
2 parents ab7d9b6 + 3c9781f commit 65865fc
Show file tree
Hide file tree
Showing 13 changed files with 282 additions and 18 deletions.
15 changes: 15 additions & 0 deletions config/data.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions docs/as-a-resource/transformers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
```
5 changes: 4 additions & 1 deletion src/Concerns/TransformableData.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
13 changes: 13 additions & 0 deletions src/Exceptions/MaxTransformationDepthReached.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Spatie\LaravelData\Exceptions;

use Exception;

class MaxTransformationDepthReached extends Exception
{
public static function create(int $depth): self
{
return new self("Max transformation depth of {$depth} reached.");
}
}
23 changes: 23 additions & 0 deletions src/Resolvers/Concerns/ChecksTransformationDepth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace Spatie\LaravelData\Resolvers\Concerns;

use Spatie\LaravelData\Exceptions\MaxTransformationDepthReached;
use Spatie\LaravelData\Support\Transformation\TransformationContext;

trait ChecksTransformationDepth
{
public function hasReachedMaxTransformationDepth(TransformationContext $context): bool
{
return $context->maxDepth !== null && $context->depth >= $context->maxDepth;
}

public function handleMaxDepthReached(TransformationContext $context): array
{
if ($context->throwWhenMaxDepthReached) {
throw MaxTransformationDepthReached::create($context->maxDepth);
}

return [];
}
}
7 changes: 7 additions & 0 deletions src/Resolvers/TransformedDataCollectableResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +23,8 @@

class TransformedDataCollectableResolver
{
use ChecksTransformationDepth;

public function __construct(
protected DataConfig $dataConfig
) {
Expand All @@ -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);
Expand Down
11 changes: 9 additions & 2 deletions src/Resolvers/TransformedDataResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,6 +21,8 @@

class TransformedDataResolver
{
use ChecksTransformationDepth;

public function __construct(
protected DataConfig $dataConfig,
protected VisibleDataFieldsResolver $visibleDataFieldsResolver,
Expand All @@ -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);
Expand Down Expand Up @@ -140,7 +147,7 @@ protected function resolvePropertyValue(
protected function transformDataOrDataCollection(
mixed $value,
TransformationContext $currentContext,
?TransformationContext $fieldContext
TransformationContext $fieldContext
): mixed {
$wrapExecutionType = $this->resolveWrapExecutionType($value, $currentContext);

Expand Down Expand Up @@ -215,7 +222,7 @@ protected function resolvePotentialPartialArray(
array $value,
?TransformationContext $fieldContext,
): array {
if($fieldContext === null) {
if ($fieldContext === null) {
return $value;
}

Expand Down
3 changes: 3 additions & 0 deletions src/Resolvers/VisibleDataFieldsResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ public function execute(
$transformationContext->mapPropertyNames,
$transformationContext->wrapExecutionType,
$transformationContext->transformers,
depth: $transformationContext->depth + 1,
maxDepth: $transformationContext->maxDepth,
throwWhenMaxDepthReached: $transformationContext->throwWhenMaxDepthReached,
);
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/Support/Transformation/TransformationContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}

Expand Down
17 changes: 17 additions & 0 deletions src/Support/Transformation/TransformationContextFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ class TransformationContextFactory
{
use ForwardsToPartialsDefinition;

public ?int $maxDepth;

public bool $throwWhenMaxDepthReached;

public static function create(): self
{
return new self();
Expand All @@ -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(
Expand Down Expand Up @@ -90,6 +96,9 @@ public function get(
$excludePartials,
$onlyPartials,
$exceptPartials,
depth: 0,
maxDepth: $this->maxDepth,
throwWhenMaxDepthReached: $this->throwWhenMaxDepthReached,
);
}

Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 14 additions & 15 deletions tests/PartialsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down Expand Up @@ -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
});
23 changes: 23 additions & 0 deletions tests/Support/Transformation/TransformationContextFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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();
});
Loading

0 comments on commit 65865fc

Please sign in to comment.