Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for transformation max depths #699

Merged
merged 3 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading