Skip to content

Commit

Permalink
Add support for transformation max depths
Browse files Browse the repository at this point in the history
  • Loading branch information
rubenvanassche committed Mar 13, 2024
1 parent ab7d9b6 commit 0c57aca
Show file tree
Hide file tree
Showing 13 changed files with 272 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.
*/
'fail_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
33 changes: 33 additions & 0 deletions docs/as-a-resource/transformers.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,36 @@ 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,
```

It is also possible if a `MaxTransformationDepthReached` exception should be thrown or an empty array should be returned:

```php
'fail_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, fail: 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'),
failWhenMaxDepthReached: config('data.fail_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/ChecksTransformationDepths.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 ChecksTransformationDepths
{
public function hasReachedMaxTransformationDepth(TransformationContext $context): bool
{
return $context->maxDepth !== null && $context->depth >= $context->maxDepth;
}

public function handleMaxDepthReached(TransformationContext $context): array
{
if ($context->failWhenMaxDepthReached) {
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\ChecksTransformationDepths;
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 ChecksTransformationDepths;

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\ChecksTransformationDepths;
use Spatie\LaravelData\Support\DataClass;
use Spatie\LaravelData\Support\DataConfig;
use Spatie\LaravelData\Support\DataContainer;
Expand All @@ -20,6 +21,8 @@

class TransformedDataResolver
{
use ChecksTransformationDepths;

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,
failWhenMaxDepthReached: $transformationContext->failWhenMaxDepthReached,
);
}
}
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 $failWhenMaxDepthReached = true,
) {
}

Expand Down
13 changes: 13 additions & 0 deletions src/Support/Transformation/TransformationContextFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ protected function __construct(
public ?PartialsCollection $excludePartials = null,
public ?PartialsCollection $onlyPartials = null,
public ?PartialsCollection $exceptPartials = null,
public ?int $maxDepth = null,
public bool $failWhenMaxDepthReached = true,
) {
}

Expand Down Expand Up @@ -90,6 +92,9 @@ public function get(
$excludePartials,
$onlyPartials,
$exceptPartials,
depth: 0,
maxDepth: $this->maxDepth,
failWhenMaxDepthReached: $this->failWhenMaxDepthReached,
);
}

Expand Down Expand Up @@ -155,6 +160,14 @@ public function withTransformer(string $transformable, Transformer|string $trans
return $this;
}

public function maxDepth(?int $maxDepth, bool $fail = true): static
{
$this->maxDepth = $maxDepth;
$this->failWhenMaxDepthReached = $fail;

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->failWhenMaxDepthReached)->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->failWhenMaxDepthReached)->toBeTrue();
});

it('can set a max transformation depth without failing', function () {
$context = TransformationContextFactory::create()
->maxDepth(4, fail: false)
->get(SimpleData::from('Hello World'));

expect($context->maxDepth)->toBe(4);
expect($context->depth)->toBe(0);
expect($context->failWhenMaxDepthReached)->toBeFalse();
});
Loading

0 comments on commit 0c57aca

Please sign in to comment.