diff --git a/composer.lock b/composer.lock index a79212fb..bf3fe207 100644 --- a/composer.lock +++ b/composer.lock @@ -372,16 +372,16 @@ }, { "name": "symfony/type-info", - "version": "v8.0.4", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "106a2d3bbf0d4576b2f70e6ca866fa420956ed0d" + "reference": "3c7de103dd6cb68be24e155838a64ef4a70ae195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/106a2d3bbf0d4576b2f70e6ca866fa420956ed0d", - "reference": "106a2d3bbf0d4576b2f70e6ca866fa420956ed0d", + "url": "https://api.github.com/repos/symfony/type-info/zipball/3c7de103dd6cb68be24e155838a64ef4a70ae195", + "reference": "3c7de103dd6cb68be24e155838a64ef4a70ae195", "shasum": "" }, "require": { @@ -430,7 +430,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v8.0.4" + "source": "https://github.com/symfony/type-info/tree/v8.0.7" }, "funding": [ { @@ -450,7 +450,7 @@ "type": "tidelift" } ], - "time": "2026-01-09T12:15:10+00:00" + "time": "2026-03-04T13:55:34+00:00" } ], "packages-dev": [ @@ -1177,16 +1177,16 @@ }, { "name": "infection/infection", - "version": "0.32.4", + "version": "0.32.6", "source": { "type": "git", "url": "https://github.com/infection/infection.git", - "reference": "a2b0a3e47b56bd2f27ca13caecae47baa7e5abe8" + "reference": "4ed769947eaf2ecf42203027301bad2bedf037e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/infection/zipball/a2b0a3e47b56bd2f27ca13caecae47baa7e5abe8", - "reference": "a2b0a3e47b56bd2f27ca13caecae47baa7e5abe8", + "url": "https://api.github.com/repos/infection/infection/zipball/4ed769947eaf2ecf42203027301bad2bedf037e5", + "reference": "4ed769947eaf2ecf42203027301bad2bedf037e5", "shasum": "" }, "require": { @@ -1297,7 +1297,7 @@ ], "support": { "issues": "https://github.com/infection/infection/issues", - "source": "https://github.com/infection/infection/tree/0.32.4" + "source": "https://github.com/infection/infection/tree/0.32.6" }, "funding": [ { @@ -1309,7 +1309,7 @@ "type": "open_collective" } ], - "time": "2026-02-09T13:24:18+00:00" + "time": "2026-02-26T14:34:26+00:00" }, { "name": "infection/mutator", @@ -1883,16 +1883,16 @@ }, { "name": "phpat/phpat", - "version": "0.12.2", + "version": "0.12.3", "source": { "type": "git", "url": "https://github.com/carlosas/phpat.git", - "reference": "fe9caef4f8633a57c1d19643d37b58050b11806c" + "reference": "2412a8959254a076e751498cbba8cf29406e0cf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/carlosas/phpat/zipball/fe9caef4f8633a57c1d19643d37b58050b11806c", - "reference": "fe9caef4f8633a57c1d19643d37b58050b11806c", + "url": "https://api.github.com/repos/carlosas/phpat/zipball/2412a8959254a076e751498cbba8cf29406e0cf4", + "reference": "2412a8959254a076e751498cbba8cf29406e0cf4", "shasum": "" }, "require": { @@ -1934,9 +1934,9 @@ "description": "PHP Architecture Tester", "support": { "issues": "https://github.com/carlosas/phpat/issues", - "source": "https://github.com/carlosas/phpat/tree/0.12.2" + "source": "https://github.com/carlosas/phpat/tree/0.12.3" }, - "time": "2026-01-27T11:41:37+00:00" + "time": "2026-02-20T11:15:22+00:00" }, { "name": "phpbench/container", @@ -1991,16 +1991,16 @@ }, { "name": "phpbench/phpbench", - "version": "1.4.3", + "version": "1.5.1", "source": { "type": "git", "url": "https://github.com/phpbench/phpbench.git", - "reference": "b641dde59d969ea42eed70a39f9b51950bc96878" + "reference": "9a28fd0833f11171b949843c6fd663eb69b6d14c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpbench/phpbench/zipball/b641dde59d969ea42eed70a39f9b51950bc96878", - "reference": "b641dde59d969ea42eed70a39f9b51950bc96878", + "url": "https://api.github.com/repos/phpbench/phpbench/zipball/9a28fd0833f11171b949843c6fd663eb69b6d14c", + "reference": "9a28fd0833f11171b949843c6fd663eb69b6d14c", "shasum": "" }, "require": { @@ -2011,7 +2011,7 @@ "ext-reflection": "*", "ext-spl": "*", "ext-tokenizer": "*", - "php": "^8.1", + "php": "^8.2", "phpbench/container": "^2.2", "psr/log": "^1.1 || ^2.0 || ^3.0", "seld/jsonlint": "^1.1", @@ -2031,8 +2031,9 @@ "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "^1.0", "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^10.4 || ^11.0", + "phpunit/phpunit": "^11.5", "rector/rector": "^1.2", + "sebastian/exporter": "^6.3.2", "symfony/error-handler": "^6.1 || ^7.0 || ^8.0", "symfony/var-dumper": "^6.1 || ^7.0 || ^8.0" }, @@ -2077,7 +2078,7 @@ ], "support": { "issues": "https://github.com/phpbench/phpbench/issues", - "source": "https://github.com/phpbench/phpbench/tree/1.4.3" + "source": "https://github.com/phpbench/phpbench/tree/1.5.1" }, "funding": [ { @@ -2085,7 +2086,7 @@ "type": "github" } ], - "time": "2025-11-06T19:07:31+00:00" + "time": "2026-03-05T08:18:58+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -2136,11 +2137,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.39", + "version": "2.1.40", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", - "reference": "c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", "shasum": "" }, "require": { @@ -2185,7 +2186,7 @@ "type": "github" } ], - "time": "2026-02-11T14:48:56+00:00" + "time": "2026-02-23T15:04:35+00:00" }, { "name": "phpstan/phpstan-phpunit", @@ -2592,16 +2593,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.53", + "version": "11.5.55", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607" + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a997a653a82845f1240d73ee73a8a4e97e4b0607", - "reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", "shasum": "" }, "require": { @@ -2674,7 +2675,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.53" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" }, "funding": [ { @@ -2698,7 +2699,7 @@ "type": "tidelift" } ], - "time": "2026-02-10T12:28:25+00:00" + "time": "2026-02-18T12:37:06+00:00" }, { "name": "psr/clock", @@ -4320,16 +4321,16 @@ }, { "name": "symfony/console", - "version": "v8.0.4", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b" + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ace03c4cf9805080ff40cbeec69fca180c339a3b", - "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b", + "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", "shasum": "" }, "require": { @@ -4386,7 +4387,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.4" + "source": "https://github.com/symfony/console/tree/v8.0.7" }, "funding": [ { @@ -4406,7 +4407,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-03-06T14:06:22+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4477,16 +4478,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.1", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d" + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", "shasum": "" }, "require": { @@ -4523,7 +4524,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.1" + "source": "https://github.com/symfony/filesystem/tree/v8.0.6" }, "funding": [ { @@ -4543,20 +4544,20 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/finder", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0" + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8bd576e97c67d45941365bf824e18dc8538e6eb0", - "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0", + "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", "shasum": "" }, "require": { @@ -4591,7 +4592,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.5" + "source": "https://github.com/symfony/finder/tree/v8.0.6" }, "funding": [ { @@ -4611,7 +4612,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:08:38+00:00" + "time": "2026-01-29T09:41:02+00:00" }, { "name": "symfony/options-resolver", @@ -5253,16 +5254,16 @@ }, { "name": "symfony/string", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "758b372d6882506821ed666032e43020c4f57194" + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", - "reference": "758b372d6882506821ed666032e43020c4f57194", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", "shasum": "" }, "require": { @@ -5319,7 +5320,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.4" + "source": "https://github.com/symfony/string/tree/v8.0.6" }, "funding": [ { @@ -5339,20 +5340,20 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "symfony/var-dumper", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "326e0406fc315eca57ef5740fa4a280b7a068c82" + "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/326e0406fc315eca57ef5740fa4a280b7a068c82", - "reference": "326e0406fc315eca57ef5740fa4a280b7a068c82", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", + "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", "shasum": "" }, "require": { @@ -5406,7 +5407,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.0.4" + "source": "https://github.com/symfony/var-dumper/tree/v8.0.6" }, "funding": [ { @@ -5426,7 +5427,7 @@ "type": "tidelift" } ], - "time": "2026-01-01T23:07:29+00:00" + "time": "2026-02-15T10:53:29+00:00" }, { "name": "thecodingmachine/safe", @@ -5623,16 +5624,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.3", + "version": "2.1.6", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "6976757ba8dd70bf8cbaea0914ad84d8b51a9f46" + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/6976757ba8dd70bf8cbaea0914ad84d8b51a9f46", - "reference": "6976757ba8dd70bf8cbaea0914ad84d8b51a9f46", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", "shasum": "" }, "require": { @@ -5679,9 +5680,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.3" + "source": "https://github.com/webmozarts/assert/tree/2.1.6" }, - "time": "2026-02-13T21:01:40+00:00" + "time": "2026-02-27T10:28:38+00:00" }, { "name": "webmozart/glob", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e7ecebe2..73ed976a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -18,6 +18,54 @@ parameters: count: 1 path: src/Cryptography/PersonalDataPayloadCryptographer.php + - + message: '#^Offset ''k'' on array\{v\: 1, a\: non\-empty\-string, k\: non\-empty\-string, n\?\: non\-empty\-string, d\: non\-empty\-string, t\?\: non\-empty\-string\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: src/Extension/Cryptography/BaseCryptographer.php + + - + message: '#^Parameter \#1 \$subjectId of callable Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKeyFactory expects non\-empty\-string, string given\.$#' + identifier: argument.type + count: 1 + path: src/Extension/Cryptography/BaseCryptographer.php + + - + message: '#^Strict comparison using \=\=\= between non\-empty\-string and null will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: src/Extension/Cryptography/BaseCryptographer.php + + - + message: '#^Parameter \#1 \$data of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\EncryptedData constructor expects non\-empty\-string, string given\.$#' + identifier: argument.type + count: 1 + path: src/Extension/Cryptography/Cipher/OpensslCipher.php + + - + message: '#^Parameter \#1 \$data of function openssl_decrypt expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: src/Extension/Cryptography/Cipher/OpensslCipher.php + + - + message: '#^Parameter \#3 \$nonce of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\EncryptedData constructor expects non\-empty\-string\|null, string\|null given\.$#' + identifier: argument.type + count: 1 + path: src/Extension/Cryptography/Cipher/OpensslCipher.php + + - + message: '#^Parameter \#1 \$id of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' + identifier: argument.type + count: 1 + path: src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php + + - + message: '#^Parameter \#3 \$key of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' + identifier: argument.type + count: 1 + path: src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php + - message: '#^Method Patchlevel\\Hydrator\\Guesser\\BuiltInGuesser\:\:guess\(\) has parameter \$type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types\: T$#' identifier: missingType.generics @@ -67,31 +115,31 @@ parameters: path: src/Metadata/AttributeMetadataFactory.php - - message: '#^Property Patchlevel\\Hydrator\\Metadata\\ClassMetadata\\:\:\$reflection \(ReflectionClass\\) does not accept ReflectionClass\\.$#' + message: '#^Property Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer\:\:\$enum \(class\-string\\|null\) does not accept string\.$#' identifier: assign.propertyType count: 1 - path: src/Metadata/ClassMetadata.php + path: src/Normalizer/EnumNormalizer.php - - message: '#^Dead catch \- Patchlevel\\Hydrator\\CircularReference is never thrown in the try block\.$#' - identifier: catch.neverThrown + message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Hydrator\:\:hydrate\(\) expects array\, array given\.$#' + identifier: argument.type count: 1 - path: src/MetadataHydrator.php + path: src/Normalizer/ObjectMapNormalizer.php - - message: '#^Property Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer\:\:\$enum \(class\-string\\|null\) does not accept string\.$#' - identifier: assign.propertyType + message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\HydratorWithContext\:\:hydrate\(\) expects array\, array given\.$#' + identifier: argument.type count: 1 - path: src/Normalizer/EnumNormalizer.php + path: src/Normalizer/ObjectMapNormalizer.php - - message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Hydrator\:\:hydrate\(\) expects array\, array given\.$#' + message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Hydrator\:\:hydrate\(\) expects array\, array\ given\.$#' identifier: argument.type count: 1 - path: src/Normalizer/ObjectMapNormalizer.php + path: src/Normalizer/ObjectNormalizer.php - - message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Hydrator\:\:hydrate\(\) expects array\, array\ given\.$#' + message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\HydratorWithContext\:\:hydrate\(\) expects array\, array\ given\.$#' identifier: argument.type count: 1 path: src/Normalizer/ObjectNormalizer.php @@ -108,6 +156,12 @@ parameters: count: 1 path: src/Normalizer/ReflectionTypeUtil.php + - + message: '#^Property Patchlevel\\Hydrator\\Tests\\Unit\\Extension\\Cryptography\\Fixture\\ChildWithSensitiveDataWithIdentifierDto\:\:\$email is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: tests/Unit/Extension/Cryptography/Fixture/ChildWithSensitiveDataWithIdentifierDto.php + - message: '#^Method Patchlevel\\Hydrator\\Tests\\Unit\\Fixture\\DtoWithHooks\:\:postHydrate\(\) is unused\.$#' identifier: method.unused @@ -179,3 +233,9 @@ parameters: identifier: cast.string count: 2 path: tests/Unit/Normalizer/ArrayShapeNormalizerTest.php + + - + message: '#^Parameter \#1 \$class of method Patchlevel\\Hydrator\\StackHydrator\:\:hydrate\(\) expects class\-string\, string given\.$#' + identifier: argument.type + count: 1 + path: tests/Unit/StackHydratorTest.php diff --git a/src/CoreExtension.php b/src/CoreExtension.php new file mode 100644 index 00000000..9ff16064 --- /dev/null +++ b/src/CoreExtension.php @@ -0,0 +1,18 @@ +addMiddleware(new TransformMiddleware(), -64); + $builder->addGuesser(new BuiltInGuesser(), -64); + } +} diff --git a/src/Extension.php b/src/Extension.php new file mode 100644 index 00000000..3b0e83f1 --- /dev/null +++ b/src/Extension.php @@ -0,0 +1,11 @@ +fallbackCallable = $fallbackCallable; + + if ($this->fallbackCallable !== null && $this->fallback !== null) { + throw new InvalidArgumentException('You can only set one of fallback or fallbackCallable'); + } + } +} diff --git a/src/Extension/Cryptography/BaseCryptographer.php b/src/Extension/Cryptography/BaseCryptographer.php new file mode 100644 index 00000000..51dd0b59 --- /dev/null +++ b/src/Extension/Cryptography/BaseCryptographer.php @@ -0,0 +1,125 @@ +cipherKeyStore->currentKeyFor($subjectId); + } catch (CipherKeyNotExists) { + $cipherKey = ($this->cipherKeyFactory)($subjectId); + $this->cipherKeyStore->store($cipherKey->id, $cipherKey); + } + + $parameter = $this->cipher->encrypt($cipherKey, $value); + + $result = [ + self::VERSION_KEY => 1, + self::METHOD_KEY => $parameter->method, + self::KEY_ID_KEY => $cipherKey->id, + self::DATA_KEY => $parameter->data, + ]; + + if ($parameter->nonce !== null) { + $result[self::NONCE_KEY] = $parameter->nonce; + } + + if ($parameter->tag !== null) { + $result[self::TAG_KEY] = $parameter->tag; + } + + return $result; + } + + /** + * @param EncryptedDataArray $encryptedData + * + * @throws CipherKeyNotExists + * @throws DecryptionFailed + */ + public function decrypt(string $subjectId, mixed $encryptedData): mixed + { + $keyId = $encryptedData[self::KEY_ID_KEY] ?? null; + + if ($keyId === null) { + throw DecryptionFailed::missingKeyId(); + } + + $cipherKey = $this->cipherKeyStore->get($keyId); + + return $this->cipher->decrypt( + $cipherKey, + new EncryptedData( + $encryptedData[self::DATA_KEY], + $encryptedData[self::METHOD_KEY], + $encryptedData[self::NONCE_KEY] ?? null, + $encryptedData[self::TAG_KEY] ?? null, + ), + ); + } + + public function supports(mixed $value): bool + { + return is_array($value) + && isset($value[self::VERSION_KEY], $value[self::METHOD_KEY], $value[self::KEY_ID_KEY], $value[self::DATA_KEY]) + && $value[self::VERSION_KEY] === 1; + } + + /** @param non-empty-string $method */ + public static function createWithOpenssl( + CipherKeyStore $cryptoStore, + string $method = OpensslCipherKeyFactory::DEFAULT_METHOD, + ): static { + return new self( + new OpensslCipher(), + $cryptoStore, + new OpensslCipherKeyFactory($method), + ); + } +} diff --git a/src/Extension/Cryptography/Cipher/Cipher.php b/src/Extension/Cryptography/Cipher/Cipher.php new file mode 100644 index 00000000..184829e9 --- /dev/null +++ b/src/Extension/Cryptography/Cipher/Cipher.php @@ -0,0 +1,15 @@ +method); + + if ($ivLength === false) { + throw EncryptionFailed::invalidIvLength($key->method); + } + + $nonce = $ivLength > 0 ? openssl_random_pseudo_bytes($ivLength) : null; + $tag = null; + + $encryptedData = @openssl_encrypt( + json_encode($data, JSON_THROW_ON_ERROR), + $key->method, + $key->key, + 0, + $nonce ?? '', + $tag, + ); + + if ($encryptedData === false) { + throw EncryptionFailed::forMethod($key->method); + } + + return new EncryptedData( + base64_encode($encryptedData), + $key->method, + $nonce !== null ? base64_encode($nonce) : null, + $tag !== null ? base64_encode($tag) : null, + ); + } + + public function decrypt(CipherKey $key, EncryptedData $parameter): mixed + { + $tag = $parameter->tag !== null ? base64_decode($parameter->tag, true) : null; + + if ($parameter->tag !== null && $tag === false) { + throw DecryptionFailed::invalidBase64('tag'); + } + + $nonce = $parameter->nonce !== null ? base64_decode($parameter->nonce, true) : null; + + if ($parameter->nonce !== null && $nonce === false) { + throw DecryptionFailed::invalidBase64('nonce'); + } + + $data = @openssl_decrypt( + base64_decode($parameter->data, true), + $parameter->method, + $key->key, + 0, + $nonce ?: '', + $tag ?: '', + ); + + if ($data === false) { + throw DecryptionFailed::forMethod($parameter->method); + } + + try { + return json_decode($data, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw DecryptionFailed::invalidJson($e); + } + } +} diff --git a/src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php b/src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php new file mode 100644 index 00000000..1a7442d6 --- /dev/null +++ b/src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php @@ -0,0 +1,65 @@ +method)) { + throw new MethodNotSupported($this->method); + } + + $keyLength = 16; + + if (function_exists('openssl_cipher_key_length')) { + $keyLength = @openssl_cipher_key_length($this->method); + } + + if ($keyLength === false) { + throw new MethodNotSupported($this->method); + } + + $this->keyLength = $keyLength; + } + + public function __invoke(string $subjectId): CipherKey + { + return new CipherKey( + bin2hex(openssl_random_pseudo_bytes(16)), + $subjectId, + bin2hex(openssl_random_pseudo_bytes($this->keyLength)), + $this->method, + new DateTimeImmutable(), + ); + } + + /** @return list */ + public static function supportedMethods(): array + { + return openssl_get_cipher_methods(true); + } + + public static function methodSupported(string $method): bool + { + return in_array($method, self::supportedMethods(), true); + } +} diff --git a/src/Extension/Cryptography/Cryptographer.php b/src/Extension/Cryptography/Cryptographer.php new file mode 100644 index 00000000..054dc4a6 --- /dev/null +++ b/src/Extension/Cryptography/Cryptographer.php @@ -0,0 +1,24 @@ +addMetadataEnricher(new CryptographyMetadataEnricher(), 64); + $builder->addMiddleware(new CryptographyMiddleware($this->cryptography), 64); + } +} diff --git a/src/Extension/Cryptography/CryptographyMetadataEnricher.php b/src/Extension/Cryptography/CryptographyMetadataEnricher.php new file mode 100644 index 00000000..794ccd0f --- /dev/null +++ b/src/Extension/Cryptography/CryptographyMetadataEnricher.php @@ -0,0 +1,78 @@ +properties as $property) { + $isSubjectId = false; + $attributeReflectionList = $property->reflection->getAttributes(DataSubjectId::class); + + if ($attributeReflectionList) { + $subjectIdIdentifier = $attributeReflectionList[0]->newInstance()->name; + + if (array_key_exists($subjectIdIdentifier, $subjectIdMapping)) { + throw new DuplicateSubjectIdIdentifier( + $classMetadata->className, + $classMetadata->propertyForField($subjectIdMapping[$subjectIdIdentifier])->propertyName, + $property->propertyName, + $subjectIdIdentifier, + ); + } + + $subjectIdMapping[$subjectIdIdentifier] = $property->fieldName; + + $isSubjectId = true; + } + + $sensitiveDataInfo = $this->sensitiveDataInfo($property->reflection); + + if (!$sensitiveDataInfo) { + continue; + } + + if ($isSubjectId) { + throw new SubjectIdAndSensitiveDataConflict($classMetadata->className, $property->propertyName); + } + + $property->extras[SensitiveDataInfo::class] = $sensitiveDataInfo; + } + + if ($subjectIdMapping === []) { + return; + } + + $classMetadata->extras[SubjectIdFieldMapping::class] = new SubjectIdFieldMapping($subjectIdMapping); + } + + private function sensitiveDataInfo(ReflectionProperty $reflectionProperty): SensitiveDataInfo|null + { + $attributeReflectionList = $reflectionProperty->getAttributes(SensitiveData::class); + + if ($attributeReflectionList === []) { + return null; + } + + $attribute = $attributeReflectionList[0]->newInstance(); + + return new SensitiveDataInfo( + $attribute->subjectIdName, + $attribute->fallbackCallable !== null ? ($attribute->fallbackCallable)(...) : $attribute->fallback, + ); + } +} diff --git a/src/Extension/Cryptography/CryptographyMiddleware.php b/src/Extension/Cryptography/CryptographyMiddleware.php new file mode 100644 index 00000000..60616e59 --- /dev/null +++ b/src/Extension/Cryptography/CryptographyMiddleware.php @@ -0,0 +1,183 @@ + $metadata + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + $context[SubjectIds::class] = $subjectIds = $this->resolveSubjectIds($metadata, $data, $context); + + foreach ($metadata->properties as $propertyMetadata) { + $info = $propertyMetadata->extras[SensitiveDataInfo::class] ?? null; + + if (!$info instanceof SensitiveDataInfo) { + continue; + } + + $value = $data[$propertyMetadata->fieldName] ?? null; + + if ($value === null) { + continue; + } + + if (!$this->cryptographer->supports($value)) { + continue; + } + + $subjectId = $subjectIds->get($info->subjectIdName); + + try { + $data[$propertyMetadata->fieldName] = $this->cryptographer->decrypt($subjectId, $value); + } catch (DecryptionFailed | CipherKeyNotExists) { + $fallback = $info->fallback instanceof Closure + ? ($info->fallback)($subjectId) + : $info->fallback; + + if ($propertyMetadata->normalizer) { + if ($propertyMetadata->normalizer instanceof NormalizerWithContext) { + $fallback = $propertyMetadata->normalizer->normalize($fallback, $context); + } else { + $fallback = $propertyMetadata->normalizer->normalize($fallback); + } + } + + $data[$propertyMetadata->fieldName] = $fallback; + } + } + + return $stack->next()->hydrate( + $metadata, + $data, + $context, + $stack, + ); + } + + /** + * @param ClassMetadata $metadata + * @param T $object + * @param array $context + * + * @return array + * + * @template T of object + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + $context[SubjectIds::class] = $subjectIds = $this->resolveSubjectIds($metadata, $object, $context); + + $data = $stack->next()->extract($metadata, $object, $context, $stack); + + foreach ($metadata->properties as $propertyMetadata) { + $info = $propertyMetadata->extras[SensitiveDataInfo::class] ?? null; + + if (!$info instanceof SensitiveDataInfo) { + continue; + } + + $value = $data[$propertyMetadata->fieldName] ?? null; + + if ($value === null) { + continue; + } + + $data[$propertyMetadata->fieldName] = $this->cryptographer->encrypt( + $subjectIds->get($info->subjectIdName), + $value, + ); + } + + return $data; + } + + /** + * @param array|object $data + * @param array $context + */ + private function resolveSubjectIds( + ClassMetadata $metadata, + array|object $data, + array $context, + ): SubjectIds { + $subjectIds = $context[SubjectIds::class] ?? new SubjectIds(); + assert($subjectIds instanceof SubjectIds); + + $mapping = $metadata->extras[SubjectIdFieldMapping::class] ?? null; + + if (!$mapping instanceof SubjectIdFieldMapping) { + return $subjectIds; + } + + $result = []; + + foreach ($mapping->nameToField as $name => $fieldName) { + if (is_array($data)) { + if (!array_key_exists($fieldName, $data)) { + throw new MissingSubjectIdForField($metadata->className, $fieldName); + } + + $subjectId = $data[$fieldName]; + } else { + $property = $metadata->propertyForField($fieldName); + $subjectId = $property->getValue($data); + + if ($property->normalizer) { + if ($property->normalizer instanceof NormalizerWithContext) { + $subjectId = $property->normalizer->normalize($subjectId, $context); + } else { + $subjectId = $property->normalizer->normalize($subjectId); + } + } + } + + if (is_int($subjectId)) { + $subjectId = (string)$subjectId; + } + + if ($subjectId instanceof Stringable) { + $subjectId = $subjectId->__toString(); + } + + if (!is_string($subjectId)) { + throw new UnsupportedSubjectId($metadata->className, $fieldName, $subjectId); + } + + $result[$name] = $subjectId; + } + + return $subjectIds->merge(new SubjectIds($result)); + } +} diff --git a/src/Extension/Cryptography/DuplicateSubjectIdIdentifier.php b/src/Extension/Cryptography/DuplicateSubjectIdIdentifier.php new file mode 100644 index 00000000..6a93822c --- /dev/null +++ b/src/Extension/Cryptography/DuplicateSubjectIdIdentifier.php @@ -0,0 +1,29 @@ + */ + private array $indexById = []; + + /** @var array> */ + private array $indexBySubjectId = []; + + public function currentKeyFor(string $subjectId): CipherKey + { + if (!isset($this->indexBySubjectId[$subjectId])) { + throw CipherKeyNotExists::forSubjectId($subjectId); + } + + $lastKey = array_key_last($this->indexBySubjectId[$subjectId]); + + if ($lastKey === null) { + throw CipherKeyNotExists::forSubjectId($subjectId); + } + + return $this->indexBySubjectId[$subjectId][$lastKey]; + } + + public function get(string $keyId): CipherKey + { + return $this->indexById[$keyId] ?? throw CipherKeyNotExists::forKeyId($keyId); + } + + public function store(string $id, CipherKey $key): void + { + $this->indexById[$id] = $key; + + if (!isset($this->indexBySubjectId[$key->subjectId])) { + $this->indexBySubjectId[$key->subjectId] = []; + } + + $this->indexBySubjectId[$key->subjectId][] = $key; + } + + public function remove(string $id): void + { + unset($this->indexById[$id]); + + foreach ($this->indexBySubjectId as $subjectId => $keys) { + $filtered = []; + + foreach ($keys as $key) { + if ($key->id === $id) { + continue; + } + + $filtered[] = $key; + } + + if ($filtered === []) { + unset($this->indexBySubjectId[$subjectId]); + } else { + $this->indexBySubjectId[$subjectId] = $filtered; + } + } + } + + public function clear(): void + { + $this->indexById = []; + $this->indexBySubjectId = []; + } + + public function removeWithSubjectId(string $subjectId): void + { + if (!isset($this->indexBySubjectId[$subjectId])) { + return; + } + + foreach ($this->indexBySubjectId[$subjectId] as $key) { + unset($this->indexById[$key->id]); + } + + unset($this->indexBySubjectId[$subjectId]); + } +} diff --git a/src/Extension/Cryptography/SubjectIdAndSensitiveDataConflict.php b/src/Extension/Cryptography/SubjectIdAndSensitiveDataConflict.php new file mode 100644 index 00000000..69cdca84 --- /dev/null +++ b/src/Extension/Cryptography/SubjectIdAndSensitiveDataConflict.php @@ -0,0 +1,26 @@ + $nameToField */ + public function __construct( + public readonly array $nameToField, + ) { + } +} diff --git a/src/Extension/Cryptography/SubjectIds.php b/src/Extension/Cryptography/SubjectIds.php new file mode 100644 index 00000000..ca1df081 --- /dev/null +++ b/src/Extension/Cryptography/SubjectIds.php @@ -0,0 +1,27 @@ + $subjectIds */ + public function __construct( + public readonly array $subjectIds = [], + ) { + } + + public function merge(self $other): self + { + return new self(array_merge($this->subjectIds, $other->subjectIds)); + } + + public function get(string $name): string + { + return $this->subjectIds[$name] ?? throw new MissingSubjectId($name); + } +} diff --git a/src/Extension/Cryptography/UnsupportedSubjectId.php b/src/Extension/Cryptography/UnsupportedSubjectId.php new file mode 100644 index 00000000..ad739a38 --- /dev/null +++ b/src/Extension/Cryptography/UnsupportedSubjectId.php @@ -0,0 +1,20 @@ +addMiddleware(new LifecycleMiddleware()); + $builder->addMetadataEnricher(new LifecycleMetadataEnricher()); + } +} diff --git a/src/Extension/Lifecycle/LifecycleMetadataEnricher.php b/src/Extension/Lifecycle/LifecycleMetadataEnricher.php new file mode 100644 index 00000000..4222ac6b --- /dev/null +++ b/src/Extension/Lifecycle/LifecycleMetadataEnricher.php @@ -0,0 +1,74 @@ +reflection->getMethods() as $reflectionMethod) { + if ($reflectionMethod->getAttributes(PreHydrate::class)) { + if ($reflectionMethod->isStatic() === false) { + throw new LogicException(sprintf('Method "%s::%s" must be static when using the PreHydrate attribute.', $classMetadata->className, $reflectionMethod->getName())); + } + + $preHydrate = $reflectionMethod->getName(); + } + + if ($reflectionMethod->getAttributes(PostHydrate::class)) { + if ($reflectionMethod->isStatic() === false) { + throw new LogicException(sprintf('Method "%s::%s" must be static when using the PostHydrate attribute.', $classMetadata->className, $reflectionMethod->getName())); + } + + $postHydrate = $reflectionMethod->getName(); + } + + if ($reflectionMethod->getAttributes(PreExtract::class)) { + if ($reflectionMethod->isStatic() === false) { + throw new LogicException(sprintf('Method "%s::%s" must be static when using the PreExtract attribute.', $classMetadata->className, $reflectionMethod->getName())); + } + + $preExtract = $reflectionMethod->getName(); + } + + if (!$reflectionMethod->getAttributes(PostExtract::class)) { + continue; + } + + if ($reflectionMethod->isStatic() === false) { + throw new LogicException(sprintf('Method "%s::%s" must be static when using the PostExtract attribute.', $classMetadata->className, $reflectionMethod->getName())); + } + + $postExtract = $reflectionMethod->getName(); + } + + if ($preHydrate === null && $postHydrate === null && $preExtract === null && $postExtract === null) { + return; + } + + $classMetadata->extras[Lifecycle::class] = new Lifecycle( + $preHydrate, + $postHydrate, + $preExtract, + $postExtract, + ); + } +} diff --git a/src/Extension/Lifecycle/LifecycleMiddleware.php b/src/Extension/Lifecycle/LifecycleMiddleware.php new file mode 100644 index 00000000..ed9db0da --- /dev/null +++ b/src/Extension/Lifecycle/LifecycleMiddleware.php @@ -0,0 +1,71 @@ + $metadata + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + $lifecycle = $metadata->extras[Lifecycle::class] ?? null; + assert($lifecycle instanceof Lifecycle || $lifecycle === null); + + if ($lifecycle?->preHydrate) { + $data = $metadata->reflection->getMethod($lifecycle->preHydrate)->invoke(null, $data, $context); + /** @var array $data */ + } + + $object = $stack->next()->hydrate($metadata, $data, $context, $stack); + + if ($lifecycle?->postHydrate) { + $metadata->reflection->getMethod($lifecycle->postHydrate)->invoke(null, $object, $context); + } + + return $object; + } + + /** + * @param ClassMetadata $metadata + * @param T $object + * @param array $context + * + * @return array + * + * @template T of object + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + $lifecycle = $metadata->extras[Lifecycle::class] ?? null; + assert($lifecycle instanceof Lifecycle || $lifecycle === null); + + if ($lifecycle?->preExtract) { + $metadata->reflection->getMethod($lifecycle->preExtract)->invoke(null, $object, $context); + } + + $data = $stack->next()->extract($metadata, $object, $context, $stack); + + if ($lifecycle?->postExtract) { + $data = $metadata->reflection->getMethod($lifecycle->postExtract)->invoke(null, $data, $context); + /** @var array $data */ + } + + return $data; + } +} diff --git a/src/HydratorWithContext.php b/src/HydratorWithContext.php new file mode 100644 index 00000000..d4936b77 --- /dev/null +++ b/src/HydratorWithContext.php @@ -0,0 +1,28 @@ + $class + * @param array $data + * @param array $context + * + * @return T + * + * @throws ClassNotSupported if the class is not supported or not found. + * + * @template T of object + */ + public function hydrate(string $class, array $data, array $context = []): object; + + /** + * @param array $context + * + * @return array + */ + public function extract(object $object, array $context = []): array; +} diff --git a/src/Metadata/ClassMetadata.php b/src/Metadata/ClassMetadata.php index 4227a336..aa8f6d34 100644 --- a/src/Metadata/ClassMetadata.php +++ b/src/Metadata/ClassMetadata.php @@ -5,34 +5,58 @@ namespace Patchlevel\Hydrator\Metadata; use ReflectionClass; +use ReflectionParameter; + +use function array_values; /** * @psalm-type serialized array{ - * className: class-string, - * properties: list, + * className: class-string, + * properties: array, * dataSubjectIdField: string|null, * postHydrateCallbacks: list, * preExtractCallbacks: list, * lazy: bool|null, + * extras: array * } * @template T of object = object */ -final readonly class ClassMetadata +final class ClassMetadata { + /** @var class-string */ + public readonly string $className; + + /** @var array */ + public readonly array $properties; + + /** @var array|null */ + private array|null $promotedConstructorDefaults = null; + /** * @param ReflectionClass $reflection * @param list $properties * @param list $postHydrateCallbacks * @param list $preExtractCallbacks + * @param array $extras */ public function __construct( - private ReflectionClass $reflection, - private array $properties = [], - private string|null $dataSubjectIdField = null, - private array $postHydrateCallbacks = [], - private array $preExtractCallbacks = [], - private bool|null $lazy = null, + public readonly ReflectionClass $reflection, + array $properties = [], + public string|null $dataSubjectIdField = null, + public array $postHydrateCallbacks = [], + public array $preExtractCallbacks = [], + public bool|null $lazy = null, + public array $extras = [], ) { + $this->className = $reflection->getName(); + + $map = []; + + foreach ($properties as $property) { + $map[$property->propertyName] = $property; + } + + $this->properties = $map; } /** @return ReflectionClass */ @@ -44,13 +68,13 @@ public function reflection(): ReflectionClass /** @return class-string */ public function className(): string { - return $this->reflection->getName(); + return $this->className; } /** @return list */ public function properties(): array { - return $this->properties; + return array_values($this->properties); } /** @return list */ @@ -92,16 +116,43 @@ public function newInstance(): object return $this->reflection->newInstanceWithoutConstructor(); } + /** @return array */ + public function promotedConstructorDefaults(): array + { + if ($this->promotedConstructorDefaults !== null) { + return $this->promotedConstructorDefaults; + } + + $constructor = $this->reflection->getConstructor(); + + if (!$constructor) { + return $this->promotedConstructorDefaults = []; + } + + $result = []; + + foreach ($constructor->getParameters() as $parameter) { + if (!$parameter->isPromoted() || !$parameter->isDefaultValueAvailable()) { + continue; + } + + $result[$parameter->getName()] = $parameter; + } + + return $this->promotedConstructorDefaults = $result; + } + /** @return serialized */ public function __serialize(): array { return [ - 'className' => $this->reflection->getName(), + 'className' => $this->className, 'properties' => $this->properties, 'dataSubjectIdField' => $this->dataSubjectIdField, 'postHydrateCallbacks' => $this->postHydrateCallbacks, 'preExtractCallbacks' => $this->preExtractCallbacks, 'lazy' => $this->lazy, + 'extras' => $this->extras, ]; } @@ -114,5 +165,6 @@ public function __unserialize(array $data): void $this->postHydrateCallbacks = $data['postHydrateCallbacks']; $this->preExtractCallbacks = $data['preExtractCallbacks']; $this->lazy = $data['lazy']; + $this->extras = $data['extras']; } } diff --git a/src/Metadata/EnrichingMetadataFactory.php b/src/Metadata/EnrichingMetadataFactory.php new file mode 100644 index 00000000..afd8d551 --- /dev/null +++ b/src/Metadata/EnrichingMetadataFactory.php @@ -0,0 +1,35 @@ + $enrichers */ + public function __construct( + private MetadataFactory $factory, + private iterable $enrichers, + ) { + } + + /** + * @param class-string $class + * + * @return ClassMetadata + * + * @throws ClassNotFound if the class does not exist. + * + * @template T of object + */ + public function metadata(string $class): ClassMetadata + { + $metadata = $this->factory->metadata($class); + + foreach ($this->enrichers as $enricher) { + $enricher->enrich($metadata); + } + + return $metadata; + } +} diff --git a/src/Metadata/MetadataEnricher.php b/src/Metadata/MetadataEnricher.php new file mode 100644 index 00000000..5516db15 --- /dev/null +++ b/src/Metadata/MetadataEnricher.php @@ -0,0 +1,10 @@ + * } */ final class PropertyMetadata { private const ENCRYPTED_PREFIX = '!'; - /** @param (callable(string, mixed):mixed)|null $personalDataFallbackCallable */ + public readonly string $propertyName; + + /** + * @param (callable(string, mixed):mixed)|null $personalDataFallbackCallable + * @param array $extras + */ public function __construct( - private readonly ReflectionProperty $reflection, - private readonly string $fieldName, - private readonly Normalizer|null $normalizer = null, - private readonly bool $isPersonalData = false, - private readonly mixed $personalDataFallback = null, - private readonly mixed $personalDataFallbackCallable = null, + public readonly ReflectionProperty $reflection, + public string $fieldName, + public Normalizer|null $normalizer = null, + public readonly bool $isPersonalData = false, + public readonly mixed $personalDataFallback = null, + public readonly mixed $personalDataFallbackCallable = null, + public array $extras = [], ) { + $this->propertyName = $reflection->getName(); + if (str_starts_with($fieldName, self::ENCRYPTED_PREFIX)) { throw new InvalidArgumentException('fieldName must not start with !'); } @@ -46,7 +55,7 @@ public function reflection(): ReflectionProperty public function propertyName(): string { - return $this->reflection->getName(); + return $this->propertyName; } public function fieldName(): string @@ -99,11 +108,12 @@ public function __serialize(): array { return [ 'className' => $this->reflection->getDeclaringClass()->getName(), - 'property' => $this->reflection->getName(), + 'property' => $this->propertyName, 'fieldName' => $this->fieldName, 'normalizer' => $this->normalizer, 'isPersonalData' => $this->isPersonalData, 'personalDataFallback' => $this->personalDataFallback, + 'extras' => $this->extras, ]; } @@ -115,5 +125,6 @@ public function __unserialize(array $data): void $this->normalizer = $data['normalizer']; $this->isPersonalData = $data['isPersonalData']; $this->personalDataFallback = $data['personalDataFallback']; + $this->extras = $data['extras']; } } diff --git a/src/MetadataHydrator.php b/src/MetadataHydrator.php index e1a37a7f..20fb5e73 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 Patchlevel\Hydrator\Normalizer\NormalizerWithContext; use ReflectionClass; use ReflectionParameter; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -30,7 +31,7 @@ use const PHP_VERSION_ID; -final class MetadataHydrator implements Hydrator +final class MetadataHydrator implements HydratorWithContext { /** @var array */ private array $stack = []; @@ -57,12 +58,13 @@ public function __construct( /** * @param class-string $class * @param array $data + * @param array $context * * @return T * * @template T of object */ - public function hydrate(string $class, array $data): object + public function hydrate(string $class, array $data, array $context = []): object { try { $metadata = $this->metadataFactory->metadata($class); @@ -71,18 +73,18 @@ public function hydrate(string $class, array $data): object } if (PHP_VERSION_ID < 80400) { - return $this->doHydrate($metadata, $data); + return $this->doHydrate($metadata, $data, $context); } $lazy = $metadata->lazy() ?? $this->defaultLazy; if (!$lazy) { - return $this->doHydrate($metadata, $data); + return $this->doHydrate($metadata, $data, $context); } return (new ReflectionClass($class))->newLazyProxy( - function () use ($metadata, $data): object { - return $this->doHydrate($metadata, $data); + function () use ($metadata, $data, $context): object { + return $this->doHydrate($metadata, $data, $context); }, ); } @@ -90,12 +92,13 @@ function () use ($metadata, $data): object { /** * @param ClassMetadata $metadata * @param array $data + * @param array $context * * @return T * * @template T of object */ - private function doHydrate(ClassMetadata $metadata, array $data): object + private function doHydrate(ClassMetadata $metadata, array $data, array $context = []): object { if ($this->eventDispatcher) { $data = $this->eventDispatcher->dispatch(new PreHydrate($data, $metadata))->data; @@ -138,7 +141,11 @@ private function doHydrate(ClassMetadata $metadata, array $data): object try { /** @psalm-suppress MixedAssignment */ - $value = $normalizer->denormalize($value); + if ($normalizer instanceof NormalizerWithContext) { + $value = $normalizer->denormalize($value, $context); + } else { + $value = $normalizer->denormalize($value); + } } catch (Throwable $e) { throw new DenormalizationFailure( $metadata->className(), @@ -167,8 +174,12 @@ private function doHydrate(ClassMetadata $metadata, array $data): object return $object; } - /** @return array */ - public function extract(object $object): array + /** + * @param array $context + * + * @return array + */ + public function extract(object $object, array $context = []): array { $objectId = spl_object_id($object); @@ -202,11 +213,18 @@ public function extract(object $object): array } try { - /** @psalm-suppress MixedAssignment */ - $value = $normalizer->normalize($value); - } catch (CircularReference $e) { - throw $e; + if ($normalizer instanceof NormalizerWithContext) { + /** @psalm-suppress MixedAssignment */ + $value = $normalizer->normalize($value, $context); + } else { + /** @psalm-suppress MixedAssignment */ + $value = $normalizer->normalize($value); + } } catch (Throwable $e) { + if ($e instanceof CircularReference) { + throw $e; + } + throw new NormalizationFailure( $object::class, $propertyMetadata->propertyName(), diff --git a/src/Middleware/Middleware.php b/src/Middleware/Middleware.php new file mode 100644 index 00000000..aa6074a5 --- /dev/null +++ b/src/Middleware/Middleware.php @@ -0,0 +1,33 @@ + $metadata + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object; + + /** + * @param ClassMetadata $metadata + * @param T $object + * @param array $context + * + * @return array + * + * @template T of object + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array; +} diff --git a/src/Middleware/NoMoreMiddleware.php b/src/Middleware/NoMoreMiddleware.php new file mode 100644 index 00000000..4d063998 --- /dev/null +++ b/src/Middleware/NoMoreMiddleware.php @@ -0,0 +1,17 @@ + $middlewares */ + public function __construct( + private readonly array $middlewares, + ) { + } + + public function next(): Middleware + { + $next = $this->middlewares[$this->index] ?? null; + + if ($next === null) { + throw new NoMoreMiddleware(); + } + + $this->index++; + + return $next; + } +} diff --git a/src/Middleware/TransformMiddleware.php b/src/Middleware/TransformMiddleware.php new file mode 100644 index 00000000..c90d1fcf --- /dev/null +++ b/src/Middleware/TransformMiddleware.php @@ -0,0 +1,152 @@ + */ + private array $callStack = []; + + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + $object = $metadata->newInstance(); + + $constructorParameters = null; + + foreach ($metadata->properties() as $propertyMetadata) { + if (!array_key_exists($propertyMetadata->fieldName(), $data)) { + if (!$propertyMetadata->reflection->isPromoted()) { + continue; + } + + $constructorParameters ??= $metadata->promotedConstructorDefaults(); + + if (!array_key_exists($propertyMetadata->propertyName, $constructorParameters)) { + continue; + } + + $propertyMetadata->setValue( + $object, + $constructorParameters[$propertyMetadata->propertyName]->getDefaultValue(), + ); + + continue; + } + + if ($propertyMetadata->normalizer) { + try { + if ($propertyMetadata->normalizer instanceof NormalizerWithContext) { + /** @psalm-suppress MixedAssignment */ + $value = $propertyMetadata->normalizer->denormalize($data[$propertyMetadata->fieldName], $context); + } else { + /** @psalm-suppress MixedAssignment */ + $value = $propertyMetadata->normalizer->denormalize($data[$propertyMetadata->fieldName]); + } + } catch (Throwable $e) { + throw new DenormalizationFailure( + $metadata->className, + $propertyMetadata->propertyName, + $propertyMetadata->normalizer::class, + $e, + ); + } + } else { + $value = $data[$propertyMetadata->fieldName]; + } + + try { + $propertyMetadata->setValue($object, $value); + } catch (TypeError $e) { + throw new TypeMismatch( + $metadata->className, + $propertyMetadata->propertyName, + $e, + ); + } + } + + return $object; + } + + /** + * @param array $context + * + * @return array + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + $objectId = spl_object_id($object); + + if (array_key_exists($objectId, $this->callStack)) { + $references = array_values($this->callStack); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack[$objectId] = $object::class; + + try { + $data = []; + + foreach ($metadata->properties as $propertyMetadata) { + if ($propertyMetadata->normalizer) { + try { + if ($propertyMetadata->normalizer instanceof NormalizerWithContext) { + /** @psalm-suppress MixedAssignment */ + $data[$propertyMetadata->fieldName] = $propertyMetadata->normalizer->normalize( + $propertyMetadata->getValue($object), + $context, + ); + } else { + /** @psalm-suppress MixedAssignment */ + $data[$propertyMetadata->fieldName] = $propertyMetadata->normalizer->normalize( + $propertyMetadata->getValue($object), + ); + } + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure( + $object::class, + $propertyMetadata->propertyName, + $propertyMetadata->normalizer::class, + $e, + ); + } + } else { + $data[$propertyMetadata->fieldName] = $propertyMetadata->getValue($object); + } + } + } finally { + unset($this->callStack[$objectId]); + } + + return $data; + } +} diff --git a/src/Normalizer/ArrayNormalizer.php b/src/Normalizer/ArrayNormalizer.php index b410f174..b61d9b29 100644 --- a/src/Normalizer/ArrayNormalizer.php +++ b/src/Normalizer/ArrayNormalizer.php @@ -13,15 +13,19 @@ use function is_array; #[Attribute(Attribute::TARGET_PROPERTY)] -final readonly class ArrayNormalizer implements Normalizer, TypeAwareNormalizer, HydratorAwareNormalizer +final readonly class ArrayNormalizer implements NormalizerWithContext, TypeAwareNormalizer, HydratorAwareNormalizer { public function __construct( private Normalizer $normalizer, ) { } - /** @return array|null */ - public function normalize(mixed $value): array|null + /** + * @param array $context + * + * @return array|null + */ + public function normalize(mixed $value, array $context = []): array|null { if ($value === null) { return null; @@ -31,15 +35,25 @@ public function normalize(mixed $value): array|null throw InvalidArgument::withWrongType('array|null', $value); } - foreach ($value as &$item) { - $item = $this->normalizer->normalize($item); + if ($this->normalizer instanceof NormalizerWithContext) { + foreach ($value as &$item) { + $item = $this->normalizer->normalize($item, $context); + } + } else { + foreach ($value as &$item) { + $item = $this->normalizer->normalize($item); + } } return $value; } - /** @return array|null */ - public function denormalize(mixed $value): array|null + /** + * @param array $context + * + * @return array|null + */ + public function denormalize(mixed $value, array $context = []): array|null { if ($value === null) { return null; @@ -49,8 +63,14 @@ public function denormalize(mixed $value): array|null throw InvalidArgument::withWrongType('array|null', $value); } - foreach ($value as &$item) { - $item = $this->normalizer->denormalize($item); + if ($this->normalizer instanceof NormalizerWithContext) { + foreach ($value as &$item) { + $item = $this->normalizer->denormalize($item, $context); + } + } else { + foreach ($value as &$item) { + $item = $this->normalizer->denormalize($item); + } } return $value; diff --git a/src/Normalizer/ArrayShapeNormalizer.php b/src/Normalizer/ArrayShapeNormalizer.php index 3bae3d42..b86692f7 100644 --- a/src/Normalizer/ArrayShapeNormalizer.php +++ b/src/Normalizer/ArrayShapeNormalizer.php @@ -13,7 +13,7 @@ use function is_array; #[Attribute(Attribute::TARGET_PROPERTY)] -final readonly class ArrayShapeNormalizer implements Normalizer, TypeAwareNormalizer, HydratorAwareNormalizer +final readonly class ArrayShapeNormalizer implements NormalizerWithContext, TypeAwareNormalizer, HydratorAwareNormalizer { /** @param array $normalizerMap */ public function __construct( @@ -21,8 +21,12 @@ public function __construct( ) { } - /** @return array|null */ - public function normalize(mixed $value): array|null + /** + * @param array $context + * + * @return array|null + */ + public function normalize(mixed $value, array $context = []): array|null { if ($value === null) { return null; @@ -39,14 +43,22 @@ public function normalize(mixed $value): array|null continue; } - $result[$field] = $normalizer->normalize($value[$field]); + if ($normalizer instanceof NormalizerWithContext) { + $result[$field] = $normalizer->normalize($value[$field], $context); + } else { + $result[$field] = $normalizer->normalize($value[$field]); + } } return $result; } - /** @return array|null */ - public function denormalize(mixed $value): array|null + /** + * @param array $context + * + * @return array|null + */ + public function denormalize(mixed $value, array $context = []): array|null { if ($value === null) { return null; @@ -63,7 +75,11 @@ public function denormalize(mixed $value): array|null continue; } - $result[$field] = $normalizer->denormalize($value[$field]); + if ($normalizer instanceof NormalizerWithContext) { + $result[$field] = $normalizer->denormalize($value[$field], $context); + } else { + $result[$field] = $normalizer->denormalize($value[$field]); + } } return $result; diff --git a/src/Normalizer/NormalizerWithContext.php b/src/Normalizer/NormalizerWithContext.php new file mode 100644 index 00000000..7a976e76 --- /dev/null +++ b/src/Normalizer/NormalizerWithContext.php @@ -0,0 +1,22 @@ + $context + * + * @throws InvalidArgument + */ + public function normalize(mixed $value, array $context = []): mixed; + + /** + * @param array $context + * + * @throws InvalidArgument + */ + public function denormalize(mixed $value, array $context = []): mixed; +} diff --git a/src/Normalizer/ObjectMapNormalizer.php b/src/Normalizer/ObjectMapNormalizer.php index 83da1b45..8bc76308 100644 --- a/src/Normalizer/ObjectMapNormalizer.php +++ b/src/Normalizer/ObjectMapNormalizer.php @@ -6,6 +6,7 @@ use Attribute; use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\HydratorWithContext; use function array_flip; use function array_key_exists; @@ -17,7 +18,7 @@ use function sprintf; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] -final class ObjectMapNormalizer implements Normalizer, HydratorAwareNormalizer +final class ObjectMapNormalizer implements NormalizerWithContext, HydratorAwareNormalizer { private Hydrator|null $hydrator = null; @@ -37,7 +38,8 @@ public function setHydrator(Hydrator $hydrator): void $this->hydrator = $hydrator; } - public function normalize(mixed $value): mixed + /** @param array $context */ + public function normalize(mixed $value, array $context = []): mixed { if (!$this->hydrator) { throw new MissingHydrator(); @@ -61,13 +63,19 @@ public function normalize(mixed $value): mixed ); } - $data = $this->hydrator->extract($value); + if ($this->hydrator instanceof HydratorWithContext) { + $data = $this->hydrator->extract($value, $context); + } else { + $data = $this->hydrator->extract($value); + } + $data[$this->typeFieldName] = $this->classToTypeMap[$value::class]; return $data; } - public function denormalize(mixed $value): mixed + /** @param array $context */ + public function denormalize(mixed $value, array $context = []): mixed { if (!$this->hydrator) { throw new MissingHydrator(); @@ -98,6 +106,10 @@ public function denormalize(mixed $value): mixed $className = $this->typeToClassMap[$type]; unset($value[$this->typeFieldName]); + if ($this->hydrator instanceof HydratorWithContext) { + return $this->hydrator->hydrate($className, $value, $context); + } + return $this->hydrator->hydrate($className, $value); } diff --git a/src/Normalizer/ObjectNormalizer.php b/src/Normalizer/ObjectNormalizer.php index 4977ec93..2aca4d7b 100644 --- a/src/Normalizer/ObjectNormalizer.php +++ b/src/Normalizer/ObjectNormalizer.php @@ -6,6 +6,7 @@ use Attribute; use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\HydratorWithContext; use ReflectionType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\GenericType; @@ -16,7 +17,7 @@ use function is_array; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] -final class ObjectNormalizer implements Normalizer, ReflectionTypeAwareNormalizer, TypeAwareNormalizer, HydratorAwareNormalizer +final class ObjectNormalizer implements NormalizerWithContext, ReflectionTypeAwareNormalizer, TypeAwareNormalizer, HydratorAwareNormalizer { private Hydrator|null $hydrator = null; @@ -26,8 +27,12 @@ public function __construct( ) { } - /** @return array|null */ - public function normalize(mixed $value): array|null + /** + * @param array $context + * + * @return array|null + */ + public function normalize(mixed $value, array $context = []): array|null { if (!$this->hydrator) { throw new MissingHydrator(); @@ -43,10 +48,15 @@ public function normalize(mixed $value): array|null throw InvalidArgument::withWrongType($className . '|null', $value); } + if ($this->hydrator instanceof HydratorWithContext) { + return $this->hydrator->extract($value, $context); + } + return $this->hydrator->extract($value); } - public function denormalize(mixed $value): object|null + /** @param array $context */ + public function denormalize(mixed $value, array $context = []): object|null { if (!$this->hydrator) { throw new MissingHydrator(); @@ -62,6 +72,10 @@ public function denormalize(mixed $value): object|null $className = $this->getClassName(); + if ($this->hydrator instanceof HydratorWithContext) { + return $this->hydrator->hydrate($className, $value, $context); + } + return $this->hydrator->hydrate($className, $value); } diff --git a/src/StackHydrator.php b/src/StackHydrator.php new file mode 100644 index 00000000..f0bab583 --- /dev/null +++ b/src/StackHydrator.php @@ -0,0 +1,114 @@ + */ + private array $classMetadata = []; + + /** @param list $middlewares */ + public function __construct( + private readonly MetadataFactory $metadataFactory = new AttributeMetadataFactory(), + private readonly array $middlewares = [new TransformMiddleware()], + private readonly bool $defaultLazy = false, + ) { + } + + /** + * @param class-string $class + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(string $class, array $data, array $context = []): object + { + try { + $metadata = $this->metadata($class); + } catch (ClassNotFound $e) { + throw new ClassNotSupported($class, $e); + } + + if (PHP_VERSION_ID < 80400) { + $stack = new Stack($this->middlewares); + + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + + $lazy = $metadata->lazy ?? $this->defaultLazy; + + if (!$lazy) { + $stack = new Stack($this->middlewares); + + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + + return (new ReflectionClass($class))->newLazyProxy( + function () use ($metadata, $data, $context): object { + $stack = new Stack($this->middlewares); + + return $stack->next()->hydrate($metadata, $data, $context, $stack); + }, + ); + } + + /** + * @param array $context + * + * @return array + */ + public function extract(object $object, array $context = []): array + { + $metadata = $this->metadata($object::class); + + $stack = new Stack($this->middlewares); + + return $stack->next()->extract($metadata, $object, $context, $stack); + } + + /** + * @param class-string $class + * + * @return ClassMetadata + * + * @template T of object + */ + public function metadata(string $class): ClassMetadata + { + if (array_key_exists($class, $this->classMetadata)) { + return $this->classMetadata[$class]; + } + + $this->classMetadata[$class] = $metadata = $this->metadataFactory->metadata($class); + + foreach ($metadata->properties() as $property) { + if (!($property->normalizer instanceof HydratorAwareNormalizer)) { + continue; + } + + $property->normalizer->setHydrator($this); + } + + return $metadata; + } +} diff --git a/src/StackHydratorBuilder.php b/src/StackHydratorBuilder.php new file mode 100644 index 00000000..4d5e72f7 --- /dev/null +++ b/src/StackHydratorBuilder.php @@ -0,0 +1,109 @@ +> */ + private array $middlewares = []; + + /** @var array> */ + private array $metadataEnrichers = []; + + /** @var array> */ + private array $guessers = []; + + private CacheItemPoolInterface|CacheInterface|null $cache = null; + + /** @return $this */ + public function addMiddleware(Middleware $middleware, int $priority = 0): static + { + $this->middlewares[$priority][] = $middleware; + + return $this; + } + + /** @return $this */ + public function addMetadataEnricher(MetadataEnricher $enricher, int $priority = 0): static + { + $this->metadataEnrichers[$priority][] = $enricher; + + return $this; + } + + /** @return $this */ + public function addGuesser(Guesser $guesser, int $priority = 0): static + { + $this->guessers[$priority][] = $guesser; + + return $this; + } + + public function enableDefaultLazy(bool $lazy = true): static + { + $this->defaultLazy = $lazy; + + return $this; + } + + public function useExtension(Extension $extension): static + { + $extension->configure($this); + + return $this; + } + + public function setCache(CacheItemPoolInterface|CacheInterface|null $cache): static + { + $this->cache = $cache; + + return $this; + } + + public function build(): StackHydrator + { + krsort($this->guessers); + krsort($this->metadataEnrichers); + krsort($this->middlewares); + + $metadataFactory = new EnrichingMetadataFactory( + new AttributeMetadataFactory( + guesser: new ChainGuesser(array_merge(...$this->guessers)), + ), + array_merge(...$this->metadataEnrichers), + ); + + if ($this->cache instanceof CacheItemPoolInterface) { + $metadataFactory = new Psr6MetadataFactory($metadataFactory, $this->cache); + } + + if ($this->cache instanceof CacheInterface) { + $metadataFactory = new Psr16MetadataFactory($metadataFactory, $this->cache); + } + + return new StackHydrator( + $metadataFactory, + array_merge(...$this->middlewares), + $this->defaultLazy, + ); + } +} diff --git a/tests/Benchmark/StackHydratorBench.php b/tests/Benchmark/StackHydratorBench.php new file mode 100644 index 00000000..4b1f7cf8 --- /dev/null +++ b/tests/Benchmark/StackHydratorBench.php @@ -0,0 +1,135 @@ +hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->build(); + } + + 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 benchExtract1Object(): void + { + $object = new ProfileCreated( + ProfileId::fromString('1'), + 'foo', + [ + new Skill('php'), + new Skill('symfony'), + ], + ); + + $this->hydrator->extract($object); + } + + #[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 benchExtract1000Objects(): void + { + $object = new ProfileCreated( + ProfileId::fromString('1'), + 'foo', + [ + new Skill('php'), + new Skill('symfony'), + ], + ); + + for ($i = 0; $i < 1_000; $i++) { + $this->hydrator->extract($object); + } + } + + #[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 benchExtract1000000Objects(): void + { + $object = new ProfileCreated( + ProfileId::fromString('1'), + 'foo', + [ + new Skill('php'), + new Skill('symfony'), + ], + ); + + for ($i = 0; $i < 1_000_000; $i++) { + $this->hydrator->extract($object); + } + } +} diff --git a/tests/Benchmark/StackHydratorWithCryptographyBench.php b/tests/Benchmark/StackHydratorWithCryptographyBench.php new file mode 100644 index 00000000..5d5f54b9 --- /dev/null +++ b/tests/Benchmark/StackHydratorWithCryptographyBench.php @@ -0,0 +1,154 @@ +store = new InMemoryCipherKeyStore(); + + $this->hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($this->store))) + ->build(); + } + + public function setUp(): void + { + $this->store->clear(); + + $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 benchExtract1Object(): void + { + $object = new ProfileCreated( + ProfileId::fromString('1'), + 'foo', + [ + new Skill('php'), + new Skill('symfony'), + ], + ); + + $this->hydrator->extract($object); + } + + #[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 benchExtract1000Objects(): void + { + $object = new ProfileCreated( + ProfileId::fromString('1'), + 'foo', + [ + new Skill('php'), + new Skill('symfony'), + ], + ); + + for ($i = 0; $i < 1_000; $i++) { + $this->hydrator->extract($object); + } + } + + #[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 benchExtract1000000Objects(): void + { + $object = new ProfileCreated( + ProfileId::fromString('1'), + 'foo', + [ + new Skill('php'), + new Skill('symfony'), + ], + ); + + for ($i = 0; $i < 1_000_000; $i++) { + $this->hydrator->extract($object); + } + } +} diff --git a/tests/Unit/Extension/Cryptography/BaseCryptographerTest.php b/tests/Unit/Extension/Cryptography/BaseCryptographerTest.php new file mode 100644 index 00000000..296026aa --- /dev/null +++ b/tests/Unit/Extension/Cryptography/BaseCryptographerTest.php @@ -0,0 +1,164 @@ +createMock(CipherKeyStore::class); + $cipherKeyStore->method('currentKeyFor')->with('subject-foo')->willReturn($cipherKey); + + $cipherKeyStore + ->expects($this->never()) + ->method('store'); + + $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); + $cipherKeyFactory->expects($this->never())->method('__invoke'); + + $cipher = $this->createMock(Cipher::class); + $cipher->expects($this->once())->method('encrypt')->with($cipherKey, 'info@patchlevel.de') + ->willReturn($encryptionParameter); + + $cryptographer = new BaseCryptographer( + $cipher, + $cipherKeyStore, + $cipherKeyFactory, + ); + + $expected = [ + 'v' => 1, + 'a' => 'aes-256-gcm', + 'k' => 'key-123', + 'd' => 'encrypted-data', + 'n' => 'random-nonce', + 't' => 'auth-tag', + ]; + + self::assertEquals($expected, $cryptographer->encrypt('subject-foo', 'info@patchlevel.de')); + } + + public function testEncryptWithoutKey(): void + { + $cipherKey = new CipherKey('key-456', 'subject-bar', 'secret-key', 'aes-256-gcm', new DateTimeImmutable()); + $encryptionParameter = new EncryptedData('encrypted-data', 'aes-256-gcm', 'random-nonce', 'auth-tag'); + + $cipherKeyStore = $this->createMock(CipherKeyStore::class); + $cipherKeyStore->method('currentKeyFor')->with('subject-bar')->willThrowException(CipherKeyNotExists::forSubjectId('subject-bar')); + + $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); + $cipherKeyFactory->expects($this->once())->method('__invoke')->with('subject-bar')->willReturn($cipherKey); + + $cipherKeyStore + ->expects($this->once()) + ->method('store') + ->with('key-456', $cipherKey); + + $cipher = $this->createMock(Cipher::class); + $cipher->expects($this->once())->method('encrypt')->with($cipherKey, 'info@patchlevel.de') + ->willReturn($encryptionParameter); + + $cryptographer = new BaseCryptographer( + $cipher, + $cipherKeyStore, + $cipherKeyFactory, + ); + + $expected = [ + 'v' => 1, + 'a' => 'aes-256-gcm', + 'k' => 'key-456', + 'd' => 'encrypted-data', + 'n' => 'random-nonce', + 't' => 'auth-tag', + ]; + + self::assertEquals($expected, $cryptographer->encrypt('subject-bar', 'info@patchlevel.de')); + } + + public function testDecrypt(): void + { + $cipherKey = new CipherKey('key-789', 'subject-baz', 'secret-key', 'aes-256-gcm', new DateTimeImmutable()); + + $cipherKeyStore = $this->createMock(CipherKeyStore::class); + $cipherKeyStore->method('get')->with('key-789')->willReturn($cipherKey); + + $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); + $cipherKeyFactory->expects($this->never())->method('__invoke'); + + $cipher = $this->createMock(Cipher::class); + $cipher->expects($this->once())->method('decrypt')->with( + $cipherKey, + $this->callback(static function (EncryptedData $param) { + return $param->data === 'encrypted-data' + && $param->method === 'aes-256-gcm' + && $param->nonce === 'random-nonce' + && $param->tag === 'auth-tag'; + }), + )->willReturn('info@patchlevel.de'); + + $cryptographer = new BaseCryptographer( + $cipher, + $cipherKeyStore, + $cipherKeyFactory, + ); + + self::assertEquals( + 'info@patchlevel.de', + $cryptographer->decrypt( + 'subject-baz', + [ + 'v' => 1, + 'a' => 'aes-256-gcm', + 'k' => 'key-789', + 'd' => 'encrypted-data', + 'n' => 'random-nonce', + 't' => 'auth-tag', + ], + ), + ); + } + + #[DataProvider('dataProviderSupports')] + public function testSupports(mixed $value, bool $supported): void + { + $cryptographer = new BaseCryptographer( + $this->createMock(Cipher::class), + $this->createMock(CipherKeyStore::class), + $this->createMock(CipherKeyFactory::class), + ); + + self::assertEquals($supported, $cryptographer->supports($value)); + } + + /** @return iterable */ + public static function dataProviderSupports(): iterable + { + yield ['foo', false]; + yield [[], false]; + yield [null, false]; + yield [['v' => 'foo'], false]; + yield [['v' => 2], false]; + yield [['v' => 1], false]; // missing required fields + yield [['v' => 1, 'a' => 'aes-256-gcm'], false]; // missing k and d + yield [['v' => 1, 'a' => 'aes-256-gcm', 'k' => 'key-123', 'd' => 'data'], true]; + } +} diff --git a/tests/Unit/Extension/Cryptography/Cipher/CreateCipherKeyFailedTest.php b/tests/Unit/Extension/Cryptography/Cipher/CreateCipherKeyFailedTest.php new file mode 100644 index 00000000..13fd8bdd --- /dev/null +++ b/tests/Unit/Extension/Cryptography/Cipher/CreateCipherKeyFailedTest.php @@ -0,0 +1,21 @@ +getMessage()); + self::assertStringContainsString('test reason', $exception->getMessage()); + } +} diff --git a/tests/Unit/Extension/Cryptography/Cipher/DecryptionFailedTest.php b/tests/Unit/Extension/Cryptography/Cipher/DecryptionFailedTest.php new file mode 100644 index 00000000..a3bb2b4c --- /dev/null +++ b/tests/Unit/Extension/Cryptography/Cipher/DecryptionFailedTest.php @@ -0,0 +1,20 @@ +getMessage()); + } +} diff --git a/tests/Unit/Extension/Cryptography/Cipher/EncryptionFailedTest.php b/tests/Unit/Extension/Cryptography/Cipher/EncryptionFailedTest.php new file mode 100644 index 00000000..a1e8d833 --- /dev/null +++ b/tests/Unit/Extension/Cryptography/Cipher/EncryptionFailedTest.php @@ -0,0 +1,20 @@ +getMessage()); + } +} diff --git a/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherKeyFactoryTest.php b/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherKeyFactoryTest.php new file mode 100644 index 00000000..79f87112 --- /dev/null +++ b/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherKeyFactoryTest.php @@ -0,0 +1,34 @@ +assertSame(32, strlen($cipherKey->key)); + $this->assertSame('aes-128-gcm', $cipherKey->method); + $this->assertSame('test-subject', $cipherKey->subjectId); + } + + public function testMethodNotSupported(): void + { + $this->expectException(MethodNotSupported::class); + + $cipherKeyFactory = new OpensslCipherKeyFactory(method: 'foo'); + $cipherKeyFactory('test-subject'); + } +} diff --git a/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherTest.php b/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherTest.php new file mode 100644 index 00000000..4d05d15d --- /dev/null +++ b/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherTest.php @@ -0,0 +1,86 @@ +createKey(); + + $encrypted = $cipher->encrypt($key, $value); + + self::assertEquals('aes-128-cbc', $encrypted->method); + self::assertNotNull($encrypted->nonce); + self::assertNotEmpty($encrypted->data); + + $decrypted = $cipher->decrypt($key, $encrypted); + + self::assertEquals($value, $decrypted); + } + + public function testEncryptFailed(): void + { + $this->expectException(EncryptionFailed::class); + + $cipher = new OpensslCipher(); + $cipher->encrypt(new CipherKey( + 'key', + 'bar', + 'abcdefg123456789', + 'invalid-method', + new DateTimeImmutable(), + ), ''); + } + + public function testDecryptFailed(): void + { + $this->expectException(DecryptionFailed::class); + + $cipher = new OpensslCipher(); + $encryptedData = new EncryptedData('invalid-data', 'aes-128-cbc', 'invalid-nonce', null); + $cipher->decrypt($this->createKey(), $encryptedData); + } + + /** @return Generator */ + public static function dataProvider(): Generator + { + yield 'empty' => ['']; + yield 'string' => ['foo bar baz']; + yield 'integer' => [42]; + yield 'float' => [0.5]; + yield 'null' => [null]; + yield 'true' => [true]; + yield 'false' => [false]; + yield 'array' => [['foo' => 'bar']]; + yield 'long text' => ['Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.']; + } + + /** @param non-empty-string $key */ + private function createKey(string $key = 'key'): CipherKey + { + return new CipherKey( + $key, + 'aes128', + 'abcdefg123456789', + 'aes-128-cbc', + new DateTimeImmutable(), + ); + } +} diff --git a/tests/Unit/Extension/Cryptography/CryptographyMetadataEnricherTest.php b/tests/Unit/Extension/Cryptography/CryptographyMetadataEnricherTest.php new file mode 100644 index 00000000..ed5ae8f8 --- /dev/null +++ b/tests/Unit/Extension/Cryptography/CryptographyMetadataEnricherTest.php @@ -0,0 +1,191 @@ +metadata($event::class); + + self::assertArrayHasKey(SubjectIdFieldMapping::class, $metadata->extras); + $subjectIdFieldMapping = $metadata->extras[SubjectIdFieldMapping::class]; + self::assertInstanceOf(SubjectIdFieldMapping::class, $subjectIdFieldMapping); + self::assertEquals(['default' => '_id'], $subjectIdFieldMapping->nameToField); + + $property = $metadata->propertyForField('_name'); + + self::assertArrayHasKey(SensitiveDataInfo::class, $property->extras); + self::assertEquals(new SensitiveDataInfo('default', 'fallback'), $property->extras[SensitiveDataInfo::class]); + } + + public function testSubjectIdAndSensitiveDataConflict(): void + { + $event = new class ('name') { + public function __construct( + #[DataSubjectId] + #[SensitiveData] + public string $name, + ) { + } + }; + + $this->expectException(SubjectIdAndSensitiveDataConflict::class); + + $this->metadata($event::class); + } + + public function testMultipleDataSubjectIdWithSameIdentifier(): void + { + $event = new class ('id', 'name') { + public function __construct( + #[DataSubjectId] + public string $id, + #[DataSubjectId] + public string $name, + ) { + } + }; + + $this->expectException(DuplicateSubjectIdIdentifier::class); + + $this->metadata($event::class); + } + + public function testSensitiveDataWithMultipleDataSubjectIdWithDifferentNames(): void + { + $event = new class ('fooId', 'fooName', 'barId', 'barName') { + public function __construct( + #[DataSubjectId(name: 'foo')] + #[NormalizedName('_fooId')] + public string $fooId, + #[SensitiveData('fallback', subjectIdName: 'foo')] + #[NormalizedName('_fooName')] + public string $fooName, + #[DataSubjectId(name: 'bar')] + #[NormalizedName('_barId')] + public string $barId, + #[SensitiveData('fallback', subjectIdName: 'bar')] + #[NormalizedName('_barName')] + public string $barName, + ) { + } + }; + + $metadata = $this->metadata($event::class); + + self::assertArrayHasKey(SubjectIdFieldMapping::class, $metadata->extras); + $subjectIdFieldMapping = $metadata->extras[SubjectIdFieldMapping::class]; + self::assertInstanceOf(SubjectIdFieldMapping::class, $subjectIdFieldMapping); + self::assertEquals(['foo' => '_fooId', 'bar' => '_barId'], $subjectIdFieldMapping->nameToField); + + $property = $metadata->propertyForField('_fooName'); + + self::assertArrayHasKey(SensitiveDataInfo::class, $property->extras); + self::assertEquals(new SensitiveDataInfo('foo', 'fallback'), $property->extras[SensitiveDataInfo::class]); + + $property = $metadata->propertyForField('_barName'); + + self::assertArrayHasKey(SensitiveDataInfo::class, $property->extras); + self::assertEquals(new SensitiveDataInfo('bar', 'fallback'), $property->extras[SensitiveDataInfo::class]); + } + + public function testDuplicateSubjectIdIdentifiers(): void + { + $event = new class ('fooId', 'fooName', 'barId', 'barName') { + public function __construct( + #[DataSubjectId(name: 'foo')] + #[NormalizedName('_fooId')] + public string $fooId, + #[SensitiveData('fallback', subjectIdName: 'foo')] + #[NormalizedName('_fooName')] + public string $fooName, + #[DataSubjectId(name: 'foo')] + #[NormalizedName('_barId')] + public string $barId, + #[SensitiveData('fallback', subjectIdName: 'foo')] + #[NormalizedName('_barName')] + public string $barName, + ) { + } + }; + + $this->expectException(DuplicateSubjectIdIdentifier::class); + $this->expectExceptionMessageMatches('/Duplicate subject id identifier found\. Used foo for .*::fooId and .*::barId\./'); + + $this->metadata($event::class); + } + + public function testExtendsWithSensitiveData(): void + { + $metadata = $this->metadata(ParentWithSensitiveDataDto::class); + + self::assertCount(2, $metadata->properties); + + self::assertArrayHasKey(SubjectIdFieldMapping::class, $metadata->extras); + $subjectIdFieldMapping = $metadata->extras[SubjectIdFieldMapping::class]; + self::assertInstanceOf(SubjectIdFieldMapping::class, $subjectIdFieldMapping); + self::assertEquals(['default' => 'profileId'], $subjectIdFieldMapping->nameToField); + + $property = $metadata->propertyForField('email'); + + self::assertArrayHasKey(SensitiveDataInfo::class, $property->extras); + self::assertEquals(new SensitiveDataInfo('default', null), $property->extras[SensitiveDataInfo::class]); + } + + public function testExtendsWithSensitiveDataWithName(): void + { + $metadata = $this->metadata(ParentWithSensitiveDataWithIdentifierDto::class); + + self::assertCount(2, $metadata->properties); + + self::assertArrayHasKey(SubjectIdFieldMapping::class, $metadata->extras); + $subjectIdFieldMapping = $metadata->extras[SubjectIdFieldMapping::class]; + self::assertInstanceOf(SubjectIdFieldMapping::class, $subjectIdFieldMapping); + self::assertEquals(['profile' => 'profileId'], $subjectIdFieldMapping->nameToField); + + $property = $metadata->propertyForField('email'); + + self::assertArrayHasKey(SensitiveDataInfo::class, $property->extras); + self::assertEquals(new SensitiveDataInfo('profile', null), $property->extras[SensitiveDataInfo::class]); + } + + /** @param class-string $class */ + private function metadata(string $class): ClassMetadata + { + $metadata = (new AttributeMetadataFactory())->metadata($class); + (new CryptographyMetadataEnricher())->enrich($metadata); + + return $metadata; + } +} diff --git a/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php b/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php new file mode 100644 index 00000000..92648311 --- /dev/null +++ b/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php @@ -0,0 +1,253 @@ +expectException(UnsupportedSubjectId::class); + + $middleware = new CryptographyMiddleware( + $this->createMock(Cryptographer::class), + ); + + $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => null, 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); + } + + public function testMissingSubjectId(): void + { + $this->expectException(MissingSubjectIdForField::class); + + $middleware = new CryptographyMiddleware( + $this->createMock(Cryptographer::class), + ); + + $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); + } + + public function testSkipEncrypt(): void + { + $middleware = new CryptographyMiddleware( + $this->createMock(Cryptographer::class), + ); + + $object = new ProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); + + $expected = ['profileId' => 'foo', 'email' => 'info@patchlevel.de']; + + $metadata = $this->metadata(ProfileCreated::class); + + $otherMiddleware = $this->createMock(Middleware::class); + $stack = new Stack([$otherMiddleware]); + + $otherMiddleware + ->expects($this->once()) + ->method('extract') + ->with($metadata, $object, [SubjectIds::class => new SubjectIds()], $stack) + ->willReturn($expected); + + $result = $middleware->extract( + $metadata, + $object, + [], + $stack, + ); + + self::assertSame($expected, $result); + } + + public function testEncrypt(): void + { + $object = new SensitiveDataProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); + + $metadata = $this->metadata(SensitiveDataProfileCreated::class); + + $otherMiddleware = $this->createMock(Middleware::class); + $stack = new Stack([$otherMiddleware]); + + $otherMiddleware + ->expects($this->once()) + ->method('extract') + ->with($metadata, $object, [SubjectIds::class => new SubjectIds(['default' => 'foo'])], $stack) + ->willReturn(['id' => 'foo', 'email' => 'info@patchlevel.de']); + + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->method('encrypt')->willReturn('encrypted'); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->extract( + $metadata, + $object, + [], + $stack, + ); + + self::assertEquals(['id' => 'foo', 'email' => 'encrypted'], $result); + } + + public function testSkipDecrypt(): void + { + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->expects($this->never())->method('decrypt'); + + $data = ['profileId' => 'foo', 'email' => 'info@patchlevel.de']; + + $expected = new ProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); + + $metadata = $this->metadata(ProfileCreated::class); + + $otherMiddleware = $this->createMock(Middleware::class); + $stack = new Stack([$otherMiddleware]); + + $otherMiddleware + ->expects($this->once()) + ->method('hydrate') + ->with($metadata, $data, [SubjectIds::class => new SubjectIds()], $stack) + ->willReturn($expected); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->hydrate( + $metadata, + $data, + [], + $stack, + ); + + self::assertSame($expected, $result); + } + + public function testDecryptWithCipherKeyNotExists(): void + { + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->method('supports')->willReturn(true); + $cryptographer->method('decrypt')->willThrowException(CipherKeyNotExists::forSubjectId('foo')); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => 'foo', 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); + + self::assertInstanceOf(SensitiveDataProfileCreated::class, $result); + self::assertEquals(ProfileId::fromString('foo'), $result->profileId); + self::assertEquals(new Email('unknown'), $result->email); + } + + public function testDecryptWithDecryptionFailed(): void + { + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->method('supports')->willReturn(true); + $cryptographer->method('decrypt')->willThrowException(DecryptionFailed::forMethod('aes-256-gcm')); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => 'foo', 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); + + self::assertInstanceOf(SensitiveDataProfileCreated::class, $result); + self::assertEquals(ProfileId::fromString('foo'), $result->profileId); + self::assertEquals(new Email('unknown'), $result->email); + } + + public function testDecryptWithFallbackCallback(): void + { + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->method('supports')->willReturn(true); + $cryptographer->method('decrypt')->willThrowException(DecryptionFailed::forMethod('aes-256-gcm')); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreatedFallbackCallback::class), + ['id' => 'foo', 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); + + self::assertInstanceOf(SensitiveDataProfileCreatedFallbackCallback::class, $result); + self::assertEquals(ProfileId::fromString('foo'), $result->profileId); + self::assertEquals(new Email('foo@example.com'), $result->email); + } + + public function testDecrypt(): void + { + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->method('supports')->willReturn(true); + $cryptographer->method('decrypt')->willReturn('info@patchlevel.de'); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => 'foo', 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); + + self::assertInstanceOf(SensitiveDataProfileCreated::class, $result); + self::assertEquals(ProfileId::fromString('foo'), $result->profileId); + self::assertEquals(Email::fromString('info@patchlevel.de'), $result->email); + } + + /** @param class-string $class */ + private function metadata(string $class): ClassMetadata + { + $metadata = (new AttributeMetadataFactory())->metadata($class); + (new CryptographyMetadataEnricher())->enrich($metadata); + + return $metadata; + } +} diff --git a/tests/Unit/Extension/Cryptography/Fixture/ChildWithSensitiveDataDto.php b/tests/Unit/Extension/Cryptography/Fixture/ChildWithSensitiveDataDto.php new file mode 100644 index 00000000..fd45d65e --- /dev/null +++ b/tests/Unit/Extension/Cryptography/Fixture/ChildWithSensitiveDataDto.php @@ -0,0 +1,19 @@ +getMessage()); + } +} diff --git a/tests/Unit/Extension/Cryptography/Store/CipherKeyNotExistsTest.php b/tests/Unit/Extension/Cryptography/Store/CipherKeyNotExistsTest.php new file mode 100644 index 00000000..80f00e80 --- /dev/null +++ b/tests/Unit/Extension/Cryptography/Store/CipherKeyNotExistsTest.php @@ -0,0 +1,20 @@ +getMessage()); + } +} diff --git a/tests/Unit/Extension/Cryptography/Store/InMemoryCipherKeyStoreTest.php b/tests/Unit/Extension/Cryptography/Store/InMemoryCipherKeyStoreTest.php new file mode 100644 index 00000000..99dcb441 --- /dev/null +++ b/tests/Unit/Extension/Cryptography/Store/InMemoryCipherKeyStoreTest.php @@ -0,0 +1,84 @@ +store('foo', $key); + + self::assertSame($key, $store->get('foo')); + } + + public function testLoadFailed(): void + { + $this->expectException(CipherKeyNotExists::class); + + $store = new InMemoryCipherKeyStore(); + $store->get('foo'); + } + + public function testRemove(): void + { + $key = new CipherKey( + 'foo', + 'bar', + 'baz', + 'aes-256-gcm', + new DateTimeImmutable(), + ); + + $store = new InMemoryCipherKeyStore(); + $store->store('foo', $key); + + self::assertSame($key, $store->get('foo')); + + $store->remove('foo'); + + $this->expectException(CipherKeyNotExists::class); + + $store->get('foo'); + } + + public function testClear(): void + { + $key = new CipherKey( + 'foo', + 'bar', + 'baz', + 'aes-256-gcm', + new DateTimeImmutable(), + ); + + $store = new InMemoryCipherKeyStore(); + $store->store('foo', $key); + + self::assertSame($key, $store->get('foo')); + + $store->clear(); + + $this->expectException(CipherKeyNotExists::class); + + $store->get('foo'); + } +} diff --git a/tests/Unit/Extension/Cryptography/SubjectIdsTest.php b/tests/Unit/Extension/Cryptography/SubjectIdsTest.php new file mode 100644 index 00000000..465ed6c6 --- /dev/null +++ b/tests/Unit/Extension/Cryptography/SubjectIdsTest.php @@ -0,0 +1,59 @@ + 'bar']); + + self::assertSame(['foo' => 'bar'], $subjectIds->subjectIds); + } + + public function testGet(): void + { + $subjectIds = new SubjectIds(['foo' => 'bar']); + + self::assertSame('bar', $subjectIds->get('foo')); + } + + public function testGetMissing(): void + { + $this->expectException(MissingSubjectId::class); + $this->expectExceptionMessage('Missing subject id foo.'); + + $subjectIds = new SubjectIds(); + $subjectIds->get('foo'); + } + + public function testMerge(): void + { + $subjectIds1 = new SubjectIds(['foo' => 'bar']); + $subjectIds2 = new SubjectIds(['baz' => 'qux']); + + $merged = $subjectIds1->merge($subjectIds2); + + self::assertSame(['foo' => 'bar', 'baz' => 'qux'], $merged->subjectIds); + self::assertNotSame($subjectIds1, $merged); + self::assertNotSame($subjectIds2, $merged); + } + + public function testMergeOverwrite(): void + { + $subjectIds1 = new SubjectIds(['foo' => 'bar']); + $subjectIds2 = new SubjectIds(['foo' => 'baz']); + + $merged = $subjectIds1->merge($subjectIds2); + + self::assertSame(['foo' => 'baz'], $merged->subjectIds); + } +} diff --git a/tests/Unit/Extension/Cryptography/UnsupportedSubjectIdTest.php b/tests/Unit/Extension/Cryptography/UnsupportedSubjectIdTest.php new file mode 100644 index 00000000..42feaf9e --- /dev/null +++ b/tests/Unit/Extension/Cryptography/UnsupportedSubjectIdTest.php @@ -0,0 +1,20 @@ +getMessage()); + } +} diff --git a/tests/Unit/Extension/Lifecycle/Fixture/LifecycleFixture.php b/tests/Unit/Extension/Lifecycle/Fixture/LifecycleFixture.php new file mode 100644 index 00000000..a417aad3 --- /dev/null +++ b/tests/Unit/Extension/Lifecycle/Fixture/LifecycleFixture.php @@ -0,0 +1,76 @@ + $data + * @param array $context + * + * @return array + */ + #[PreHydrate] + public static function preHydrate(array $data, array $context): array + { + $name = $data['name'] ?? ''; + assert(is_string($name)); + + $data['name'] = $name . ' [preHydrate]'; + + return $data; + } + + /** @param array $context */ + #[PostHydrate] + public static function postHydrate(object $object, array $context): void + { + if (!($object instanceof self)) { + return; + } + + $object->name .= ' [postHydrate]'; + } + + /** @param array $context */ + #[PreExtract] + public static function preExtract(object $object, array $context): void + { + if (!($object instanceof self)) { + return; + } + + $object->name .= ' [preExtract]'; + } + + /** + * @param array $data + * @param array $context + * + * @return array + */ + #[PostExtract] + public static function postExtract(array $data, array $context): array + { + $name = $data['name'] ?? ''; + assert(is_string($name)); + + $data['name'] = $name . ' [postExtract]'; + + return $data; + } +} diff --git a/tests/Unit/Extension/Lifecycle/LifecycleExtensionTest.php b/tests/Unit/Extension/Lifecycle/LifecycleExtensionTest.php new file mode 100644 index 00000000..7b936b3c --- /dev/null +++ b/tests/Unit/Extension/Lifecycle/LifecycleExtensionTest.php @@ -0,0 +1,33 @@ +useExtension(new CoreExtension()) + ->useExtension(new LifecycleExtension()) + ->build(); + + $data = ['name' => 'foo']; + $object = $hydrator->hydrate(LifecycleFixture::class, $data); + + self::assertSame('foo [preHydrate] [postHydrate]', $object->name); + + $extractedData = $hydrator->extract($object); + + self::assertSame('foo [preHydrate] [postHydrate] [preExtract] [postExtract]', $extractedData['name']); + } +} diff --git a/tests/Unit/Extension/Lifecycle/LifecycleMetadataEnricherTest.php b/tests/Unit/Extension/Lifecycle/LifecycleMetadataEnricherTest.php new file mode 100644 index 00000000..28debc94 --- /dev/null +++ b/tests/Unit/Extension/Lifecycle/LifecycleMetadataEnricherTest.php @@ -0,0 +1,107 @@ +metadata(LifecycleFixture::class); + + self::assertArrayHasKey(Lifecycle::class, $metadata->extras); + $lifecycle = $metadata->extras[Lifecycle::class]; + + self::assertInstanceOf(Lifecycle::class, $lifecycle); + self::assertSame('preHydrate', $lifecycle->preHydrate); + self::assertSame('postHydrate', $lifecycle->postHydrate); + self::assertSame('preExtract', $lifecycle->preExtract); + self::assertSame('postExtract', $lifecycle->postExtract); + } + + public function testNoLifecycleAttributes(): void + { + $object = new class { + }; + + $metadata = $this->metadata($object::class); + + self::assertArrayNotHasKey(Lifecycle::class, $metadata->extras); + } + + public function testNonStaticPreHydrate(): void + { + $object = new class { + #[PreHydrate] + public function preHydrate(): void + { + } + }; + + $this->expectException(LogicException::class); + $this->metadata($object::class); + } + + public function testNonStaticPostHydrate(): void + { + $object = new class { + #[PostHydrate] + public function postHydrate(): void + { + } + }; + + $this->expectException(LogicException::class); + $this->metadata($object::class); + } + + public function testNonStaticPreExtract(): void + { + $object = new class { + #[PreExtract] + public function preExtract(): void + { + } + }; + + $this->expectException(LogicException::class); + $this->metadata($object::class); + } + + public function testNonStaticPostExtract(): void + { + $object = new class { + #[PostExtract] + public function postExtract(): void + { + } + }; + + $this->expectException(LogicException::class); + $this->metadata($object::class); + } + + /** @param class-string $class */ + private function metadata(string $class): ClassMetadata + { + $metadata = (new AttributeMetadataFactory())->metadata($class); + (new LifecycleMetadataEnricher())->enrich($metadata); + + return $metadata; + } +} diff --git a/tests/Unit/Extension/Lifecycle/LifecycleMiddlewareTest.php b/tests/Unit/Extension/Lifecycle/LifecycleMiddlewareTest.php new file mode 100644 index 00000000..dd1de1e0 --- /dev/null +++ b/tests/Unit/Extension/Lifecycle/LifecycleMiddlewareTest.php @@ -0,0 +1,130 @@ +metadata(LifecycleFixture::class); + $metadata->extras[Lifecycle::class] = new Lifecycle( + preHydrate: 'preHydrate', + postHydrate: 'postHydrate', + ); + + $innerMiddleware = new class implements Middleware { + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + $name = $data['name'] ?? ''; + assert(is_string($name)); + + $object = new LifecycleFixture($name); + + assert($object instanceof $metadata->className); + + return $object; + } + + /** + * @param array $context + * + * @return array + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + return []; + } + }; + + $stack = new Stack([$innerMiddleware]); + + $object = $middleware->hydrate($metadata, ['name' => 'foo'], [], $stack); + + self::assertInstanceOf(LifecycleFixture::class, $object); + self::assertSame('foo [preHydrate] [postHydrate]', $object->name); + } + + public function testExtract(): void + { + $middleware = new LifecycleMiddleware(); + $metadata = $this->metadata(LifecycleFixture::class); + $metadata->extras[Lifecycle::class] = new Lifecycle( + preExtract: 'preExtract', + postExtract: 'postExtract', + ); + + $innerMiddleware = new class implements Middleware { + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + $object = new stdClass(); + + assert($object instanceof $metadata->className); + + return $object; + } + + /** + * @param array $context + * + * @return array + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + if ($object instanceof LifecycleFixture) { + return ['name' => $object->name]; + } + + return []; + } + }; + + $stack = new Stack([$innerMiddleware]); + $object = new LifecycleFixture('foo'); + + $data = $middleware->extract($metadata, $object, [], $stack); + + self::assertSame('foo [preExtract] [postExtract]', $data['name']); + } + + /** @param class-string $class */ + private function metadata(string $class): ClassMetadata + { + return (new AttributeMetadataFactory())->metadata($class); + } +} diff --git a/tests/Unit/Fixture/ContextAwareDto.php b/tests/Unit/Fixture/ContextAwareDto.php new file mode 100644 index 00000000..1aae379a --- /dev/null +++ b/tests/Unit/Fixture/ContextAwareDto.php @@ -0,0 +1,14 @@ + $context */ + public function normalize(mixed $value, array $context = []): mixed + { + if (!is_string($value)) { + throw InvalidArgument::withWrongType('string', $value); + } + + $prefix = isset($context['prefix']) && is_string($context['prefix']) + ? $context['prefix'] + : ''; + + return $prefix . $value; + } + + /** @param array $context */ + public function denormalize(mixed $value, array $context = []): mixed + { + if (!is_string($value)) { + throw InvalidArgument::withWrongType('string', $value); + } + + $suffix = isset($context['suffix']) && is_string($context['suffix']) + ? $context['suffix'] + : ''; + + return $value . $suffix; + } +} diff --git a/tests/Unit/Metadata/EnrichingMetadataFactoryTest.php b/tests/Unit/Metadata/EnrichingMetadataFactoryTest.php new file mode 100644 index 00000000..d82a4e89 --- /dev/null +++ b/tests/Unit/Metadata/EnrichingMetadataFactoryTest.php @@ -0,0 +1,48 @@ +createMock(MetadataFactory::class); + $innerFactory->expects(self::once()) + ->method('metadata') + ->with(stdClass::class) + ->willReturn($classMetadata); + + $enricher1 = $this->createMock(MetadataEnricher::class); + $enricher1->expects(self::once()) + ->method('enrich') + ->with($classMetadata); + + $enricher2 = $this->createMock(MetadataEnricher::class); + $enricher2->expects(self::once()) + ->method('enrich') + ->with($classMetadata); + + $factory = new EnrichingMetadataFactory( + $innerFactory, + [$enricher1, $enricher2], + ); + + $result = $factory->metadata(stdClass::class); + + self::assertSame($classMetadata, $result); + } +} diff --git a/tests/Unit/Metadata/Psr16MetadataFactoryTest.php b/tests/Unit/Metadata/Psr16MetadataFactoryTest.php new file mode 100644 index 00000000..6f356bc8 --- /dev/null +++ b/tests/Unit/Metadata/Psr16MetadataFactoryTest.php @@ -0,0 +1,63 @@ +createMock(CacheInterface::class); + $cache->expects(self::once()) + ->method('get') + ->with(stdClass::class) + ->willReturn($classMetadata); + + $innerFactory = $this->createMock(MetadataFactory::class); + $innerFactory->expects(self::never()) + ->method('metadata'); + + $factory = new Psr16MetadataFactory($innerFactory, $cache); + $result = $factory->metadata(stdClass::class); + + self::assertSame($classMetadata, $result); + } + + public function testMetadataWithMiss(): void + { + $classMetadata = new ClassMetadata(new ReflectionClass(stdClass::class)); + + $cache = $this->createMock(CacheInterface::class); + $cache->expects(self::once()) + ->method('get') + ->with(stdClass::class) + ->willReturn(null); + $cache->expects(self::once()) + ->method('set') + ->with(stdClass::class, $classMetadata); + + $innerFactory = $this->createMock(MetadataFactory::class); + $innerFactory->expects(self::once()) + ->method('metadata') + ->with(stdClass::class) + ->willReturn($classMetadata); + + $factory = new Psr16MetadataFactory($innerFactory, $cache); + $result = $factory->metadata(stdClass::class); + + self::assertSame($classMetadata, $result); + } +} diff --git a/tests/Unit/Metadata/Psr6MetadataFactoryTest.php b/tests/Unit/Metadata/Psr6MetadataFactoryTest.php new file mode 100644 index 00000000..0d6d5f24 --- /dev/null +++ b/tests/Unit/Metadata/Psr6MetadataFactoryTest.php @@ -0,0 +1,80 @@ +createMock(CacheItemInterface::class); + $item->expects(self::once()) + ->method('isHit') + ->willReturn(true); + $item->expects(self::once()) + ->method('get') + ->willReturn($classMetadata); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache->expects(self::once()) + ->method('getItem') + ->with(stdClass::class) + ->willReturn($item); + + $innerFactory = $this->createMock(MetadataFactory::class); + $innerFactory->expects(self::never()) + ->method('metadata'); + + $factory = new Psr6MetadataFactory($innerFactory, $cache); + $result = $factory->metadata(stdClass::class); + + self::assertSame($classMetadata, $result); + } + + public function testMetadataWithMiss(): void + { + $classMetadata = new ClassMetadata(new ReflectionClass(stdClass::class)); + + $item = $this->createMock(CacheItemInterface::class); + $item->expects(self::once()) + ->method('isHit') + ->willReturn(false); + $item->expects(self::once()) + ->method('set') + ->with($classMetadata); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache->expects(self::once()) + ->method('getItem') + ->with(stdClass::class) + ->willReturn($item); + $cache->expects(self::once()) + ->method('save') + ->with($item); + + $innerFactory = $this->createMock(MetadataFactory::class); + $innerFactory->expects(self::once()) + ->method('metadata') + ->with(stdClass::class) + ->willReturn($classMetadata); + + $factory = new Psr6MetadataFactory($innerFactory, $cache); + $result = $factory->metadata(stdClass::class); + + self::assertSame($classMetadata, $result); + } +} diff --git a/tests/Unit/MetadataHydratorTest.php b/tests/Unit/MetadataHydratorTest.php index 0a2ce9d5..0054bb7d 100644 --- a/tests/Unit/MetadataHydratorTest.php +++ b/tests/Unit/MetadataHydratorTest.php @@ -22,6 +22,7 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto; +use Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\DtoWithHooks; use Patchlevel\Hydrator\Tests\Unit\Fixture\Email; @@ -100,6 +101,15 @@ public function testExtractWithHydratorAwareNormalizer(): void ); } + public function testExtractPassesContextToNormalizer(): void + { + $dto = new ContextAwareDto('value'); + + $data = $this->hydrator->extract($dto, ['prefix' => 'ctx-']); + + self::assertSame(['value' => 'ctx-value'], $data); + } + public function testExtractCircularReference(): void { $this->expectException(CircularReference::class); @@ -187,6 +197,17 @@ public function testHydrate(): void self::assertEquals($expected, $event); } + public function testHydratePassesContextToNormalizer(): void + { + $event = $this->hydrator->hydrate( + ContextAwareDto::class, + ['value' => 'value'], + ['suffix' => '-ctx'], + ); + + self::assertSame('value-ctx', $event->value); + } + public function testHydrateUnknownClass(): void { $this->expectException(ClassNotSupported::class); diff --git a/tests/Unit/Middleware/StackTest.php b/tests/Unit/Middleware/StackTest.php new file mode 100644 index 00000000..31f30b23 --- /dev/null +++ b/tests/Unit/Middleware/StackTest.php @@ -0,0 +1,34 @@ +expectException(NoMoreMiddleware::class); + + $stack = new Stack([]); + $stack->next(); + } + + public function testStack(): void + { + $middleware1 = $this->createStub(Middleware::class); + $middleware2 = $this->createStub(Middleware::class); + + $stack = new Stack([$middleware1, $middleware2]); + + self::assertSame($middleware1, $stack->next()); + self::assertSame($middleware2, $stack->next()); + } +} diff --git a/tests/Unit/Middleware/TransformerMiddlewareTest.php b/tests/Unit/Middleware/TransformerMiddlewareTest.php new file mode 100644 index 00000000..b8c4c4ad --- /dev/null +++ b/tests/Unit/Middleware/TransformerMiddlewareTest.php @@ -0,0 +1,70 @@ +hydrate( + $this->classMetadata(ProfileCreated::class), + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + [], + new Stack([]), + ); + + self::assertEquals($expected, $event); + } + + public function testExtract(): void + { + $middleware = new TransformMiddleware(); + + $expected = ['profileId' => '1', 'email' => 'info@patchlevel.de']; + + $data = $middleware->extract( + $this->classMetadata(ProfileCreated::class), + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ), + [], + new Stack([]), + ); + + self::assertEquals($expected, $data); + } + + /** + * @param class-string $class + * + * @return ClassMetadata + * + * @template T of object + */ + private function classMetadata(string $class): ClassMetadata + { + return (new AttributeMetadataFactory()) + ->metadata($class); + } +} diff --git a/tests/Unit/Normalizer/ArrayNormalizerTest.php b/tests/Unit/Normalizer/ArrayNormalizerTest.php index fdc8c714..a617bfe3 100644 --- a/tests/Unit/Normalizer/ArrayNormalizerTest.php +++ b/tests/Unit/Normalizer/ArrayNormalizerTest.php @@ -10,6 +10,7 @@ use Patchlevel\Hydrator\Normalizer\HydratorAwareNormalizer; use Patchlevel\Hydrator\Normalizer\InvalidArgument; use Patchlevel\Hydrator\Normalizer\Normalizer; +use Patchlevel\Hydrator\Normalizer\NormalizerWithContext; use PHPUnit\Framework\TestCase; #[Attribute(Attribute::TARGET_PROPERTY)] @@ -89,6 +90,68 @@ public function denormalize(mixed $value): int $this->assertEquals([1, 2], $normalizer->denormalize(['1', '2'])); } + public function testNormalizePassesContextToInnerNormalizer(): void + { + $context = ['key' => 'value']; + + $innerNormalizer = new class implements NormalizerWithContext { + /** @var array> */ + public array $contexts = []; + + /** @param array $context */ + public function normalize(mixed $value, array $context = []): mixed + { + $this->contexts[] = $context; + + return $value; + } + + /** @param array $context */ + public function denormalize(mixed $value, array $context = []): mixed + { + $this->contexts[] = $context; + + return $value; + } + }; + + $normalizer = new ArrayNormalizer($innerNormalizer); + $normalizer->normalize(['a', 'b'], $context); + + $this->assertSame([$context, $context], $innerNormalizer->contexts); + } + + public function testDenormalizePassesContextToInnerNormalizer(): void + { + $context = ['key' => 'value']; + + $innerNormalizer = new class implements NormalizerWithContext { + /** @var array> */ + public array $contexts = []; + + /** @param array $context */ + public function normalize(mixed $value, array $context = []): mixed + { + $this->contexts[] = $context; + + return $value; + } + + /** @param array $context */ + public function denormalize(mixed $value, array $context = []): mixed + { + $this->contexts[] = $context; + + return $value; + } + }; + + $normalizer = new ArrayNormalizer($innerNormalizer); + $normalizer->denormalize(['a', 'b'], $context); + + $this->assertSame([$context, $context], $innerNormalizer->contexts); + } + public function testPassHydrator(): void { $hydrator = $this->createMock(Hydrator::class); diff --git a/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php b/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php index 45fdfcc0..fc2369b3 100644 --- a/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php +++ b/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php @@ -10,6 +10,7 @@ use Patchlevel\Hydrator\Normalizer\HydratorAwareNormalizer; use Patchlevel\Hydrator\Normalizer\InvalidArgument; use Patchlevel\Hydrator\Normalizer\Normalizer; +use Patchlevel\Hydrator\Normalizer\NormalizerWithContext; use PHPUnit\Framework\TestCase; #[Attribute(Attribute::TARGET_PROPERTY)] @@ -89,6 +90,68 @@ public function denormalize(mixed $value): int $this->assertEquals(['foo' => 1], $normalizer->denormalize(['foo' => '1'])); } + public function testNormalizePassesContextToInnerNormalizer(): void + { + $context = ['key' => 'value']; + + $innerNormalizer = new class implements NormalizerWithContext { + /** @var array> */ + public array $contexts = []; + + /** @param array $context */ + public function normalize(mixed $value, array $context = []): mixed + { + $this->contexts[] = $context; + + return $value; + } + + /** @param array $context */ + public function denormalize(mixed $value, array $context = []): mixed + { + $this->contexts[] = $context; + + return $value; + } + }; + + $normalizer = new ArrayShapeNormalizer(['foo' => $innerNormalizer, 'bar' => $innerNormalizer]); + $normalizer->normalize(['foo' => 'a', 'bar' => 'b'], $context); + + $this->assertSame([$context, $context], $innerNormalizer->contexts); + } + + public function testDenormalizePassesContextToInnerNormalizer(): void + { + $context = ['key' => 'value']; + + $innerNormalizer = new class implements NormalizerWithContext { + /** @var array> */ + public array $contexts = []; + + /** @param array $context */ + public function normalize(mixed $value, array $context = []): mixed + { + $this->contexts[] = $context; + + return $value; + } + + /** @param array $context */ + public function denormalize(mixed $value, array $context = []): mixed + { + $this->contexts[] = $context; + + return $value; + } + }; + + $normalizer = new ArrayShapeNormalizer(['foo' => $innerNormalizer, 'bar' => $innerNormalizer]); + $normalizer->denormalize(['foo' => 'a', 'bar' => 'b'], $context); + + $this->assertSame([$context, $context], $innerNormalizer->contexts); + } + public function testPassHydrator(): void { $hydrator = $this->createMock(Hydrator::class); diff --git a/tests/Unit/Normalizer/ObjectMapNormalizerTest.php b/tests/Unit/Normalizer/ObjectMapNormalizerTest.php index 1b8b0549..237e418f 100644 --- a/tests/Unit/Normalizer/ObjectMapNormalizerTest.php +++ b/tests/Unit/Normalizer/ObjectMapNormalizerTest.php @@ -5,6 +5,7 @@ namespace Patchlevel\Hydrator\Tests\Unit\Normalizer; use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\HydratorWithContext; use Patchlevel\Hydrator\Normalizer\InvalidArgument; use Patchlevel\Hydrator\Normalizer\MissingHydrator; use Patchlevel\Hydrator\Normalizer\ObjectMapNormalizer; @@ -128,6 +129,56 @@ public function testDenormalizeWithValue(): void ); } + public function testNormalizePassesContextToHydrator(): void + { + $context = ['key' => 'value']; + $hydrator = $this->createMock(HydratorWithContext::class); + + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $hydrator + ->expects($this->once()) + ->method('extract') + ->with($event, $context) + ->willReturn(['profileId' => '1', 'email' => 'info@patchlevel.de']); + + $normalizer = new ObjectMapNormalizer([ProfileCreated::class => 'created']); + $normalizer->setHydrator($hydrator); + + self::assertEquals( + ['profileId' => '1', 'email' => 'info@patchlevel.de', '_type' => 'created'], + $normalizer->normalize($event, $context), + ); + } + + public function testDenormalizePassesContextToHydrator(): void + { + $context = ['key' => 'value']; + $hydrator = $this->createMock(HydratorWithContext::class); + + $expected = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $hydrator + ->expects($this->once()) + ->method('hydrate') + ->with(ProfileCreated::class, ['profileId' => '1', 'email' => 'info@patchlevel.de'], $context) + ->willReturn($expected); + + $normalizer = new ObjectMapNormalizer([ProfileCreated::class => 'created']); + $normalizer->setHydrator($hydrator); + + $this->assertEquals( + $expected, + $normalizer->denormalize(['profileId' => '1', 'email' => 'info@patchlevel.de', '_type' => 'created'], $context), + ); + } + public function testSerialize(): void { $hydrator = $this->createStub(Hydrator::class); diff --git a/tests/Unit/Normalizer/ObjectNormalizerTest.php b/tests/Unit/Normalizer/ObjectNormalizerTest.php index 3b894e13..2f53a153 100644 --- a/tests/Unit/Normalizer/ObjectNormalizerTest.php +++ b/tests/Unit/Normalizer/ObjectNormalizerTest.php @@ -6,6 +6,7 @@ use Attribute; use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\HydratorWithContext; use Patchlevel\Hydrator\Normalizer\InvalidArgument; use Patchlevel\Hydrator\Normalizer\InvalidType; use Patchlevel\Hydrator\Normalizer\MissingHydrator; @@ -133,6 +134,53 @@ public function testDenormalizeWithValue(): void ); } + public function testNormalizePassesContextToHydrator(): void + { + $context = ['key' => 'value']; + $hydrator = $this->createMock(HydratorWithContext::class); + + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $hydrator->expects($this->once())->method('extract')->with($event, $context) + ->willReturn(['profileId' => '1', 'email' => 'info@patchlevel.de']); + + $normalizer = new ObjectNormalizer(ProfileCreated::class); + $normalizer->setHydrator($hydrator); + + self::assertEquals( + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + $normalizer->normalize($event, $context), + ); + } + + public function testDenormalizePassesContextToHydrator(): void + { + $context = ['key' => 'value']; + $hydrator = $this->createMock(HydratorWithContext::class); + + $expected = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $hydrator->expects($this->once())->method('hydrate')->with( + ProfileCreated::class, + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + $context, + )->willReturn($expected); + + $normalizer = new ObjectNormalizer(ProfileCreated::class); + $normalizer->setHydrator($hydrator); + + $this->assertEquals( + $expected, + $normalizer->denormalize(['profileId' => '1', 'email' => 'info@patchlevel.de'], $context), + ); + } + public function testAutoDetect(): void { $hydrator = $this->createMock(Hydrator::class); diff --git a/tests/Unit/StackHydratorBuilderTest.php b/tests/Unit/StackHydratorBuilderTest.php new file mode 100644 index 00000000..774a86dd --- /dev/null +++ b/tests/Unit/StackHydratorBuilderTest.php @@ -0,0 +1,150 @@ +createMock(Middleware::class); + $middleware2 = $this->createMock(Middleware::class); + + $builder = new StackHydratorBuilder(); + $builder->addMiddleware($middleware1, 10); + $builder->addMiddleware($middleware2, 20); + + $hydrator = $builder->build(); + + $reflection = new ReflectionProperty(StackHydrator::class, 'middlewares'); + $middlewares = $reflection->getValue($hydrator); + + self::assertSame([$middleware2, $middleware1], $middlewares); + } + + public function testAddMetadataEnricherWithPriority(): void + { + $enricher1 = $this->createMock(MetadataEnricher::class); + $enricher2 = $this->createMock(MetadataEnricher::class); + + $builder = new StackHydratorBuilder(); + $builder->addMetadataEnricher($enricher1, 10); + $builder->addMetadataEnricher($enricher2, 20); + + $hydrator = $builder->build(); + + $reflection = new ReflectionProperty(StackHydrator::class, 'metadataFactory'); + $enrichingMetadataFactory = $reflection->getValue($hydrator); + + self::assertInstanceOf(EnrichingMetadataFactory::class, $enrichingMetadataFactory); + + $reflection = new ReflectionProperty(EnrichingMetadataFactory::class, 'enrichers'); + $enrichers = $reflection->getValue($enrichingMetadataFactory); + + self::assertSame([$enricher2, $enricher1], $enrichers); + } + + public function testAddGuesserWithPriority(): void + { + $guesser1 = $this->createMock(Guesser::class); + $guesser2 = $this->createMock(Guesser::class); + + $builder = new StackHydratorBuilder(); + $builder->addGuesser($guesser1, 10); + $builder->addGuesser($guesser2, 20); + + $hydrator = $builder->build(); + + $reflection = new ReflectionProperty(StackHydrator::class, 'metadataFactory'); + $enrichingMetadataFactory = $reflection->getValue($hydrator); + + self::assertInstanceOf(EnrichingMetadataFactory::class, $enrichingMetadataFactory); + + $reflection = new ReflectionProperty(EnrichingMetadataFactory::class, 'factory'); + $metadataFactory = $reflection->getValue($enrichingMetadataFactory); + + self::assertInstanceOf(AttributeMetadataFactory::class, $metadataFactory); + + $reflection = new ReflectionProperty(AttributeMetadataFactory::class, 'guesser'); + $guesser = $reflection->getValue($metadataFactory); + + self::assertInstanceOf(ChainGuesser::class, $guesser); + + $reflection = new ReflectionProperty(ChainGuesser::class, 'guessers'); + $guessers = $reflection->getValue($guesser); + + self::assertSame([$guesser2, $guesser1], $guessers); + } + + public function testEnableDefaultLazy(): void + { + $builder = new StackHydratorBuilder(); + $builder->enableDefaultLazy(); + + $hydrator = $builder->build(); + + $reflection = new ReflectionProperty(StackHydrator::class, 'defaultLazy'); + self::assertTrue($reflection->getValue($hydrator)); + } + + public function testUseExtension(): void + { + $extension = $this->createMock(Extension::class); + $builder = new StackHydratorBuilder(); + + $extension->expects(self::once()) + ->method('configure') + ->with($builder); + + $builder->useExtension($extension); + } + + public function testCachePsr6(): void + { + $cache = $this->createMock(CacheItemPoolInterface::class); + + $builder = new StackHydratorBuilder(); + $builder->setCache($cache); + + $hydrator = $builder->build(); + + $reflection = new ReflectionProperty(StackHydrator::class, 'metadataFactory'); + $factory = $reflection->getValue($hydrator); + + self::assertInstanceOf(Psr6MetadataFactory::class, $factory); + } + + public function testCachePsr16(): void + { + $cache = $this->createMock(CacheInterface::class); + + $builder = new StackHydratorBuilder(); + $builder->setCache($cache); + + $hydrator = $builder->build(); + + $reflection = new ReflectionProperty(StackHydrator::class, 'metadataFactory'); + $factory = $reflection->getValue($hydrator); + + self::assertInstanceOf(Psr16MetadataFactory::class, $factory); + } +} diff --git a/tests/Unit/StackHydratorTest.php b/tests/Unit/StackHydratorTest.php new file mode 100644 index 00000000..8754e8ac --- /dev/null +++ b/tests/Unit/StackHydratorTest.php @@ -0,0 +1,545 @@ +hydrator = new StackHydrator(); + } + + public function testExtract(): void + { + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + self::assertEquals( + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + $this->hydrator->extract($event), + ); + } + + public function testExtractWithInheritance(): void + { + $event = new ParentDto( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + self::assertEquals( + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + $this->hydrator->extract($event), + ); + } + + public function testExtractWithHydratorAwareNormalizer(): void + { + $event = new ProfileCreatedWrapper( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ), + ); + + self::assertEquals( + ['event' => ['profileId' => '1', 'email' => 'info@patchlevel.de']], + $this->hydrator->extract($event), + ); + } + + public function testExtractCircularReference(): void + { + $this->expectException(CircularReference::class); + $this->expectExceptionMessage('Circular reference detected: Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto -> Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto -> Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto -> Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto'); + + $dto1 = new Circle1Dto(); + $dto2 = new Circle2Dto(); + $dto3 = new Circle3Dto(); + + $dto1->to = $dto2; + $dto2->to = $dto3; + $dto3->to = $dto1; + + $this->hydrator->extract($dto1); + } + + public function testExtractWithInferNormalizer(): void + { + $result = $this->hydrator->extract( + new InferNormalizerWithNullableDto( + null, + null, + profileId: ProfileId::fromString('1'), + ), + ); + + self::assertEquals( + [ + 'status' => null, + 'dateTimeImmutable' => null, + 'dateTime' => null, + 'dateTimeZone' => null, + 'profileId' => '1', + ], + $result, + ); + } + + public function testExtractWithContext(): void + { + $object = new InferNormalizerDto( + Status::Draft, + new DateTimeImmutable('2015-02-13 22:34:32+01:00'), + new DateTime('2015-02-13 22:34:32+01:00'), + new DateTimeZone('EDT'), + ['foo'], + ); + + $expect = [ + 'status' => 'draft', + 'dateTimeImmutable' => '2015-02-13T22:34:32+01:00', + 'dateTime' => '2015-02-13T22:34:32+01:00', + 'dateTimeZone' => 'EDT', + 'array' => ['foo'], + ]; + + $middleware = $this->createMock(Middleware::class); + $middleware + ->expects($this->once()) + ->method('extract') + ->with( + $this->isInstanceOf(ClassMetadata::class), + $object, + ['context' => '123'], + $this->isInstanceOf(Stack::class), + )->willReturn($expect); + + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->addMiddleware($middleware) + ->build(); + + $data = $hydrator->extract($object, ['context' => '123']); + + self::assertEquals($expect, $data); + } + + #[RequiresPhp('>=8.5')] + public function testExtractWithInlineNormalizer(): void + { + $event = new ProfileCreatedWithInlineNormalizer( + ProfileId::fromString('1'), + ValueObject::fromString('foo'), + ); + + self::assertEquals( + ['profileId' => '1', 'valueObject' => 'foo'], + $this->hydrator->extract($event), + ); + } + + public function testHydrate(): void + { + $expected = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $event = $this->hydrator->hydrate( + ProfileCreated::class, + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ); + + self::assertEquals($expected, $event); + } + + public function testHydrateUnknownClass(): void + { + $this->expectException(ClassNotSupported::class); + $this->expectExceptionCode(0); + + $this->hydrator->hydrate( + 'Unknown', + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ); + } + + public function testHydrateWithDefaults(): void + { + $object = $this->hydrator->hydrate( + DefaultDto::class, + ['name' => 'test'], + ); + + self::assertEquals('test', $object->name); + self::assertEquals(new Email('info@patchlevel.de'), $object->email); + self::assertEquals(true, $object->admin); + } + + public function testHydrateWithInheritance(): void + { + $expected = new ParentDto( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $event = $this->hydrator->hydrate( + ParentDto::class, + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ); + + self::assertEquals($expected, $event); + } + + public function testHydrateWithHydratorAwareNormalizer(): void + { + $expected = new ProfileCreatedWrapper( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ), + ); + + $event = $this->hydrator->hydrate( + ProfileCreatedWrapper::class, + [ + 'event' => ['profileId' => '1', 'email' => 'info@patchlevel.de'], + ], + ); + + self::assertEquals($expected, $event); + } + + public function testHydrateWithTypeMismatch(): void + { + $this->expectException(TypeMismatch::class); + + $this->hydrator->hydrate( + ProfileCreated::class, + ['profileId' => null, 'email' => null], + ); + } + + public function testHydrateWithContext(): void + { + $expect = new InferNormalizerDto( + Status::Draft, + new DateTimeImmutable('2015-02-13 22:34:32+01:00'), + new DateTime('2015-02-13 22:34:32+01:00'), + new DateTimeZone('EDT'), + ['foo'], + ); + + $data = [ + 'status' => 'draft', + 'dateTimeImmutable' => '2015-02-13T22:34:32+01:00', + 'dateTime' => '2015-02-13T22:34:32+01:00', + 'dateTimeZone' => 'EDT', + 'array' => ['foo'], + ]; + + $middleware = $this->createMock(Middleware::class); + $middleware + ->expects($this->once()) + ->method('hydrate') + ->with( + $this->isInstanceOf(ClassMetadata::class), + $data, + ['context' => '123'], + $this->isInstanceOf(Stack::class), + )->willReturn($expect); + + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->addMiddleware($middleware) + ->build(); + + $object = $hydrator->hydrate(InferNormalizerDto::class, $data, ['context' => '123']); + + self::assertEquals($expect, $object); + } + + #[RequiresPhp('>=8.5')] + public function testHydrateWithInlineNormalizer(): void + { + $expected = new ProfileCreatedWithInlineNormalizer( + ProfileId::fromString('1'), + ValueObject::fromString('foo'), + ); + + $event = $this->hydrator->hydrate( + ProfileCreatedWithInlineNormalizer::class, + ['profileId' => '1', 'valueObject' => 'foo'], + ); + + self::assertEquals($expected, $event); + } + + public function testDenormalizationFailure(): void + { + $this->expectException(DenormalizationFailure::class); + + $this->hydrator->hydrate( + ProfileCreated::class, + ['profileId' => 123, 'email' => 123], + ); + } + + public function testNormalizationFailure(): void + { + $this->expectException(NormalizationFailure::class); + + $this->hydrator->extract( + new WrongNormalizer(true), + ); + } + + public function testHydrateWithNormalizerInBaseClass(): void + { + $expected = new NormalizerInBaseClassDefinedDto( + StatusWithNormalizer::Draft, + new ProfileCreatedWithNormalizer( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ), + [StatusWithNormalizer::Draft], + [StatusWithNormalizer::Draft], + [StatusWithNormalizer::Draft], + [ + 'foo' => new Skill('php'), + 'bar' => new Skill('symfony'), + ], + [ + 'foo' => 'php', + 'bar' => 15, + 'baz' => ['test'], + ], + ); + + $event = $this->hydrator->hydrate( + NormalizerInBaseClassDefinedDto::class, + [ + 'status' => 'draft', + 'profileCreated' => ['profileId' => '1', 'email' => 'info@patchlevel.de'], + 'defaultArray' => ['draft'], + 'listArray' => ['draft'], + 'iterableArray' => ['draft'], + 'skillsHashMap' => ['foo' => ['name' => 'php'], 'bar' => ['name' => 'symfony']], + 'jsonArray' => ['foo' => 'php', 'bar' => 15, 'baz' => ['test']], + ], + ); + + self::assertEquals($expected, $event); + } + + public function testHydrateWithInferNormalizer(): void + { + $expected = new InferNormalizerDto( + Status::Draft, + new DateTimeImmutable('2015-02-13 22:34:32+01:00'), + new DateTime('2015-02-13 22:34:32+01:00'), + new DateTimeZone('EDT'), + ['foo'], + ); + + $event = $this->hydrator->hydrate( + InferNormalizerDto::class, + [ + 'status' => 'draft', + 'dateTimeImmutable' => '2015-02-13T22:34:32+01:00', + 'dateTime' => '2015-02-13T22:34:32+01:00', + 'dateTimeZone' => 'EDT', + 'array' => ['foo'], + ], + ); + + self::assertEquals($expected, $event); + } + + public function testHydrateWithInferNormalizerAndNullableProperties(): void + { + $expected = new InferNormalizerWithNullableDto( + null, + null, + null, + null, + ); + + $event = $this->hydrator->hydrate( + InferNormalizerWithNullableDto::class, + [ + 'status' => null, + 'dateTimeImmutable' => null, + 'dateTime' => null, + 'dateTimeZone' => null, + ], + ); + + self::assertEquals($expected, $event); + } + + public function testHydrateWithInferNormalizerWitIterables(): void + { + $expected = new InferNormalizerWithIterablesDto( + [Status::Draft], + [Status::Draft], + [Status::Draft], + [ + 'foo' => Status::Draft, + 'bar' => Status::Draft, + ], + [ + 'foo' => [Status::Draft], + 'bar' => [Status::Draft], + ], + [ + 'foo' => 'php', + 'bar' => 15, + 'baz' => ['test'], + ], + [ + 'status' => Status::Draft, + 'other' => [Status::Draft], + ], + ); + + $event = $this->hydrator->hydrate( + InferNormalizerWithIterablesDto::class, + [ + 'defaultArray' => ['draft'], + 'listArray' => ['draft'], + 'iterableArray' => ['draft'], + 'hashMap' => ['foo' => 'draft', 'bar' => 'draft'], + 'nested' => ['foo' => ['draft'], 'bar' => ['draft']], + 'jsonArray' => ['foo' => 'php', 'bar' => 15, 'baz' => ['test']], + 'shapeArray' => ['status' => 'draft', 'other' => ['draft']], + ], + ); + + self::assertEquals($expected, $event); + } + + #[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'), + ); + + $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 testMetadata(): void + { + $metadata = $this->hydrator->metadata(ProfileCreated::class); + + self::assertSame(ProfileCreated::class, $metadata->className); + + $metadata2 = $this->hydrator->metadata(ProfileCreated::class); + self::assertSame($metadata, $metadata2); + } + + public function testMetadataWithHydratorAwareNormalizer(): void + { + $metadata = $this->hydrator->metadata(ProfileCreatedWrapper::class); + + $propertyMetadata = $metadata->propertyForField('event'); + $normalizer = $propertyMetadata->normalizer; + + self::assertInstanceOf(HydratorAwareNormalizer::class, $normalizer); + + $reflection = new ReflectionProperty($normalizer, 'hydrator'); + self::assertSame($this->hydrator, $reflection->getValue($normalizer)); + } +} diff --git a/tools/composer.lock b/tools/composer.lock index a6c9e00d..1a696591 100644 --- a/tools/composer.lock +++ b/tools/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "azjezz/psl", - "version": "4.2.1", + "version": "4.3.0", "source": { "type": "git", "url": "https://github.com/azjezz/psl.git", - "reference": "28c6752857597a1bb6fa8be16678c144b9097ab8" + "reference": "74c95be0214eb7ea39146ed00ac4eb71b45d787b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/azjezz/psl/zipball/28c6752857597a1bb6fa8be16678c144b9097ab8", - "reference": "28c6752857597a1bb6fa8be16678c144b9097ab8", + "url": "https://api.github.com/repos/azjezz/psl/zipball/74c95be0214eb7ea39146ed00ac4eb71b45d787b", + "reference": "74c95be0214eb7ea39146ed00ac4eb71b45d787b", "shasum": "" }, "require": { @@ -30,7 +30,7 @@ "revolt/event-loop": "^1.0.7" }, "require-dev": { - "carthage-software/mago": "^1.3.0", + "carthage-software/mago": "^1.6.0", "infection/infection": "^0.31.2", "php-coveralls/php-coveralls": "^2.7.0", "phpbench/phpbench": "^1.4.0", @@ -68,7 +68,7 @@ "description": "PHP Standard Library", "support": { "issues": "https://github.com/azjezz/psl/issues", - "source": "https://github.com/azjezz/psl/tree/4.2.1" + "source": "https://github.com/azjezz/psl/tree/4.3.0" }, "funding": [ { @@ -80,7 +80,7 @@ "type": "github" } ], - "time": "2026-01-29T12:38:33+00:00" + "time": "2026-02-24T01:58:53+00:00" }, { "name": "beberlei/assert", @@ -1714,16 +1714,16 @@ }, { "name": "symfony/console", - "version": "v7.4.4", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", "shasum": "" }, "require": { @@ -1788,7 +1788,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.4" + "source": "https://github.com/symfony/console/tree/v7.4.7" }, "funding": [ { @@ -1808,7 +1808,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-03-06T14:06:20+00:00" }, { "name": "symfony/deprecation-contracts", @@ -1879,16 +1879,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.1", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d" + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", "shasum": "" }, "require": { @@ -1925,7 +1925,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.1" + "source": "https://github.com/symfony/filesystem/tree/v8.0.6" }, "funding": [ { @@ -1945,20 +1945,20 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/finder", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0" + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8bd576e97c67d45941365bf824e18dc8538e6eb0", - "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0", + "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", "shasum": "" }, "require": { @@ -1993,7 +1993,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.5" + "source": "https://github.com/symfony/finder/tree/v8.0.6" }, "funding": [ { @@ -2013,7 +2013,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:08:38+00:00" + "time": "2026-01-29T09:41:02+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2828,16 +2828,16 @@ }, { "name": "symfony/string", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "758b372d6882506821ed666032e43020c4f57194" + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", - "reference": "758b372d6882506821ed666032e43020c4f57194", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", "shasum": "" }, "require": { @@ -2894,7 +2894,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.4" + "source": "https://github.com/symfony/string/tree/v8.0.6" }, "funding": [ { @@ -2914,7 +2914,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-09T10:14:57+00:00" } ], "packages-dev": [],