From dc33c9d78c8ac4613bc765fc30852de1f42e3ccf Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 20 Apr 2025 12:33:03 +0200 Subject: [PATCH 1/4] fix readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dd90f4a..4d83fa5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The library is a core component of [patchlevel/event-sourcing](ttps://github.com where it powers the storage and retrieval of thousands of objects. Hydration is handled through normalizers, especially for complex data types. -The system can automatically determine the appropriate normalizer based on the data type and PHPStan/Psalm annotations. +The system can automatically determine the appropriate normalizer based on the data type and annotations. In most cases, no manual configuration is needed. And if customization is required, it can be done easily using attributes. From 6b3cb99fd60166a6d09a4442f46ec861eeac81da Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 6 Jun 2025 11:11:07 +0200 Subject: [PATCH 2/4] add support for array shapes --- README.md | 39 +- baseline.xml | 137 +++- composer.json | 2 +- composer.lock | 656 ++++++++++-------- phpstan-baseline.neon | 2 +- src/Metadata/AttributeMetadataFactory.php | 38 + src/Normalizer/ArrayShapeNormalizer.php | 107 +++ .../InferNormalizerWithIterablesDto.php | 2 + tests/Unit/MetadataHydratorTest.php | 5 + .../Normalizer/ArrayShapeNormalizerTest.php | 99 +++ 10 files changed, 804 insertions(+), 283 deletions(-) create mode 100644 src/Normalizer/ArrayShapeNormalizer.php create mode 100644 tests/Unit/Normalizer/ArrayShapeNormalizerTest.php diff --git a/README.md b/README.md index 4d83fa5..5a38989 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,10 @@ For this purpose, normalizers of this order are determined: 1) Does the class property have a normalizer as an attribute? Use this. 2) The data type of the property is determined. - 1) If it is a collection, use the ArrayNormalizer (recursive). - 2) If it is an object, then look for a normalizer as attribute on the class or interfaces and use this. - 3) If it is an object, then guess the normalizer based on the object. Fallback to the object normalizer. + 1) If it is an array shape, use the ArrayShapeNormalizer (recursive). + 2) If it is a collection, use the ArrayNormalizer (recursive). + 3) If it is an object, then look for a normalizer as attribute on the class or interfaces and use this. + 4) If it is an object, then guess the normalizer based on the object. Fallback to the object normalizer. The normalizer is only determined once because it is cached in the metadata. Below you will find the list of all normalizers and how to set them manually or explicitly. @@ -155,14 +156,44 @@ use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; final class DTO { - #[ArrayNormalizer(new DateTimeImmutableNormalizer())] + /** + * @var list + */ + #[ArrayNormalizer] public array $dates; + + #[ArrayNormalizer(new DateTimeImmutableNormalizer())] + public array $explicitDates; } ``` > [!NOTE] > The keys from the arrays are taken over here. +#### ArrayShape + +If you have an array with a specific shape, you can use the `ArrayShapeNormalizer`. + +```php +use Patchlevel\Hydrator\Normalizer\ArrayShapeNormalizer; +use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; + +final class DTO +{ + /** + * @var array{ + * date: DateTimeImmutable, + * otherField: string + * } + */ + #[ArrayShapeNormalizer] + public array $meta; + + #[ArrayShapeNormalizer(['date' => new DateTimeImmutableNormalizer()])] + public array $explicitMeta; +} +``` + #### DateTimeImmutable With the `DateTimeImmutable` Normalizer, as the name suggests, diff --git a/baseline.xml b/baseline.xml index 7869dec..f0ef8fa 100644 --- a/baseline.xml +++ b/baseline.xml @@ -1,5 +1,10 @@ - + + + + + + ivLength)]]> @@ -25,16 +30,58 @@ - - - + + + + + + + + + + + + personalDataFallbackCallable]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -42,10 +89,47 @@ + + + + + + + + + + + $this->extractProfileCreated($object), + $object instanceof Skill => $this->extractSkill($object), + default => throw new InvalidArgumentException('Unknown object type'), + }]]> + + + + + + + + + + + + + + + + + + + @@ -59,8 +143,53 @@ + + metadata(ProfileCreated::class)]]> + metadata(ProfileCreated::class)]]> + metadata(ProfileCreated::class)]]> + metadata(ProfileCreated::class)]]> + + + + + + + + + $normalizer]]]> + + + + + reflectionType($object, 'notAObject'))]]> + reflectionType($object, 'object'))]]> + reflectionType($object, 'objectNullable'))]]> + reflectionType($object, 'objectUnionNullable'))]]> + reflectionType($object, 'object'), + ProfileCreated::class, + )]]> + reflectionType($object, 'objectNullable'), + ProfileCreated::class, + )]]> + reflectionType($object, 'objectUnionNullable'), + ProfileCreated::class, + )]]> + reflectionType($object, 'object'), + ChildDto::class, + )]]> + reflectionType($object, 'intersection'))]]> + reflectionType($object, 'nullableString'))]]> + reflectionType($object, 'string'))]]> + reflectionType($object, 'union'))]]> + reflectionType($object, 'unionNullableString'))]]> + + diff --git a/composer.json b/composer.json index d9e0e1d..572be19 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "psr/cache": "^2.0.0 || ^3.0.0", "psr/simple-cache": "^2.0.0 || ^3.0.0", "symfony/event-dispatcher": "^5.4.29 || ^6.4.0 || ^7.0.0", - "symfony/type-info": "^7.2.4" + "symfony/type-info": "^7.3.0" }, "require-dev": { "infection/infection": "^0.29.10", diff --git a/composer.lock b/composer.lock index ebf27f9..7ef1e2f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "88a6e4328de05fee58b52ac893a9927c", + "content-hash": "780c8d213d902449d9c8d03c4985c73e", "packages": [ { "name": "psr/cache", @@ -209,18 +209,85 @@ }, "time": "2021-10-29T13:26:27+00:00" }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, { "name": "symfony/event-dispatcher", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1" + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/910c5db85a5356d0fea57680defec4e99eb9c8c1", - "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", "shasum": "" }, "require": { @@ -271,7 +338,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.2.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.0" }, "funding": [ { @@ -287,20 +354,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-04-22T09:11:45+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", "shasum": "" }, "require": { @@ -314,7 +381,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -347,7 +414,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" }, "funding": [ { @@ -363,28 +430,32 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/type-info", - "version": "v7.2.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "c4824a6b658294c828e609d3d8dbb4e87f6a375d" + "reference": "bc9af22e25796d98078f69c0749ab3a9d3454786" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/c4824a6b658294c828e609d3d8dbb4e87f6a375d", - "reference": "c4824a6b658294c828e609d3d8dbb4e87f6a375d", + "url": "https://api.github.com/repos/symfony/type-info/zipball/bc9af22e25796d98078f69c0749ab3a9d3454786", + "reference": "bc9af22e25796d98078f69c0749ab3a9d3454786", "shasum": "" }, "require": { "php": ">=8.2", - "psr/container": "^1.1|^2.0" + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" }, "require-dev": { - "phpstan/phpdoc-parser": "^1.0|^2.0" + "phpstan/phpdoc-parser": "^1.30|^2.0" }, "type": "library", "autoload": { @@ -422,7 +493,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.2.5" + "source": "https://github.com/symfony/type-info/tree/v7.3.0" }, "funding": [ { @@ -438,7 +509,7 @@ "type": "tidelift" } ], - "time": "2025-03-24T09:03:36+00:00" + "time": "2025-03-30T12:17:06+00:00" } ], "packages-dev": [ @@ -2272,16 +2343,16 @@ }, { "name": "infection/infection", - "version": "0.29.12", + "version": "0.29.14", "source": { "type": "git", "url": "https://github.com/infection/infection.git", - "reference": "dfe9cf6e65545881c7d21343c494cc8a1fdbfb80" + "reference": "feea2a48a8aeedd3a4d2105167b41a46f0e568a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/infection/zipball/dfe9cf6e65545881c7d21343c494cc8a1fdbfb80", - "reference": "dfe9cf6e65545881c7d21343c494cc8a1fdbfb80", + "url": "https://api.github.com/repos/infection/infection/zipball/feea2a48a8aeedd3a4d2105167b41a46f0e568a3", + "reference": "feea2a48a8aeedd3a4d2105167b41a46f0e568a3", "shasum": "" }, "require": { @@ -2297,7 +2368,7 @@ "infection/extension-installer": "^0.1.0", "infection/include-interceptor": "^0.2.5", "infection/mutator": "^0.4", - "justinrainbow/json-schema": "^5.3", + "justinrainbow/json-schema": "^5.3 || ^6.0", "nikic/php-parser": "^5.3", "ondram/ci-detector": "^4.1.0", "php": "^8.2", @@ -2319,16 +2390,16 @@ "require-dev": { "ext-simplexml": "*", "fidry/makefile": "^1.0", - "helmich/phpunit-json-assert": "^3.0", - "phpstan/extension-installer": "^1.1.0", - "phpstan/phpstan": "^1.10.15", - "phpstan/phpstan-phpunit": "^1.0.0", - "phpstan/phpstan-strict-rules": "^1.1.0", - "phpstan/phpstan-webmozart-assert": "^1.0.2", - "phpunit/phpunit": "^10.5", - "rector/rector": "^1.0", - "sidz/phpstan-rules": "^0.4", - "symfony/yaml": "^6.4 || ^7.0" + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^11.5", + "rector/rector": "^2.0", + "sidz/phpstan-rules": "^0.5.1", + "symfony/yaml": "^6.4 || ^7.0", + "thecodingmachine/phpstan-safe-rule": "^1.4" }, "bin": [ "bin/infection" @@ -2384,7 +2455,7 @@ ], "support": { "issues": "https://github.com/infection/infection/issues", - "source": "https://github.com/infection/infection/tree/0.29.12" + "source": "https://github.com/infection/infection/tree/0.29.14" }, "funding": [ { @@ -2396,20 +2467,20 @@ "type": "open_collective" } ], - "time": "2025-02-17T18:25:11+00:00" + "time": "2025-03-02T18:49:12+00:00" }, { "name": "infection/mutator", - "version": "0.4.0", + "version": "0.4.1", "source": { "type": "git", "url": "https://github.com/infection/mutator.git", - "reference": "51d6d01a2357102030aee9d603063c4bad86b144" + "reference": "3c976d721b02b32f851ee4e15d553ef1e9186d1d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/mutator/zipball/51d6d01a2357102030aee9d603063c4bad86b144", - "reference": "51d6d01a2357102030aee9d603063c4bad86b144", + "url": "https://api.github.com/repos/infection/mutator/zipball/3c976d721b02b32f851ee4e15d553ef1e9186d1d", + "reference": "3c976d721b02b32f851ee4e15d553ef1e9186d1d", "shasum": "" }, "require": { @@ -2437,7 +2508,7 @@ "description": "Mutator interface to implement custom mutators (mutation operators) for Infection", "support": { "issues": "https://github.com/infection/mutator/issues", - "source": "https://github.com/infection/mutator/tree/0.4.0" + "source": "https://github.com/infection/mutator/tree/0.4.1" }, "funding": [ { @@ -2449,34 +2520,44 @@ "type": "open_collective" } ], - "time": "2024-05-14T22:39:59+00:00" + "time": "2025-04-29T08:19:52+00:00" }, { "name": "justinrainbow/json-schema", - "version": "5.3.0", + "version": "6.4.2", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "feb2ca6dd1cebdaf1ed60a4c8de2e53ce11c4fd8" + "reference": "ce1fd2d47799bb60668643bc6220f6278a4c1d02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/feb2ca6dd1cebdaf1ed60a4c8de2e53ce11c4fd8", - "reference": "feb2ca6dd1cebdaf1ed60a4c8de2e53ce11c4fd8", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/ce1fd2d47799bb60668643bc6220f6278a4c1d02", + "reference": "ce1fd2d47799bb60668643bc6220f6278a4c1d02", "shasum": "" }, "require": { - "php": ">=7.1" + "ext-json": "*", + "marc-mabe/php-enum": "^4.0", + "php": "^7.2 || ^8.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", + "friendsofphp/php-cs-fixer": "3.3.0", "json-schema/json-schema-test-suite": "1.2.0", - "phpunit/phpunit": "^4.8.35" + "marc-mabe/php-enum-phpstan": "^2.0", + "phpspec/prophecy": "^1.19", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^8.5" }, "bin": [ "bin/validate-json" ], "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.x-dev" + } + }, "autoload": { "psr-4": { "JsonSchema\\": "src/JsonSchema/" @@ -2505,16 +2586,16 @@ } ], "description": "A library to validate a json schema.", - "homepage": "https://github.com/justinrainbow/json-schema", + "homepage": "https://github.com/jsonrainbow/json-schema", "keywords": [ "json", "schema" ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/5.3.0" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.4.2" }, - "time": "2024-07-06T21:00:26+00:00" + "time": "2025-06-03T18:27:04+00:00" }, { "name": "kelunik/certificate", @@ -2748,18 +2829,91 @@ ], "time": "2024-12-08T08:18:47+00:00" }, + { + "name": "marc-mabe/php-enum", + "version": "v4.7.1", + "source": { + "type": "git", + "url": "https://github.com/marc-mabe/php-enum.git", + "reference": "7159809e5cfa041dca28e61f7f7ae58063aae8ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/7159809e5cfa041dca28e61f7f7ae58063aae8ed", + "reference": "7159809e5cfa041dca28e61f7f7ae58063aae8ed", + "shasum": "" + }, + "require": { + "ext-reflection": "*", + "php": "^7.1 | ^8.0" + }, + "require-dev": { + "phpbench/phpbench": "^0.16.10 || ^1.0.4", + "phpstan/phpstan": "^1.3.1", + "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", + "vimeo/psalm": "^4.17.0 | ^5.26.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-3.x": "3.2-dev", + "dev-master": "4.7-dev" + } + }, + "autoload": { + "psr-4": { + "MabeEnum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Marc Bennewitz", + "email": "dev@mabe.berlin", + "homepage": "https://mabe.berlin/", + "role": "Lead" + } + ], + "description": "Simple and fast implementation of enumerations with native PHP", + "homepage": "https://github.com/marc-mabe/php-enum", + "keywords": [ + "enum", + "enum-map", + "enum-set", + "enumeration", + "enumerator", + "enummap", + "enumset", + "map", + "set", + "type", + "type-hint", + "typehint" + ], + "support": { + "issues": "https://github.com/marc-mabe/php-enum/issues", + "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.1" + }, + "time": "2024-11-28T04:54:44+00:00" + }, { "name": "myclabs/deep-copy", - "version": "1.13.0", + "version": "1.13.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", "shasum": "" }, "require": { @@ -2798,7 +2952,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" }, "funding": [ { @@ -2806,7 +2960,7 @@ "type": "tidelift" } ], - "time": "2025-02-12T12:17:51+00:00" + "time": "2025-04-29T12:36:36+00:00" }, { "name": "netresearch/jsonmapper", @@ -2861,16 +3015,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.5.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", "shasum": "" }, "require": { @@ -2913,9 +3067,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-05-31T08:24:38+00:00" }, { "name": "ondram/ci-detector", @@ -3541,16 +3695,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.12", + "version": "2.1.17", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "96dde49e967c0c22812bcfa7bda4ff82c09f3b0c" + "reference": "89b5ef665716fa2a52ecd2633f21007a6a349053" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/96dde49e967c0c22812bcfa7bda4ff82c09f3b0c", - "reference": "96dde49e967c0c22812bcfa7bda4ff82c09f3b0c", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/89b5ef665716fa2a52ecd2633f21007a6a349053", + "reference": "89b5ef665716fa2a52ecd2633f21007a6a349053", "shasum": "" }, "require": { @@ -3595,7 +3749,7 @@ "type": "github" } ], - "time": "2025-04-16T13:19:18+00:00" + "time": "2025-05-21T20:55:28+00:00" }, { "name": "phpunit/php-code-coverage", @@ -3922,16 +4076,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.17", + "version": "11.5.22", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "fd2e863a2995cdfd864fb514b5e0b28b09895b5c" + "reference": "4cd72faaa8f811e4cc63040cba167757660a5538" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fd2e863a2995cdfd864fb514b5e0b28b09895b5c", - "reference": "fd2e863a2995cdfd864fb514b5e0b28b09895b5c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4cd72faaa8f811e4cc63040cba167757660a5538", + "reference": "4cd72faaa8f811e4cc63040cba167757660a5538", "shasum": "" }, "require": { @@ -3941,7 +4095,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.0", + "myclabs/deep-copy": "^1.13.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", @@ -3954,7 +4108,7 @@ "sebastian/code-unit": "^3.0.3", "sebastian/comparator": "^6.3.1", "sebastian/diff": "^6.0.2", - "sebastian/environment": "^7.2.0", + "sebastian/environment": "^7.2.1", "sebastian/exporter": "^6.3.0", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", @@ -4003,7 +4157,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.17" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.22" }, "funding": [ { @@ -4014,12 +4168,20 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2025-04-08T07:59:11+00:00" + "time": "2025-06-06T02:48:05+00:00" }, { "name": "psalm/plugin-phpunit", @@ -4311,33 +4473,33 @@ }, { "name": "roave/infection-static-analysis-plugin", - "version": "1.37.0", + "version": "1.38.0", "source": { "type": "git", "url": "https://github.com/Roave/infection-static-analysis-plugin.git", - "reference": "062af2a493b570346f6cbbae378e1d69bc4194bb" + "reference": "431c7bba21df58bf11186ec03501e2b47d321b2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/infection-static-analysis-plugin/zipball/062af2a493b570346f6cbbae378e1d69bc4194bb", - "reference": "062af2a493b570346f6cbbae378e1d69bc4194bb", + "url": "https://api.github.com/repos/Roave/infection-static-analysis-plugin/zipball/431c7bba21df58bf11186ec03501e2b47d321b2f", + "reference": "431c7bba21df58bf11186ec03501e2b47d321b2f", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", - "infection/infection": "0.29.12", + "infection/infection": "0.29.14", "php": "~8.2.0 || ~8.3.0 || ~8.4.0", - "sanmai/later": "^0.1.4", - "vimeo/psalm": "^6.8.8" + "sanmai/later": "^0.1.7", + "vimeo/psalm": "^6.12.0" }, "conflict": { "symfony/polyfill-php84": "<1.30.0" }, "require-dev": { - "azjezz/psl": "^3.2", - "doctrine/coding-standard": "^12.0.0", - "phpunit/phpunit": "^11.5.10", - "psalm/plugin-phpunit": "^0.19.2" + "azjezz/psl": "^3.3.0", + "doctrine/coding-standard": "^13.0.1", + "phpunit/phpunit": "^11.5.21", + "psalm/plugin-phpunit": "^0.19.5" }, "bin": [ "bin/roave-infection-static-analysis-plugin" @@ -4361,26 +4523,26 @@ "description": "Static analysis on top of mutation testing - prevents escaped mutants from being invalid according to static analysis", "support": { "issues": "https://github.com/Roave/infection-static-analysis-plugin/issues", - "source": "https://github.com/Roave/infection-static-analysis-plugin/tree/1.37.0" + "source": "https://github.com/Roave/infection-static-analysis-plugin/tree/1.38.0" }, - "time": "2025-02-27T18:02:45+00:00" + "time": "2025-06-04T01:32:53+00:00" }, { "name": "sanmai/later", - "version": "0.1.5", + "version": "0.1.7", "source": { "type": "git", "url": "https://github.com/sanmai/later.git", - "reference": "cf5164557d19930295892094996f049ea12ba14d" + "reference": "72a82d783864bca90412d8a26c1878f8981fee97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sanmai/later/zipball/cf5164557d19930295892094996f049ea12ba14d", - "reference": "cf5164557d19930295892094996f049ea12ba14d", + "url": "https://api.github.com/repos/sanmai/later/zipball/72a82d783864bca90412d8a26c1878f8981fee97", + "reference": "72a82d783864bca90412d8a26c1878f8981fee97", "shasum": "" }, "require": { - "php": ">=7.4" + "php": ">=8.2" }, "require-dev": { "ergebnis/composer-normalize": "^2.8", @@ -4419,7 +4581,7 @@ "description": "Later: deferred wrapper object", "support": { "issues": "https://github.com/sanmai/later/issues", - "source": "https://github.com/sanmai/later/tree/0.1.5" + "source": "https://github.com/sanmai/later/tree/0.1.7" }, "funding": [ { @@ -4427,24 +4589,24 @@ "type": "github" } ], - "time": "2024-12-06T02:36:26+00:00" + "time": "2025-05-11T01:48:00+00:00" }, { "name": "sanmai/pipeline", - "version": "6.12", + "version": "6.16", "source": { "type": "git", "url": "https://github.com/sanmai/pipeline.git", - "reference": "ad7dbc3f773eeafb90d5459522fbd8f188532e25" + "reference": "f32413630904f83b069a7fbdfab34267dbaecdb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sanmai/pipeline/zipball/ad7dbc3f773eeafb90d5459522fbd8f188532e25", - "reference": "ad7dbc3f773eeafb90d5459522fbd8f188532e25", + "url": "https://api.github.com/repos/sanmai/pipeline/zipball/f32413630904f83b069a7fbdfab34267dbaecdb6", + "reference": "f32413630904f83b069a7fbdfab34267dbaecdb6", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" + "php": ">=8.2" }, "require-dev": { "ergebnis/composer-normalize": "^2.8", @@ -4453,8 +4615,8 @@ "league/pipeline": "^0.3 || ^1.0", "phan/phan": ">=1.1", "php-coveralls/php-coveralls": "^2.4.1", - "phpstan/phpstan": ">=0.10", - "phpunit/phpunit": ">=9.4", + "phpstan/phpstan": ">=0.10 <2", + "phpunit/phpunit": ">=9.4 <12", "vimeo/psalm": ">=2" }, "type": "library", @@ -4484,7 +4646,7 @@ "description": "General-purpose collections pipeline", "support": { "issues": "https://github.com/sanmai/pipeline/issues", - "source": "https://github.com/sanmai/pipeline/tree/6.12" + "source": "https://github.com/sanmai/pipeline/tree/6.16" }, "funding": [ { @@ -4492,7 +4654,7 @@ "type": "github" } ], - "time": "2024-10-17T02:22:57+00:00" + "time": "2025-06-02T06:36:22+00:00" }, { "name": "sebastian/cli-parser", @@ -4871,23 +5033,23 @@ }, { "name": "sebastian/environment", - "version": "7.2.0", + "version": "7.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5" + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "suggest": { "ext-posix": "*" @@ -4923,15 +5085,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0" + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2024-07-03T04:54:44+00:00" + "time": "2025-05-21T11:55:47+00:00" }, { "name": "sebastian/exporter", @@ -5486,32 +5660,32 @@ }, { "name": "slevomat/coding-standard", - "version": "8.17.0", + "version": "8.18.1", "source": { "type": "git", "url": "https://github.com/slevomat/coding-standard.git", - "reference": "ace04a4e2e20c9bc26ad14d6c4c737cde6056ec0" + "reference": "06b18b3f64979ab31d27c37021838439f3ed5919" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/ace04a4e2e20c9bc26ad14d6c4c737cde6056ec0", - "reference": "ace04a4e2e20c9bc26ad14d6c4c737cde6056ec0", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/06b18b3f64979ab31d27c37021838439f3ed5919", + "reference": "06b18b3f64979ab31d27c37021838439f3ed5919", "shasum": "" }, "require": { "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.0", "php": "^7.4 || ^8.0", "phpstan/phpdoc-parser": "^2.1.0", - "squizlabs/php_codesniffer": "^3.12.1" + "squizlabs/php_codesniffer": "^3.13.0" }, "require-dev": { "phing/phing": "3.0.1", "php-parallel-lint/php-parallel-lint": "1.4.0", - "phpstan/phpstan": "2.1.11", - "phpstan/phpstan-deprecation-rules": "2.0.1", + "phpstan/phpstan": "2.1.17", + "phpstan/phpstan-deprecation-rules": "2.0.3", "phpstan/phpstan-phpunit": "2.0.6", "phpstan/phpstan-strict-rules": "2.0.4", - "phpunit/phpunit": "9.6.8|10.5.45|11.4.4|11.5.17|12.1.2" + "phpunit/phpunit": "9.6.8|10.5.45|11.4.4|11.5.21|12.1.3" }, "type": "phpcodesniffer-standard", "extra": { @@ -5535,7 +5709,7 @@ ], "support": { "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/8.17.0" + "source": "https://github.com/slevomat/coding-standard/tree/8.18.1" }, "funding": [ { @@ -5547,7 +5721,7 @@ "type": "tidelift" } ], - "time": "2025-04-10T06:06:16+00:00" + "time": "2025-05-22T14:32:30+00:00" }, { "name": "spatie/array-to-xml", @@ -5619,16 +5793,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.12.2", + "version": "3.13.0", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa" + "reference": "65ff2489553b83b4597e89c3b8b721487011d186" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa", - "reference": "6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/65ff2489553b83b4597e89c3b8b721487011d186", + "reference": "65ff2489553b83b4597e89c3b8b721487011d186", "shasum": "" }, "require": { @@ -5699,7 +5873,7 @@ "type": "thanks_dev" } ], - "time": "2025-04-13T04:10:18+00:00" + "time": "2025-05-11T03:36:00+00:00" }, { "name": "staabm/side-effects-detector", @@ -5755,23 +5929,24 @@ }, { "name": "symfony/console", - "version": "v7.2.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e51498ea18570c062e7df29d05a7003585b19b88" + "reference": "66c1440edf6f339fd82ed6c7caa76cb006211b44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e51498ea18570c062e7df29d05a7003585b19b88", - "reference": "e51498ea18570c062e7df29d05a7003585b19b88", + "url": "https://api.github.com/repos/symfony/console/zipball/66c1440edf6f339fd82ed6c7caa76cb006211b44", + "reference": "66c1440edf6f339fd82ed6c7caa76cb006211b44", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^6.4|^7.0" + "symfony/string": "^7.2" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -5828,7 +6003,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.5" + "source": "https://github.com/symfony/console/tree/v7.3.0" }, "funding": [ { @@ -5844,78 +6019,11 @@ "type": "tidelift" } ], - "time": "2025-03-12T08:11:12+00:00" - }, - { - "name": "symfony/deprecation-contracts", - "version": "v3.5.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.5-dev" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2025-05-24T10:34:04+00:00" }, { "name": "symfony/filesystem", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", @@ -5961,7 +6069,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.2.0" + "source": "https://github.com/symfony/filesystem/tree/v7.3.0" }, "funding": [ { @@ -5981,16 +6089,16 @@ }, { "name": "symfony/finder", - "version": "v7.2.2", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "87a71856f2f56e4100373e92529eed3171695cfb" + "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/87a71856f2f56e4100373e92529eed3171695cfb", - "reference": "87a71856f2f56e4100373e92529eed3171695cfb", + "url": "https://api.github.com/repos/symfony/finder/zipball/ec2344cf77a48253bbca6939aa3d2477773ea63d", + "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d", "shasum": "" }, "require": { @@ -6025,7 +6133,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.2.2" + "source": "https://github.com/symfony/finder/tree/v7.3.0" }, "funding": [ { @@ -6041,20 +6149,20 @@ "type": "tidelift" } ], - "time": "2024-12-30T19:00:17+00:00" + "time": "2024-12-30T19:00:26+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "7da8fbac9dcfef75ffc212235d76b2754ce0cf50" + "reference": "afb9a8038025e5dbc657378bfab9198d75f10fca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/7da8fbac9dcfef75ffc212235d76b2754ce0cf50", - "reference": "7da8fbac9dcfef75ffc212235d76b2754ce0cf50", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/afb9a8038025e5dbc657378bfab9198d75f10fca", + "reference": "afb9a8038025e5dbc657378bfab9198d75f10fca", "shasum": "" }, "require": { @@ -6092,7 +6200,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.2.0" + "source": "https://github.com/symfony/options-resolver/tree/v7.3.0" }, "funding": [ { @@ -6108,11 +6216,11 @@ "type": "tidelift" } ], - "time": "2024-11-20T11:17:29+00:00" + "time": "2025-04-04T13:12:05+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -6171,7 +6279,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" }, "funding": [ { @@ -6191,7 +6299,7 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -6249,7 +6357,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" }, "funding": [ { @@ -6269,7 +6377,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -6330,7 +6438,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" }, "funding": [ { @@ -6350,19 +6458,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -6410,7 +6519,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" }, "funding": [ { @@ -6426,20 +6535,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "e5493eb51311ab0b1cc2243416613f06ed8f18bd" + "reference": "000df7860439609837bbe28670b0be15783b7fbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/e5493eb51311ab0b1cc2243416613f06ed8f18bd", - "reference": "e5493eb51311ab0b1cc2243416613f06ed8f18bd", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/000df7860439609837bbe28670b0be15783b7fbf", + "reference": "000df7860439609837bbe28670b0be15783b7fbf", "shasum": "" }, "require": { @@ -6486,7 +6595,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.32.0" }, "funding": [ { @@ -6502,20 +6611,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T12:04:04+00:00" + "time": "2025-02-20T12:04:08+00:00" }, { "name": "symfony/process", - "version": "v7.2.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "87b7c93e57df9d8e39a093d32587702380ff045d" + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/87b7c93e57df9d8e39a093d32587702380ff045d", - "reference": "87b7c93e57df9d8e39a093d32587702380ff045d", + "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", "shasum": "" }, "require": { @@ -6547,7 +6656,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.2.5" + "source": "https://github.com/symfony/process/tree/v7.3.0" }, "funding": [ { @@ -6563,20 +6672,20 @@ "type": "tidelift" } ], - "time": "2025-03-13T12:21:46+00:00" + "time": "2025-04-17T09:11:12+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", "shasum": "" }, "require": { @@ -6594,7 +6703,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -6630,7 +6739,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" }, "funding": [ { @@ -6646,20 +6755,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2025-04-25T09:37:31+00:00" }, { "name": "symfony/string", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", "shasum": "" }, "require": { @@ -6717,7 +6826,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.0" + "source": "https://github.com/symfony/string/tree/v7.3.0" }, "funding": [ { @@ -6733,24 +6842,25 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:31:26+00:00" + "time": "2025-04-20T20:19:01+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.2.3", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "82b478c69745d8878eb60f9a049a4d584996f73a" + "reference": "548f6760c54197b1084e1e5c71f6d9d523f2f78e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/82b478c69745d8878eb60f9a049a4d584996f73a", - "reference": "82b478c69745d8878eb60f9a049a4d584996f73a", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/548f6760c54197b1084e1e5c71f6d9d523f2f78e", + "reference": "548f6760c54197b1084e1e5c71f6d9d523f2f78e", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { @@ -6800,7 +6910,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.2.3" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.0" }, "funding": [ { @@ -6816,20 +6926,20 @@ "type": "tidelift" } ], - "time": "2025-01-17T11:39:41+00:00" + "time": "2025-04-27T18:39:23+00:00" }, { "name": "thecodingmachine/safe", - "version": "v3.1.0", + "version": "v3.3.0", "source": { "type": "git", "url": "https://github.com/thecodingmachine/safe.git", - "reference": "e14ac96126e6c19ea9d1f4029abb51487f4cf2cf" + "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/e14ac96126e6c19ea9d1f4029abb51487f4cf2cf", - "reference": "e14ac96126e6c19ea9d1f4029abb51487f4cf2cf", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236", "shasum": "" }, "require": { @@ -6939,7 +7049,7 @@ "description": "PHP core functions that throw exceptions instead of returning FALSE on error", "support": { "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v3.1.0" + "source": "https://github.com/thecodingmachine/safe/tree/v3.3.0" }, "funding": [ { @@ -6955,7 +7065,7 @@ "type": "github" } ], - "time": "2025-04-12T06:41:26+00:00" + "time": "2025-05-14T06:15:44+00:00" }, { "name": "theseer/tokenizer", @@ -7009,16 +7119,16 @@ }, { "name": "vimeo/psalm", - "version": "6.10.0", + "version": "6.12.0", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "9c0add4eb88d4b169ac04acb7c679918cbb9c252" + "reference": "cf420941d061a57050b6c468ef2c778faf40aee2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/9c0add4eb88d4b169ac04acb7c679918cbb9c252", - "reference": "9c0add4eb88d4b169ac04acb7c679918cbb9c252", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/cf420941d061a57050b6c468ef2c778faf40aee2", + "reference": "cf420941d061a57050b6c468ef2c778faf40aee2", "shasum": "" }, "require": { @@ -7123,7 +7233,7 @@ "issues": "https://github.com/vimeo/psalm/issues", "source": "https://github.com/vimeo/psalm" }, - "time": "2025-03-31T10:12:50+00:00" + "time": "2025-05-28T12:52:06+00:00" }, { "name": "webmozart/assert", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0cdb2bb..32f26cc 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -63,7 +63,7 @@ parameters: - message: '#^Parameter \#1 \$class of method Patchlevel\\Hydrator\\Metadata\\AttributeMetadataFactory\:\:findNormalizerOnClass\(\) expects class\-string, string given\.$#' identifier: argument.type - count: 2 + count: 3 path: src/Metadata/AttributeMetadataFactory.php - diff --git a/src/Metadata/AttributeMetadataFactory.php b/src/Metadata/AttributeMetadataFactory.php index 45948d7..6e0e7a2 100644 --- a/src/Metadata/AttributeMetadataFactory.php +++ b/src/Metadata/AttributeMetadataFactory.php @@ -13,6 +13,7 @@ use Patchlevel\Hydrator\Guesser\BuiltInGuesser; use Patchlevel\Hydrator\Guesser\Guesser; use Patchlevel\Hydrator\Normalizer\ArrayNormalizer; +use Patchlevel\Hydrator\Normalizer\ArrayShapeNormalizer; use Patchlevel\Hydrator\Normalizer\Normalizer; use Patchlevel\Hydrator\Normalizer\ReflectionTypeAwareNormalizer; use Patchlevel\Hydrator\Normalizer\TypeAwareNormalizer; @@ -21,6 +22,7 @@ use ReflectionException; use ReflectionProperty; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\ArrayShapeType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -384,6 +386,42 @@ private function inferNormalizerByType(Type $type): Normalizer|null return $this->guesser->guess($type); } + if ($type instanceof ArrayShapeType) { + $shape = $type->getShape(); + + $normalizers = []; + + foreach ($shape as $field => $fieldInfo) { + $valueType = $fieldInfo['type']; + + if ($valueType instanceof NullableType) { + $valueType = $valueType->getWrappedType(); + } + + $normalizer = null; + + if ($valueType instanceof ObjectType) { + $normalizer = $this->findNormalizerOnClass($valueType->getClassName()); + } + + if ($normalizer === null) { + $normalizer = $this->inferNormalizerByType($valueType); + } + + if ($normalizer === null) { + continue; + } + + $normalizers[$field] = $normalizer; + } + + if ($normalizers === []) { + return null; + } + + return new ArrayShapeNormalizer($normalizers); + } + if ($type instanceof CollectionType) { $valueType = $type->getCollectionValueType(); diff --git a/src/Normalizer/ArrayShapeNormalizer.php b/src/Normalizer/ArrayShapeNormalizer.php new file mode 100644 index 0000000..64c10eb --- /dev/null +++ b/src/Normalizer/ArrayShapeNormalizer.php @@ -0,0 +1,107 @@ + $normalizerMap */ + public function __construct( + private readonly array $normalizerMap, + ) { + } + + /** @return array|null */ + public function normalize(mixed $value): array|null + { + if ($value === null) { + return null; + } + + if (!is_array($value)) { + throw InvalidArgument::withWrongType('array|null', $value); + } + + $result = []; + + foreach ($this->normalizerMap as $field => $normalizer) { + if (!isset($value[$field])) { + continue; + } + + $result[$field] = $normalizer->normalize($value[$field]); + } + + return $result; + } + + /** @return array|null */ + public function denormalize(mixed $value): array|null + { + if ($value === null) { + return null; + } + + if (!is_array($value)) { + throw InvalidArgument::withWrongType('array|null', $value); + } + + $result = []; + + foreach ($this->normalizerMap as $field => $normalizer) { + if (!isset($value[$field])) { + continue; + } + + $result[$field] = $normalizer->denormalize($value[$field]); + } + + return $result; + } + + public function setHydrator(Hydrator $hydrator): void + { + foreach ($this->normalizerMap as $normalizer) { + if (!$normalizer instanceof HydratorAwareNormalizer) { + continue; + } + + $normalizer->setHydrator($hydrator); + } + } + + public function handleType(Type|null $type): void + { + if ($type === null) { + return; + } + + if ($type instanceof NullableType) { + $type = $type->getWrappedType(); + } + + if (!$type instanceof ArrayShapeType) { + return; + } + + $shape = $type->getShape(); + + foreach ($this->normalizerMap as $field => $normalizer) { + if (!$normalizer instanceof TypeAwareNormalizer) { + continue; + } + + $normalizer->handleType($shape[$field]['type']); + } + } +} diff --git a/tests/Unit/Fixture/InferNormalizerWithIterablesDto.php b/tests/Unit/Fixture/InferNormalizerWithIterablesDto.php index e6cb424..4a8310c 100644 --- a/tests/Unit/Fixture/InferNormalizerWithIterablesDto.php +++ b/tests/Unit/Fixture/InferNormalizerWithIterablesDto.php @@ -13,6 +13,7 @@ final class InferNormalizerWithIterablesDto * @param array $hashMap * @param array> $nested * @param array{foo: string, bar: int, baz: list}|null $jsonArray + * @param array{status: Status, other: iterable}|null $shapeArray */ public function __construct( public array $defaultArray = [], @@ -21,6 +22,7 @@ public function __construct( public array $hashMap = [], public iterable $nested = [], public array|null $jsonArray = null, + public array|null $shapeArray = null, ) { } } diff --git a/tests/Unit/MetadataHydratorTest.php b/tests/Unit/MetadataHydratorTest.php index df9800c..1980f6d 100644 --- a/tests/Unit/MetadataHydratorTest.php +++ b/tests/Unit/MetadataHydratorTest.php @@ -476,6 +476,10 @@ public function testHydrateWithInferNormalizerWitIterables(): void 'bar' => 15, 'baz' => ['test'], ], + [ + 'status' => Status::Draft, + 'other' => [Status::Draft], + ], ); $event = $this->hydrator->hydrate( @@ -487,6 +491,7 @@ public function testHydrateWithInferNormalizerWitIterables(): void 'hashMap' => ['foo' => 'draft', 'bar' => 'draft'], 'nested' => ['foo' => ['draft'], 'bar' => ['draft']], 'jsonArray' => ['foo' => 'php', 'bar' => 15, 'baz' => ['test']], + 'shapeArray' => ['status' => 'draft', 'other' => ['draft']], ], ); diff --git a/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php b/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php new file mode 100644 index 0000000..79dc159 --- /dev/null +++ b/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php @@ -0,0 +1,99 @@ +createMock(Normalizer::class); + + $normalizer = new ArrayShapeNormalizer(['foo' => $innerNormalizer]); + $this->assertEquals(null, $normalizer->normalize(null)); + } + + public function testDenormalizeWithNull(): void + { + $innerNormalizer = $this->createMock(Normalizer::class); + + $normalizer = new ArrayShapeNormalizer(['foo' => $innerNormalizer]); + $this->assertEquals(null, $normalizer->denormalize(null)); + } + + public function testNormalizeWithInvalidArgument(): void + { + $this->expectException(InvalidArgument::class); + + $innerNormalizer = $this->createMock(Normalizer::class); + + $normalizer = new ArrayShapeNormalizer(['foo' => $innerNormalizer]); + $normalizer->normalize('foo'); + } + + public function testDenormalizeWithInvalidArgument(): void + { + $this->expectException(InvalidArgument::class); + + $innerNormalizer = $this->createMock(Normalizer::class); + + $normalizer = new ArrayShapeNormalizer(['foo' => $innerNormalizer]); + $normalizer->denormalize('foo'); + } + + public function testNormalizeWithValue(): void + { + $innerNormalizer = new class implements Normalizer { + public function normalize(mixed $value): string + { + return (string)$value; + } + + public function denormalize(mixed $value): int + { + return (int)$value; + } + }; + + $normalizer = new ArrayShapeNormalizer(['foo' => $innerNormalizer]); + $this->assertEquals(['foo' => '1'], $normalizer->normalize(['foo' => 1])); + } + + public function testDenormalizeWithValue(): void + { + $innerNormalizer = new class implements Normalizer { + public function normalize(mixed $value): string + { + return (string)$value; + } + + public function denormalize(mixed $value): int + { + return (int)$value; + } + }; + + $normalizer = new ArrayShapeNormalizer(['foo' => $innerNormalizer]); + $this->assertEquals(['foo' => 1], $normalizer->denormalize(['foo' => '1'])); + } + + public function testPassHydrator(): void + { + $hydrator = $this->createMock(Hydrator::class); + $normalizer = $this->createMockForIntersectionOfInterfaces([Normalizer::class, HydratorAwareNormalizer::class]); + $normalizer->expects($this->once())->method('setHydrator')->with($hydrator); + + $normalizer = new ArrayShapeNormalizer(['foo' => $normalizer]); + $normalizer->setHydrator($hydrator); + } +} From 083b2ba518a395e40bc58fb23b305eb5d28c2f88 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Jun 2025 13:55:06 +0000 Subject: [PATCH 3/4] Update shivammathur/setup-php action to v2.34.0 | datasource | package | from | to | | ----------- | ---------------------- | ------ | ------ | | github-tags | shivammathur/setup-php | 2.33.0 | 2.34.0 | Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/backward-compatibility-check.yml | 2 +- .github/workflows/benchmark.yml | 2 +- .github/workflows/coding-standard.yml | 2 +- .github/workflows/mutation-tests-diff.yml | 2 +- .github/workflows/mutation-tests.yml | 2 +- .github/workflows/phpstan.yml | 2 +- .github/workflows/psalm.yml | 2 +- .github/workflows/unit.yml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/backward-compatibility-check.yml b/.github/workflows/backward-compatibility-check.yml index b964cbc..b9b37ec 100644 --- a/.github/workflows/backward-compatibility-check.yml +++ b/.github/workflows/backward-compatibility-check.yml @@ -27,7 +27,7 @@ jobs: fetch-depth: 0 - name: "Install PHP" - uses: "shivammathur/setup-php@2.33.0" + uses: "shivammathur/setup-php@2.34.0" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 4b578ad..8482083 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -22,7 +22,7 @@ jobs: steps: - name: "Install PHP" - uses: "shivammathur/setup-php@2.33.0" + uses: "shivammathur/setup-php@2.34.0" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/coding-standard.yml b/.github/workflows/coding-standard.yml index 3cd229a..849c002 100644 --- a/.github/workflows/coding-standard.yml +++ b/.github/workflows/coding-standard.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@v4 - name: "Install PHP" - uses: "shivammathur/setup-php@2.33.0" + uses: "shivammathur/setup-php@2.34.0" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/mutation-tests-diff.yml b/.github/workflows/mutation-tests-diff.yml index 2631879..50f6d76 100644 --- a/.github/workflows/mutation-tests-diff.yml +++ b/.github/workflows/mutation-tests-diff.yml @@ -27,7 +27,7 @@ jobs: fetch-depth: 0 - name: "Install PHP" - uses: "shivammathur/setup-php@2.33.0" + uses: "shivammathur/setup-php@2.34.0" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index 55ea546..e7a4ccc 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@v4 - name: "Install PHP" - uses: "shivammathur/setup-php@2.33.0" + uses: "shivammathur/setup-php@2.34.0" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index ac30151..fd9e68d 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@v4 - name: "Install PHP" - uses: "shivammathur/setup-php@2.33.0" + uses: "shivammathur/setup-php@2.34.0" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index 1183036..abfd6d3 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@v4 - name: "Install PHP" - uses: "shivammathur/setup-php@2.33.0" + uses: "shivammathur/setup-php@2.34.0" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 4603e74..f85b281 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -38,7 +38,7 @@ jobs: uses: actions/checkout@v4 - name: "Install PHP" - uses: "shivammathur/setup-php@2.33.0" + uses: "shivammathur/setup-php@2.34.0" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" From 0145bae210b48ea95824d34be19e75c7c5433d7e Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 6 Jun 2025 16:24:14 +0200 Subject: [PATCH 4/4] add lazy with attribute --- README.md | 27 ++++ src/Attribute/Lazy.php | 16 +++ src/Metadata/AttributeMetadataFactory.php | 15 ++ src/Metadata/ClassMetadata.php | 9 ++ src/MetadataHydrator.php | 37 ++++- tests/Benchmark/HydratorBench.php | 8 +- .../HydratorWithCryptographyBench.php | 8 +- tests/Benchmark/HydratorWithLazyBench.php | 130 ++++++++++++++++++ tests/Unit/Fixture/LazyProfileCreated.php | 19 +++ .../Metadata/AttributeMetadataFactoryTest.php | 24 ++++ tests/Unit/MetadataHydratorTest.php | 56 ++++++++ 11 files changed, 339 insertions(+), 10 deletions(-) create mode 100644 src/Attribute/Lazy.php create mode 100644 tests/Benchmark/HydratorWithLazyBench.php create mode 100644 tests/Unit/Fixture/LazyProfileCreated.php diff --git a/README.md b/README.md index 4d83fa5..a8e7de7 100644 --- a/README.md +++ b/README.md @@ -442,6 +442,29 @@ readonly class ProfileCreated } ``` +### Lazy + +Since PHP 8.4, it's been possible to lazy-hydrate objects. +That is, the actual hydration process occurs when the object is accessed. +You can define for each class whether you want it to be lazy by using the `Lazy` attribute. + +```php +use Patchlevel\Hydrator\Attribute\Lazy; + +#[Lazy] +readonly class ProfileCreated +{ + public function __construct( + public string $id, + public string $name, + ) { + } +} +``` + +> [!NOTE] +> If you are using a PHP version older than 8.4, the attribute will be ignored. + ### Hooks Sometimes you need to do something before extract or after hydrate process. @@ -592,6 +615,10 @@ final class ProfileCreated } ``` +> [!TIP] +> Cryptography is very expensive in terms of performance, +> you can combine it with lazy to improve performance and only decrypt when you actually access the object. + #### Configure Cryptography Here we show you how to configure the cryptography. diff --git a/src/Attribute/Lazy.php b/src/Attribute/Lazy.php new file mode 100644 index 0000000..1407229 --- /dev/null +++ b/src/Attribute/Lazy.php @@ -0,0 +1,16 @@ +getSubjectIdField($reflectionClass), $this->getPostHydrateCallbacks($reflectionClass), $this->getPreExtractCallbacks($reflectionClass), + $this->getLazy($reflectionClass), ); $parentMetadataClass = $reflectionClass->getParentClass(); @@ -212,6 +214,18 @@ private function getPreExtractCallbacks(ReflectionClass $reflection): array return $methods; } + /** @param ReflectionClass $reflection */ + private function getLazy(ReflectionClass $reflection): bool|null + { + $attributeReflectionList = $reflection->getAttributes(Lazy::class); + + if ($attributeReflectionList === []) { + return null; + } + + return $attributeReflectionList[0]->newInstance()->enabled; + } + private function getFieldName(ReflectionProperty $reflectionProperty): string { $attributeReflectionList = $reflectionProperty->getAttributes(NormalizedName::class); @@ -271,6 +285,7 @@ private function mergeMetadata(ClassMetadata $parent, ClassMetadata $child): Cla $parentDataSubjectIdField ?? $childDataSubjectIdField, array_merge($parent->postHydrateCallbacks(), $child->postHydrateCallbacks()), array_merge($parent->preExtractCallbacks(), $child->preExtractCallbacks()), + $child->lazy() ?? $parent->lazy(), ); } diff --git a/src/Metadata/ClassMetadata.php b/src/Metadata/ClassMetadata.php index 89ba44c..c3ead0a 100644 --- a/src/Metadata/ClassMetadata.php +++ b/src/Metadata/ClassMetadata.php @@ -13,6 +13,7 @@ * dataSubjectIdField: string|null, * postHydrateCallbacks: list, * preExtractCallbacks: list, + * lazy: bool|null, * } * @template T of object = object */ @@ -30,6 +31,7 @@ public function __construct( private readonly string|null $dataSubjectIdField = null, private readonly array $postHydrateCallbacks = [], private readonly array $preExtractCallbacks = [], + private readonly bool|null $lazy = null, ) { } @@ -63,6 +65,11 @@ public function preExtractCallbacks(): array return $this->preExtractCallbacks; } + public function lazy(): bool|null + { + return $this->lazy; + } + public function dataSubjectIdField(): string|null { return $this->dataSubjectIdField; @@ -94,6 +101,7 @@ public function __serialize(): array 'dataSubjectIdField' => $this->dataSubjectIdField, 'postHydrateCallbacks' => $this->postHydrateCallbacks, 'preExtractCallbacks' => $this->preExtractCallbacks, + 'lazy' => $this->lazy, ]; } @@ -105,5 +113,6 @@ public function __unserialize(array $data): void $this->dataSubjectIdField = $data['dataSubjectIdField']; $this->postHydrateCallbacks = $data['postHydrateCallbacks']; $this->preExtractCallbacks = $data['preExtractCallbacks']; + $this->lazy = $data['lazy']; } } diff --git a/src/MetadataHydrator.php b/src/MetadataHydrator.php index 2e00b28..e1a37a7 100644 --- a/src/MetadataHydrator.php +++ b/src/MetadataHydrator.php @@ -16,6 +16,7 @@ use Patchlevel\Hydrator\Metadata\ClassNotFound; use Patchlevel\Hydrator\Metadata\MetadataFactory; use Patchlevel\Hydrator\Normalizer\HydratorAwareNormalizer; +use ReflectionClass; use ReflectionParameter; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; @@ -27,6 +28,8 @@ use function is_object; use function spl_object_id; +use const PHP_VERSION_ID; + final class MetadataHydrator implements Hydrator { /** @var array */ @@ -36,6 +39,7 @@ public function __construct( private readonly MetadataFactory $metadataFactory = new AttributeMetadataFactory(), PayloadCryptographer|null $cryptographer = null, private EventDispatcherInterface|null $eventDispatcher = null, + private readonly bool $defaultLazy = false, ) { if (!$cryptographer) { return; @@ -66,6 +70,33 @@ public function hydrate(string $class, array $data): object throw new ClassNotSupported($class, $e); } + if (PHP_VERSION_ID < 80400) { + return $this->doHydrate($metadata, $data); + } + + $lazy = $metadata->lazy() ?? $this->defaultLazy; + + if (!$lazy) { + return $this->doHydrate($metadata, $data); + } + + return (new ReflectionClass($class))->newLazyProxy( + function () use ($metadata, $data): object { + return $this->doHydrate($metadata, $data); + }, + ); + } + + /** + * @param ClassMetadata $metadata + * @param array $data + * + * @return T + * + * @template T of object + */ + private function doHydrate(ClassMetadata $metadata, array $data): object + { if ($this->eventDispatcher) { $data = $this->eventDispatcher->dispatch(new PreHydrate($data, $metadata))->data; } @@ -110,7 +141,7 @@ public function hydrate(string $class, array $data): object $value = $normalizer->denormalize($value); } catch (Throwable $e) { throw new DenormalizationFailure( - $class, + $metadata->className(), $propertyMetadata->propertyName(), $normalizer::class, $e, @@ -122,7 +153,7 @@ public function hydrate(string $class, array $data): object $propertyMetadata->setValue($object, $value); } catch (TypeError $e) { throw new TypeMismatch( - $class, + $metadata->className(), $propertyMetadata->propertyName(), $e, ); @@ -234,6 +265,7 @@ private function promotedConstructorParametersWithDefaultValue(ClassMetadata $me public static function create( iterable $guessers = [], EventDispatcherInterface|null $eventDispatcher = null, + bool $defaultLazy = false, ): self { $guesser = new BuiltInGuesser(); @@ -250,6 +282,7 @@ public static function create( ), null, $eventDispatcher, + $defaultLazy, ); } } diff --git a/tests/Benchmark/HydratorBench.php b/tests/Benchmark/HydratorBench.php index 7c0c35b..529b980 100644 --- a/tests/Benchmark/HydratorBench.php +++ b/tests/Benchmark/HydratorBench.php @@ -66,7 +66,7 @@ public function benchExtract1Object(): void $this->hydrator->extract($object); } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchHydrate1000Objects(): void { for ($i = 0; $i < 1_000; $i++) { @@ -81,7 +81,7 @@ public function benchHydrate1000Objects(): void } } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchExtract1000Objects(): void { $object = new ProfileCreated( @@ -98,7 +98,7 @@ public function benchExtract1000Objects(): void } } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchHydrate1000000Objects(): void { for ($i = 0; $i < 1_000_000; $i++) { @@ -113,7 +113,7 @@ public function benchHydrate1000000Objects(): void } } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchExtract1000000Objects(): void { $object = new ProfileCreated( diff --git a/tests/Benchmark/HydratorWithCryptographyBench.php b/tests/Benchmark/HydratorWithCryptographyBench.php index 5b8ebb2..da3ab2b 100644 --- a/tests/Benchmark/HydratorWithCryptographyBench.php +++ b/tests/Benchmark/HydratorWithCryptographyBench.php @@ -84,7 +84,7 @@ public function benchExtract1Object(): void $this->hydrator->extract($object); } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchHydrate1000Objects(): void { for ($i = 0; $i < 1_000; $i++) { @@ -102,7 +102,7 @@ public function benchHydrate1000Objects(): void } } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchExtract1000Objects(): void { $object = new ProfileCreated( @@ -119,7 +119,7 @@ public function benchExtract1000Objects(): void } } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchHydrate1000000Objects(): void { for ($i = 0; $i < 1_000_000; $i++) { @@ -137,7 +137,7 @@ public function benchHydrate1000000Objects(): void } } - #[Bench\Revs(5)] + #[Bench\Revs(3)] public function benchExtract1000000Objects(): void { $object = new ProfileCreated( diff --git a/tests/Benchmark/HydratorWithLazyBench.php b/tests/Benchmark/HydratorWithLazyBench.php new file mode 100644 index 0000000..fe9c98d --- /dev/null +++ b/tests/Benchmark/HydratorWithLazyBench.php @@ -0,0 +1,130 @@ +hydrator = MetadataHydrator::create(defaultLazy: true); + } + + public function setUp(): void + { + $object = $this->hydrator->hydrate( + ProfileCreated::class, + [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ], + ); + + $this->hydrator->extract($object); + } + + #[Bench\Revs(5)] + public function benchHydrate1Object(): void + { + $this->hydrator->hydrate(ProfileCreated::class, [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ]); + } + + #[Bench\Revs(5)] + public function benchHydrate1ObjectTriggerInit(): void + { + $object = $this->hydrator->hydrate(ProfileCreated::class, [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ]); + + $name = $object->name; + } + + #[Bench\Revs(3)] + public function benchHydrate1000Objects(): void + { + for ($i = 0; $i < 1_000; $i++) { + $this->hydrator->hydrate(ProfileCreated::class, [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ]); + } + } + + #[Bench\Revs(3)] + public function benchHydrate1000ObjectsTriggerInit(): void + { + for ($i = 0; $i < 1_000; $i++) { + $object = $this->hydrator->hydrate(ProfileCreated::class, [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ]); + + $name = $object->name; + } + } + + #[Bench\Revs(3)] + public function benchHydrate1000000Objects(): void + { + for ($i = 0; $i < 1_000_000; $i++) { + $this->hydrator->hydrate(ProfileCreated::class, [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ]); + } + } + + #[Bench\Revs(3)] + public function benchHydrate1000000ObjectsTriggerInit(): void + { + for ($i = 0; $i < 1_000_000; $i++) { + $object = $this->hydrator->hydrate(ProfileCreated::class, [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ]); + + $object = $object->name; + } + } +} diff --git a/tests/Unit/Fixture/LazyProfileCreated.php b/tests/Unit/Fixture/LazyProfileCreated.php new file mode 100644 index 0000000..18a148d --- /dev/null +++ b/tests/Unit/Fixture/LazyProfileCreated.php @@ -0,0 +1,19 @@ +preExtractCallbacks()); self::assertCount(0, $metadata->postHydrateCallbacks()); } + + public function testNoLazy(): void + { + $object = new class { + }; + + $metadataFactory = new AttributeMetadataFactory(); + $metadata = $metadataFactory->metadata($object::class); + + self::assertNull($metadata->lazy()); + } + + public function testLazy(): void + { + $object = new #[Lazy] + class { + }; + + $metadataFactory = new AttributeMetadataFactory(); + $metadata = $metadataFactory->metadata($object::class); + + self::assertTrue($metadata->lazy()); + } } diff --git a/tests/Unit/MetadataHydratorTest.php b/tests/Unit/MetadataHydratorTest.php index df9800c..239c98e 100644 --- a/tests/Unit/MetadataHydratorTest.php +++ b/tests/Unit/MetadataHydratorTest.php @@ -29,6 +29,7 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithIterablesDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerWithNullableDto; +use Patchlevel\Hydrator\Tests\Unit\Fixture\LazyProfileCreated; use Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; @@ -40,7 +41,9 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\StatusWithNormalizer; use Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer; use Patchlevel\Hydrator\TypeMismatch; +use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\TestCase; +use ReflectionClass; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -512,6 +515,59 @@ public function testHydrateWithHooks(): void self::assertEquals(false, $object->preExtractCalled); } + #[RequiresPhp('>=8.4')] + public function testLazyHydrate(): void + { + $event = $this->hydrator->hydrate( + LazyProfileCreated::class, + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ); + + $expected = new LazyProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + self::assertInstanceOf(LazyProfileCreated::class, $event); + + $reflection = new ReflectionClass(LazyProfileCreated::class); + + self::assertTrue($reflection->isUninitializedLazyObject($event)); + + $reflection->initializeLazyObject($event); + + self::assertEquals($expected, $event); + } + + #[RequiresPhp('<8.4')] + public function testLazyNotSupported(): void + { + $event = $this->hydrator->hydrate( + LazyProfileCreated::class, + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ); + + $expected = new LazyProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + self::assertEquals($expected, $event); + } + + #[RequiresPhp('>=8.4')] + public function testLazyExtract(): void + { + $event = $this->hydrator->hydrate( + LazyProfileCreated::class, + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ); + + $data = $this->hydrator->extract($event); + + self::assertEquals(['profileId' => '1', 'email' => 'info@patchlevel.de'], $data); + } + public function testCreate(): void { $eventDispatcher = $this->createMock(EventDispatcherInterface::class);