diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 00000000..2c94ab4d --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,30 @@ +name: Benchmarks + +on: + push: + paths: + - '**.php' + - 'phpbench.json' + pull_request: + paths: + - '**.php' + - 'phpbench.json' + +jobs: + phpstan: + name: phpstan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@v2 + + - name: Run Benchmark + run: composer benchmark diff --git a/.gitignore b/.gitignore index 1cf475ff..fe98f112 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ testbench.yaml vendor node_modules .php-cs-fixer.cache +.phpbench +.DS_Store diff --git a/benchmarks/DataBench.php b/benchmarks/DataBench.php new file mode 100644 index 00000000..51b46a41 --- /dev/null +++ b/benchmarks/DataBench.php @@ -0,0 +1,124 @@ +createApplication(); + } + + protected function getPackageProviders($app) + { + return [ + LaravelDataServiceProvider::class, + ]; + } + + #[Revs(500), Iterations(2)] + public function benchDataCreation() + { + MultiNestedData::from([ + 'nested' => ['simple' => 'Hello'], + 'nestedCollection' => [ + ['simple' => 'I'], + ['simple' => 'am'], + ['simple' => 'groot'], + ], + ]); + } + + #[Revs(500), Iterations(2)] + public function benchDataTransformation() + { + $data = new MultiNestedData( + new NestedData(new SimpleData('Hello')), + new DataCollection(NestedData::class, [ + new NestedData(new SimpleData('I')), + new NestedData(new SimpleData('am')), + new NestedData(new SimpleData('groot')), + ]) + ); + + $data->toArray(); + } + + #[Revs(500), Iterations(2)] + public function benchDataCollectionCreation() + { + $collection = Collection::times( + 15, + fn() => [ + 'withoutType' => 42, + 'int' => 42, + 'bool' => true, + 'float' => 3.14, + 'string' => 'Hello world', + 'array' => [1, 1, 2, 3, 5, 8], + 'nullable' => null, + 'mixed' => 42, + 'explicitCast' => '16-06-1994', + 'defaultCast' => '1994-05-16T12:00:00+01:00', + 'nestedData' => [ + 'string' => 'hello', + ], + 'nestedCollection' => [ + ['string' => 'never'], + ['string' => 'gonna'], + ['string' => 'give'], + ['string' => 'you'], + ['string' => 'up'], + ], + ] + )->all(); + + ComplicatedData::collection($collection); + } + + #[Revs(500), Iterations(2)] + public function benchDataCollectionTransformation() + { + $collection = Collection::times( + 15, + fn() => new ComplicatedData( + 42, + 42, + true, + 3.14, + 'Hello World', + [1, 1, 2, 3, 5, 8], + null, + Optional::create(), + 42, + CarbonImmutable::create(1994,05,16), + new DateTime('1994-05-16T12:00:00+01:00'), + new SimpleData('hello'), + new DataCollection(NestedData::class, [ + new NestedData(new SimpleData('I')), + new NestedData(new SimpleData('am')), + new NestedData(new SimpleData('groot')), + ]) + ) + )->all(); + + $collection = ComplicatedData::collection($collection); + + $collection->toArray(); + } +} diff --git a/composer.json b/composer.json index 25811b02..41854ccc 100644 --- a/composer.json +++ b/composer.json @@ -22,23 +22,23 @@ "spatie/laravel-package-tools" : "^1.9.0" }, "require-dev" : { - "fakerphp/faker": "^1.14", - "friendsofphp/php-cs-fixer": "^3.0", - "inertiajs/inertia-laravel": "^0.6.3", - "nesbot/carbon": "^2.63", - "nette/php-generator": "^3.5", - "nunomaduro/larastan": "^2.0", - "orchestra/testbench": "^7.6|^8.0", - "pestphp/pest": "^1.22", - "pestphp/pest-plugin-laravel": "^1.3", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpunit/phpunit": "^9.3", - "spatie/invade": "^1.0", - "spatie/laravel-typescript-transformer": "^2.1.6", - "spatie/pest-plugin-snapshots": "^1.1", - "spatie/phpunit-snapshot-assertions": "^4.2", - "spatie/test-time": "^1.2" + "fakerphp/faker" : "^1.14", + "friendsofphp/php-cs-fixer" : "^3.0", + "inertiajs/inertia-laravel" : "^0.6.3", + "nesbot/carbon" : "^2.63", + "nette/php-generator" : "^3.5", + "nunomaduro/larastan" : "^2.0", + "orchestra/testbench" : "^7.6|^8.0", + "pestphp/pest" : "^1.22", + "pestphp/pest-plugin-laravel" : "^1.3", + "phpbench/phpbench" : "^1.2", + "phpstan/extension-installer" : "^1.1", + "phpunit/phpunit" : "^9.3", + "spatie/invade" : "^1.0", + "spatie/laravel-typescript-transformer" : "^2.1.6", + "spatie/pest-plugin-snapshots" : "^1.1", + "spatie/phpunit-snapshot-assertions" : "^4.2", + "spatie/test-time" : "^1.2" }, "autoload" : { "psr-4" : { @@ -55,7 +55,9 @@ "analyse" : "vendor/bin/phpstan analyse", "test" : "./vendor/bin/pest --no-coverage", "test-coverage" : "vendor/bin/pest --coverage-html coverage", - "format" : "vendor/bin/php-cs-fixer fix --allow-risky=yes" + "format" : "vendor/bin/php-cs-fixer fix --allow-risky=yes", + "benchmark" : "vendor/bin/phpbench run --report=default", + "benchmark-profiled" : "vendor/bin/phpbench xdebug:profile" }, "config" : { "sort-packages" : true, diff --git a/phpbench.json b/phpbench.json new file mode 100644 index 00000000..f171580e --- /dev/null +++ b/phpbench.json @@ -0,0 +1,5 @@ +{ + "$schema":"./vendor/phpbench/phpbench/phpbench.schema.json", + "runner.bootstrap": "vendor/autoload.php", + "runner.path" : "benchmarks" +} diff --git a/phpbench.json.dist b/phpbench.json.dist deleted file mode 100644 index b4d4f77d..00000000 --- a/phpbench.json.dist +++ /dev/null @@ -1,9 +0,0 @@ -{ - "php_disable_ini": true, - "bootstrap": "vendor/autoload.php", - "path": "benchmark", - "php_config": { - "extension": [ "json.so" ] - }, - "time_unit": "milliseconds" -} diff --git a/src/DataPipeline.php b/src/DataPipeline.php index beea1d4a..114ad439 100644 --- a/src/DataPipeline.php +++ b/src/DataPipeline.php @@ -4,9 +4,9 @@ use Illuminate\Support\Collection; use Spatie\LaravelData\DataPipes\DataPipe; -use Spatie\LaravelData\Exceptions\CannotCreateData; use Spatie\LaravelData\Normalizers\Normalizer; use Spatie\LaravelData\Support\DataConfig; +use Spatie\LaravelData\Support\ResolvedDataPipeline; class DataPipeline { @@ -27,13 +27,6 @@ public static function create(): static return app(static::class); } - public function using(mixed $value): static - { - $this->value = $value; - - return $this; - } - public function into(string $classString): static { $this->classString = $classString; @@ -62,12 +55,17 @@ public function firstThrough(string|DataPipe $pipe): static return $this; } - public function execute(): Collection + public function resolve(): ResolvedDataPipeline { + $normalizers = array_merge( + $this->normalizers, + $this->classString::normalizers() + ); + /** @var \Spatie\LaravelData\Normalizers\Normalizer[] $normalizers */ $normalizers = array_map( fn (string|Normalizer $normalizer) => is_string($normalizer) ? app($normalizer) : $normalizer, - $this->normalizers + $normalizers ); /** @var \Spatie\LaravelData\DataPipes\DataPipe[] $pipes */ @@ -76,32 +74,18 @@ public function execute(): Collection $this->pipes ); - $properties = null; - - foreach ($normalizers as $normalizer) { - $properties = $normalizer->normalize($this->value); - - if ($properties !== null) { - break; - } - } - - if ($properties === null) { - throw CannotCreateData::noNormalizerFound($this->classString, $this->value); - } - - $properties = collect($properties); - - $class = $this->dataConfig->getDataClass($this->classString); - - $properties = ($class->name)::prepareForPipeline($properties); - - foreach ($pipes as $pipe) { - $piped = $pipe->handle($this->value, $class, $properties); - - $properties = $piped; - } + return new ResolvedDataPipeline( + $normalizers, + $pipes, + $this->dataConfig->getDataClass($this->classString) + ); + } - return $properties; + /** @deprecated */ + public function execute(): Collection + { + return $this->dataConfig + ->getResolvedDataPipeline($this->classString) + ->execute($this->value); } } diff --git a/src/Resolvers/DataFromSomethingResolver.php b/src/Resolvers/DataFromSomethingResolver.php index 22bf316e..7eba218e 100644 --- a/src/Resolvers/DataFromSomethingResolver.php +++ b/src/Resolvers/DataFromSomethingResolver.php @@ -5,7 +5,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Collection; use Spatie\LaravelData\Contracts\BaseData; -use Spatie\LaravelData\Normalizers\ArrayableNormalizer; use Spatie\LaravelData\Support\DataConfig; use Spatie\LaravelData\Support\DataMethod; @@ -39,35 +38,15 @@ public function execute(string $class, mixed ...$payloads): BaseData return $data; } -// $properties = new Collection(); -// -// foreach ($payloads as $payload) { -// /** @var BaseData $class */ -// $pipeline = $class::pipeline(); -// -// foreach ($class::normalizers() as $normalizer) { -// $pipeline->normalizer($normalizer); -// } -// -// foreach ($pipeline->using($payload)->execute() as $key => $value) { -// $properties[$key] = $value; -// } -// } - - $properties = array_reduce( - $payloads, - function (Collection $carry, mixed $payload) use ($class) { - /** @var BaseData $class */ - $pipeline = $class::pipeline(); - - foreach ($class::normalizers() as $normalizer) { - $pipeline->normalizer($normalizer); - } - - return $carry->merge($pipeline->using($payload)->execute()); - }, - collect(), - ); + $properties = new Collection(); + + $pipeline = $this->dataConfig->getResolvedDataPipeline($class); + + foreach ($payloads as $payload) { + foreach ($pipeline->execute($payload) as $key => $value) { + $properties[$key] = $value; + } + } return $this->dataFromArrayResolver->execute($class, $properties); } @@ -101,13 +80,11 @@ protected function createFromCustomCreationMethod(string $class, array $payloads return null; } + $pipeline = $this->dataConfig->getResolvedDataPipeline($class); + foreach ($payloads as $payload) { if ($payload instanceof Request) { - $class::pipeline() - ->normalizer(ArrayableNormalizer::class) - ->into($class) - ->using($payload) - ->execute(); + $pipeline->execute($payload); } } diff --git a/src/Resolvers/EmptyDataResolver.php b/src/Resolvers/EmptyDataResolver.php index b67ed410..9c86e2e5 100644 --- a/src/Resolvers/EmptyDataResolver.php +++ b/src/Resolvers/EmptyDataResolver.php @@ -17,7 +17,9 @@ public function execute(string $class, array $extra = []): array { $dataClass = $this->dataConfig->getDataClass($class); - return $dataClass->properties->reduce(function (array $payload, DataProperty $property) use ($extra) { + $payload = []; + + foreach ($dataClass->properties as $property) { $name = $property->outputMappedName ?? $property->name; if ($property->hasDefaultValue) { @@ -25,9 +27,9 @@ public function execute(string $class, array $extra = []): array } else { $payload[$name] = $extra[$property->name] ?? $this->getValueForProperty($property); } + } - return $payload; - }, []); + return $payload; } protected function getValueForProperty(DataProperty $property): mixed diff --git a/src/Support/DataConfig.php b/src/Support/DataConfig.php index e35e8219..c9591b5e 100644 --- a/src/Support/DataConfig.php +++ b/src/Support/DataConfig.php @@ -17,6 +17,9 @@ class DataConfig /** @var array */ protected array $casts = []; + /** @var array */ + protected array $resolvedDataPipelines = []; + /** @var \Spatie\LaravelData\RuleInferrers\RuleInferrer[] */ protected array $ruleInferrers; @@ -45,6 +48,15 @@ public function getDataClass(string $class): DataClass return $this->dataClasses[$class] = DataClass::create(new ReflectionClass($class)); } + public function getResolvedDataPipeline(string $class): ResolvedDataPipeline + { + if (array_key_exists($class, $this->resolvedDataPipelines)) { + return $this->resolvedDataPipelines[$class]; + } + + return $this->resolvedDataPipelines[$class] = $class::pipeline()->resolve(); + } + public function findGlobalCastForProperty(DataProperty $property): ?Cast { foreach ($property->type->acceptedTypes as $acceptedType => $baseTypes) { @@ -81,4 +93,12 @@ public function getRuleInferrers(): array { return $this->ruleInferrers; } + + public function reset(): self + { + $this->dataClasses = []; + $this->resolvedDataPipelines = []; + + return $this; + } } diff --git a/src/Support/ResolvedDataPipeline.php b/src/Support/ResolvedDataPipeline.php new file mode 100644 index 00000000..8c1cb1fe --- /dev/null +++ b/src/Support/ResolvedDataPipeline.php @@ -0,0 +1,49 @@ + $normalizers + * @param array<\Spatie\LaravelData\DataPipes\DataPipe> $pipes + */ + public function __construct( + protected array $normalizers, + protected array $pipes, + protected DataClass $dataClass, + ) { + } + + public function execute(mixed $value): Collection + { + $properties = null; + + foreach ($this->normalizers as $normalizer) { + $properties = $normalizer->normalize($value); + + if ($properties !== null) { + break; + } + } + + if ($properties === null) { + throw CannotCreateData::noNormalizerFound($this->dataClass->name, $value); + } + + $properties = collect($properties); + + $properties = ($this->dataClass->name)::prepareForPipeline($properties); + + foreach ($this->pipes as $pipe) { + $piped = $pipe->handle($value, $this->dataClass, $properties); + + $properties = $piped; + } + + return $properties; + } +} diff --git a/src/Transformers/DataCollectableTransformer.php b/src/Transformers/DataCollectableTransformer.php index 7a27b3c5..5c3bdbb4 100644 --- a/src/Transformers/DataCollectableTransformer.php +++ b/src/Transformers/DataCollectableTransformer.php @@ -9,7 +9,6 @@ use Illuminate\Support\Enumerable; use Spatie\LaravelData\Contracts\BaseData; use Spatie\LaravelData\Contracts\IncludeableData; -use Spatie\LaravelData\Contracts\TransformableData; use Spatie\LaravelData\Support\PartialTrees; use Spatie\LaravelData\Support\Wrapping\Wrap; use Spatie\LaravelData\Support\Wrapping\WrapExecutionType; @@ -44,22 +43,29 @@ public function transform(): array protected function transformCollection(Enumerable $items): array { - $items = $items->map($this->transformItemClosure()) - ->when( + $payload = []; + + foreach ($items as $key => $item) { + $normalized = $this->transformItemClosure()($item); + + if (! $this->transformValues) { + $payload[$key] = $normalized; + + continue; + } + + $payload[$key] = $normalized->transform( $this->transformValues, - fn (Enumerable $collection) => $collection->map(fn (TransformableData $data) => $data->transform( - $this->transformValues, - $this->wrapExecutionType->shouldExecute() - ? WrapExecutionType::TemporarilyDisabled - : $this->wrapExecutionType, - $this->mapPropertyNames, - )) - ) - ->all(); + $this->wrapExecutionType->shouldExecute() + ? WrapExecutionType::TemporarilyDisabled + : $this->wrapExecutionType, + $this->mapPropertyNames, + ); + } return $this->wrapExecutionType->shouldExecute() - ? $this->wrap->wrap($items) - : $items; + ? $this->wrap->wrap($payload) + : $payload; } protected function transformItemClosure(): Closure diff --git a/src/Transformers/DataTransformer.php b/src/Transformers/DataTransformer.php index 94657a1d..736f2b09 100644 --- a/src/Transformers/DataTransformer.php +++ b/src/Transformers/DataTransformer.php @@ -94,34 +94,6 @@ protected function resolvePayload(TransformableData $data): array } return $payload; - -// return $dataClass -// ->properties -// ->reduce(function (array $payload, DataProperty $property) use ($data, $trees) { -// $name = $property->name; -// -// if (! $this->shouldIncludeProperty($name, $data->{$name}, $trees)) { -// return $payload; -// } -// -// $value = $this->resolvePropertyValue( -// $property, -// $data->{$name}, -// $trees->getNested($name), -// ); -// -// if ($value instanceof Optional) { -// return $payload; -// } -// -// if ($this->mapPropertyNames && $property->outputMappedName) { -// $name = $property->outputMappedName; -// } -// -// $payload[$name] = $value; -// -// return $payload; -// }, []); } protected function shouldIncludeProperty( diff --git a/tests/TestSupport/DataValidationAsserter.php b/tests/TestSupport/DataValidationAsserter.php index 93017d04..15270611 100644 --- a/tests/TestSupport/DataValidationAsserter.php +++ b/tests/TestSupport/DataValidationAsserter.php @@ -158,12 +158,12 @@ public function assertAttributes( private function pipePayload(array $payload): array { $properties = app(DataPipeline::class) - ->using($payload) ->normalizer(ArrayNormalizer::class) ->into($this->dataClass) ->through(MapPropertiesDataPipe::class) ->through(ValidatePropertiesDataPipe::class) - ->execute(); + ->resolve() + ->execute($payload); return $properties->all(); } diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index ad4bb14e..93fd8f74 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -19,14 +19,15 @@ use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\MapInputName; -use Spatie\LaravelData\Attributes\MapName; +use Spatie\LaravelData\Attributes\MapName; use Spatie\LaravelData\Attributes\Validation\ArrayType; use Spatie\LaravelData\Attributes\Validation\Bail; -use Spatie\LaravelData\Attributes\Validation\Exists; +use Spatie\LaravelData\Attributes\Validation\Exists; use Spatie\LaravelData\Attributes\Validation\In; + use Spatie\LaravelData\Attributes\Validation\IntegerType; use Spatie\LaravelData\Attributes\Validation\Max; use Spatie\LaravelData\Attributes\Validation\Min; @@ -39,8 +40,8 @@ use Spatie\LaravelData\Attributes\WithoutValidation; use Spatie\LaravelData\Data; use Spatie\LaravelData\DataCollection; - use Spatie\LaravelData\DataPipeline; + use Spatie\LaravelData\DataPipes\AuthorizedDataPipe; use Spatie\LaravelData\DataPipes\CastPropertiesDataPipe; use Spatie\LaravelData\DataPipes\DefaultValuesDataPipe; @@ -52,6 +53,7 @@ use Spatie\LaravelData\Normalizers\ModelNormalizer; use Spatie\LaravelData\Normalizers\ObjectNormalizer; use Spatie\LaravelData\Optional; +use Spatie\LaravelData\Support\DataConfig; use Spatie\LaravelData\Support\Validation\References\FieldReference; use Spatie\LaravelData\Support\Validation\References\RouteParameterReference; use Spatie\LaravelData\Support\Validation\ValidationContext; @@ -2002,6 +2004,8 @@ public static function pipeline(): DataPipeline expect($data)->toBeInstanceOf(Data::class) ->string->toEqual('nowp'); + app(DataConfig::class)->reset(); + $dataClass::$validateAllTypes = true; $data = $dataClass::from([