From 0155f30e90248adfd20c496f805c73d9beac8e00 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 02:09:21 +0000 Subject: [PATCH 01/17] Lock file maintenance Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index a79212fb..c2af5984 100644 --- a/composer.lock +++ b/composer.lock @@ -5623,16 +5623,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.3", + "version": "2.1.4", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "6976757ba8dd70bf8cbaea0914ad84d8b51a9f46" + "reference": "b39f1870fc7c3e9e4a26106df5053354b9260a33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/6976757ba8dd70bf8cbaea0914ad84d8b51a9f46", - "reference": "6976757ba8dd70bf8cbaea0914ad84d8b51a9f46", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/b39f1870fc7c3e9e4a26106df5053354b9260a33", + "reference": "b39f1870fc7c3e9e4a26106df5053354b9260a33", "shasum": "" }, "require": { @@ -5679,9 +5679,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.4" }, - "time": "2026-02-13T21:01:40+00:00" + "time": "2026-02-17T12:17:51+00:00" }, { "name": "webmozart/glob", From efc383f716c8b67aae1403054153f83c1bea034f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 02:11:54 +0000 Subject: [PATCH 02/17] Lock file maintenance Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/composer.lock b/composer.lock index c2af5984..99643b5f 100644 --- a/composer.lock +++ b/composer.lock @@ -2592,16 +2592,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 +2674,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 +2698,7 @@ "type": "tidelift" } ], - "time": "2026-02-10T12:28:25+00:00" + "time": "2026-02-18T12:37:06+00:00" }, { "name": "psr/clock", @@ -5623,16 +5623,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.4", + "version": "2.1.5", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "b39f1870fc7c3e9e4a26106df5053354b9260a33" + "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/b39f1870fc7c3e9e4a26106df5053354b9260a33", - "reference": "b39f1870fc7c3e9e4a26106df5053354b9260a33", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/79155f94852fa27e2f73b459f6503f5e87e2c188", + "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188", "shasum": "" }, "require": { @@ -5679,9 +5679,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.4" + "source": "https://github.com/webmozarts/assert/tree/2.1.5" }, - "time": "2026-02-17T12:17:51+00:00" + "time": "2026-02-18T14:09:36+00:00" }, { "name": "webmozart/glob", From e66027fd005ceaca7312da04dc635115e9f5a74c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:45:34 +0000 Subject: [PATCH 03/17] Lock file maintenance Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/composer.lock b/composer.lock index 99643b5f..bd7d875c 100644 --- a/composer.lock +++ b/composer.lock @@ -1177,16 +1177,16 @@ }, { "name": "infection/infection", - "version": "0.32.4", + "version": "0.32.5", "source": { "type": "git", "url": "https://github.com/infection/infection.git", - "reference": "a2b0a3e47b56bd2f27ca13caecae47baa7e5abe8" + "reference": "932fc7aa7a03bdbe387e42f8c8bd17d9d347653e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/infection/zipball/a2b0a3e47b56bd2f27ca13caecae47baa7e5abe8", - "reference": "a2b0a3e47b56bd2f27ca13caecae47baa7e5abe8", + "url": "https://api.github.com/repos/infection/infection/zipball/932fc7aa7a03bdbe387e42f8c8bd17d9d347653e", + "reference": "932fc7aa7a03bdbe387e42f8c8bd17d9d347653e", "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.5" }, "funding": [ { @@ -1309,7 +1309,7 @@ "type": "open_collective" } ], - "time": "2026-02-09T13:24:18+00:00" + "time": "2026-02-20T07:59:31+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", From 0b8df606358f907f6e605d6e35482f8feaebb3a6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:35:06 +0000 Subject: [PATCH 04/17] Lock file maintenance Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index bd7d875c..acf7f08d 100644 --- a/composer.lock +++ b/composer.lock @@ -2136,11 +2136,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 +2185,7 @@ "type": "github" } ], - "time": "2026-02-11T14:48:56+00:00" + "time": "2026-02-23T15:04:35+00:00" }, { "name": "phpstan/phpstan-phpunit", From 306a5151f6670e8519de70c35c5481e8a8ad8426 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:54:01 +0000 Subject: [PATCH 05/17] Lock file maintenance Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- tools/composer.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tools/composer.lock b/tools/composer.lock index a6c9e00d..a5e15be7 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", From d997a05ba2b825d774257c8b944bf24b71847d2a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:41:54 +0000 Subject: [PATCH 06/17] Lock file maintenance Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.lock | 84 ++++++++++++++++++++++----------------------- tools/composer.lock | 48 +++++++++++++------------- 2 files changed, 66 insertions(+), 66 deletions(-) diff --git a/composer.lock b/composer.lock index acf7f08d..4edc03b7 100644 --- a/composer.lock +++ b/composer.lock @@ -372,16 +372,16 @@ }, { "name": "symfony/type-info", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "106a2d3bbf0d4576b2f70e6ca866fa420956ed0d" + "reference": "785992c06d07306f963ded3439036f5da9b292fe" }, "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/785992c06d07306f963ded3439036f5da9b292fe", + "reference": "785992c06d07306f963ded3439036f5da9b292fe", "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.6" }, "funding": [ { @@ -450,7 +450,7 @@ "type": "tidelift" } ], - "time": "2026-01-09T12:15:10+00:00" + "time": "2026-02-20T07:51:53+00:00" } ], "packages-dev": [ @@ -1177,16 +1177,16 @@ }, { "name": "infection/infection", - "version": "0.32.5", + "version": "0.32.6", "source": { "type": "git", "url": "https://github.com/infection/infection.git", - "reference": "932fc7aa7a03bdbe387e42f8c8bd17d9d347653e" + "reference": "4ed769947eaf2ecf42203027301bad2bedf037e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/infection/zipball/932fc7aa7a03bdbe387e42f8c8bd17d9d347653e", - "reference": "932fc7aa7a03bdbe387e42f8c8bd17d9d347653e", + "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.5" + "source": "https://github.com/infection/infection/tree/0.32.6" }, "funding": [ { @@ -1309,7 +1309,7 @@ "type": "open_collective" } ], - "time": "2026-02-20T07:59:31+00:00" + "time": "2026-02-26T14:34:26+00:00" }, { "name": "infection/mutator", @@ -4320,16 +4320,16 @@ }, { "name": "symfony/console", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b" + "reference": "488285876e807a4777f074041d8bb508623419fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ace03c4cf9805080ff40cbeec69fca180c339a3b", - "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b", + "url": "https://api.github.com/repos/symfony/console/zipball/488285876e807a4777f074041d8bb508623419fa", + "reference": "488285876e807a4777f074041d8bb508623419fa", "shasum": "" }, "require": { @@ -4386,7 +4386,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.4" + "source": "https://github.com/symfony/console/tree/v8.0.6" }, "funding": [ { @@ -4406,7 +4406,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4477,16 +4477,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 +4523,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 +4543,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 +4591,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 +4611,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 +5253,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 +5319,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 +5339,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 +5406,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 +5426,7 @@ "type": "tidelift" } ], - "time": "2026-01-01T23:07:29+00:00" + "time": "2026-02-15T10:53:29+00:00" }, { "name": "thecodingmachine/safe", diff --git a/tools/composer.lock b/tools/composer.lock index a5e15be7..89d26dbc 100644 --- a/tools/composer.lock +++ b/tools/composer.lock @@ -1714,16 +1714,16 @@ }, { "name": "symfony/console", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "reference": "6d643a93b47398599124022eb24d97c153c12f27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/symfony/console/zipball/6d643a93b47398599124022eb24d97c153c12f27", + "reference": "6d643a93b47398599124022eb24d97c153c12f27", "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.6" }, "funding": [ { @@ -1808,7 +1808,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-02-25T17:02:47+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": [], From 5c68f0d0061f888831d0ade76815ff36cdc7ece9 Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 27 Feb 2026 16:55:29 +0100 Subject: [PATCH 07/17] backport context feature --- phpstan-baseline.neon | 18 ++++-- src/HydratorWithContext.php | 28 +++++++++ src/MetadataHydrator.php | 46 +++++++++----- src/Normalizer/ArrayNormalizer.php | 38 ++++++++--- src/Normalizer/ArrayShapeNormalizer.php | 30 ++++++--- src/Normalizer/NormalizerWithContext.php | 22 +++++++ src/Normalizer/ObjectMapNormalizer.php | 20 ++++-- src/Normalizer/ObjectNormalizer.php | 22 +++++-- tests/Unit/Fixture/ContextAwareDto.php | 14 +++++ tests/Unit/Fixture/ContextAwareNormalizer.php | 43 +++++++++++++ tests/Unit/MetadataHydratorTest.php | 21 +++++++ tests/Unit/Normalizer/ArrayNormalizerTest.php | 63 +++++++++++++++++++ .../Normalizer/ArrayShapeNormalizerTest.php | 63 +++++++++++++++++++ .../Normalizer/ObjectMapNormalizerTest.php | 51 +++++++++++++++ .../Unit/Normalizer/ObjectNormalizerTest.php | 48 ++++++++++++++ 15 files changed, 483 insertions(+), 44 deletions(-) create mode 100644 src/HydratorWithContext.php create mode 100644 src/Normalizer/NormalizerWithContext.php create mode 100644 tests/Unit/Fixture/ContextAwareDto.php create mode 100644 tests/Unit/Fixture/ContextAwareNormalizer.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e7ecebe2..6e3fe1a1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -72,12 +72,6 @@ parameters: count: 1 path: src/Metadata/ClassMetadata.php - - - message: '#^Dead catch \- Patchlevel\\Hydrator\\CircularReference is never thrown in the try block\.$#' - identifier: catch.neverThrown - count: 1 - path: src/MetadataHydrator.php - - message: '#^Property Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer\:\:\$enum \(class\-string\\|null\) does not accept string\.$#' identifier: assign.propertyType @@ -90,12 +84,24 @@ parameters: count: 1 path: src/Normalizer/ObjectMapNormalizer.php + - + message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\HydratorWithContext\:\:hydrate\(\) expects array\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Normalizer/ObjectMapNormalizer.php + - message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Hydrator\:\:hydrate\(\) expects array\, array\ given\.$#' identifier: argument.type count: 1 path: src/Normalizer/ObjectNormalizer.php + - + message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\HydratorWithContext\:\:hydrate\(\) expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: src/Normalizer/ObjectNormalizer.php + - message: '#^Property Patchlevel\\Hydrator\\Normalizer\\ObjectNormalizer\:\:\$className \(class\-string\|null\) does not accept string\.$#' identifier: assign.propertyType 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/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/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/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/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/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); From e152cfbb8ce280661af77ea5a508226b6ff58e6b Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 27 Feb 2026 18:39:49 +0100 Subject: [PATCH 08/17] backport stack hydrator --- phpstan-baseline.neon | 12 +- src/CoreExtension.php | 17 + src/Extension.php | 10 + src/Metadata/ClassMetadata.php | 76 ++- src/Metadata/EnrichingMetadataFactory.php | 35 ++ src/Metadata/MetadataEnricher.php | 10 + src/Metadata/PropertyMetadata.php | 31 +- src/Middleware/Middleware.php | 32 + src/Middleware/NoMoreMiddleware.php | 16 + src/Middleware/Stack.php | 29 + src/Middleware/TransformMiddleware.php | 151 +++++ src/StackHydrator.php | 113 ++++ src/StackHydratorBuilder.php | 108 ++++ tests/Benchmark/StackHydratorBench.php | 135 +++++ .../Metadata/EnrichingMetadataFactoryTest.php | 48 ++ .../Metadata/Psr16MetadataFactoryTest.php | 63 ++ .../Unit/Metadata/Psr6MetadataFactoryTest.php | 80 +++ tests/Unit/Middleware/StackTest.php | 34 ++ .../Middleware/TransformerMiddlewareTest.php | 70 +++ tests/Unit/StackHydratorBuilderTest.php | 150 +++++ tests/Unit/StackHydratorTest.php | 545 ++++++++++++++++++ 21 files changed, 1737 insertions(+), 28 deletions(-) create mode 100644 src/CoreExtension.php create mode 100644 src/Extension.php create mode 100644 src/Metadata/EnrichingMetadataFactory.php create mode 100644 src/Metadata/MetadataEnricher.php create mode 100644 src/Middleware/Middleware.php create mode 100644 src/Middleware/NoMoreMiddleware.php create mode 100644 src/Middleware/Stack.php create mode 100644 src/Middleware/TransformMiddleware.php create mode 100644 src/StackHydrator.php create mode 100644 src/StackHydratorBuilder.php create mode 100644 tests/Benchmark/StackHydratorBench.php create mode 100644 tests/Unit/Metadata/EnrichingMetadataFactoryTest.php create mode 100644 tests/Unit/Metadata/Psr16MetadataFactoryTest.php create mode 100644 tests/Unit/Metadata/Psr6MetadataFactoryTest.php create mode 100644 tests/Unit/Middleware/StackTest.php create mode 100644 tests/Unit/Middleware/TransformerMiddlewareTest.php create mode 100644 tests/Unit/StackHydratorBuilderTest.php create mode 100644 tests/Unit/StackHydratorTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6e3fe1a1..76fc3b7a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -66,12 +66,6 @@ parameters: count: 3 path: src/Metadata/AttributeMetadataFactory.php - - - message: '#^Property Patchlevel\\Hydrator\\Metadata\\ClassMetadata\\:\:\$reflection \(ReflectionClass\\) does not accept ReflectionClass\\.$#' - identifier: assign.propertyType - count: 1 - path: src/Metadata/ClassMetadata.php - - message: '#^Property Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer\:\:\$enum \(class\-string\\|null\) does not accept string\.$#' identifier: assign.propertyType @@ -185,3 +179,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..81cce0df --- /dev/null +++ b/src/CoreExtension.php @@ -0,0 +1,17 @@ +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..723e3052 --- /dev/null +++ b/src/Extension.php @@ -0,0 +1,10 @@ +, + * 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/Middleware/Middleware.php b/src/Middleware/Middleware.php new file mode 100644 index 00000000..106dab61 --- /dev/null +++ b/src/Middleware/Middleware.php @@ -0,0 +1,32 @@ + $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..8203ab3c --- /dev/null +++ b/src/Middleware/NoMoreMiddleware.php @@ -0,0 +1,16 @@ + $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..c180d17c --- /dev/null +++ b/src/Middleware/TransformMiddleware.php @@ -0,0 +1,151 @@ + */ + 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/StackHydrator.php b/src/StackHydrator.php new file mode 100644 index 00000000..bfe593bb --- /dev/null +++ b/src/StackHydrator.php @@ -0,0 +1,113 @@ + */ + 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..39c5b9b6 --- /dev/null +++ b/src/StackHydratorBuilder.php @@ -0,0 +1,108 @@ +> */ + 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/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/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/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)); + } +} From c53de6e44342ad516a37669937c3fbbfce45e339 Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 27 Feb 2026 20:26:19 +0100 Subject: [PATCH 09/17] backport lifecycle and cryptography extension --- phpstan-baseline.neon | 30 +++ .../Cryptography/Attribute/DataSubjectId.php | 16 ++ .../Cryptography/Attribute/SensitiveData.php | 27 ++ .../Cryptography/BaseCryptographer.php | 97 +++++++ src/Extension/Cryptography/Cipher/Cipher.php | 18 ++ .../Cryptography/Cipher/CipherKey.php | 20 ++ .../Cryptography/Cipher/CipherKeyFactory.php | 11 + .../Cipher/CreateCipherKeyFailed.php | 16 ++ .../Cryptography/Cipher/DecryptionFailed.php | 16 ++ .../Cryptography/Cipher/EncryptionFailed.php | 16 ++ .../Cipher/MethodNotSupported.php | 18 ++ .../Cryptography/Cipher/OpensslCipher.php | 68 +++++ .../Cipher/OpensslCipherKeyFactory.php | 65 +++++ src/Extension/Cryptography/Cryptographer.php | 23 ++ .../Cryptography/CryptographyExtension.php | 22 ++ .../CryptographyMetadataEnricher.php | 77 ++++++ .../Cryptography/CryptographyMiddleware.php | 182 +++++++++++++ .../DuplicateSubjectIdIdentifier.php | 28 ++ .../Cryptography/MissingSubjectId.php | 18 ++ .../Cryptography/MissingSubjectIdForField.php | 19 ++ .../Cryptography/SensitiveDataInfo.php | 14 + .../Cryptography/Store/CipherKeyNotExists.php | 18 ++ .../Cryptography/Store/CipherKeyStore.php | 17 ++ .../Store/InMemoryCipherKeyStore.php | 33 +++ .../SubjectIdAndSensitiveDataConflict.php | 25 ++ .../Cryptography/SubjectIdFieldMapping.php | 14 + src/Extension/Cryptography/SubjectIds.php | 26 ++ .../Cryptography/UnsupportedSubjectId.php | 19 ++ .../Lifecycle/Attribute/PostExtract.php | 12 + .../Lifecycle/Attribute/PostHydrate.php | 12 + .../Lifecycle/Attribute/PreExtract.php | 12 + .../Lifecycle/Attribute/PreHydrate.php | 12 + src/Extension/Lifecycle/Lifecycle.php | 16 ++ .../Lifecycle/LifecycleExtension.php | 17 ++ .../Lifecycle/LifecycleMetadataEnricher.php | 73 +++++ .../Lifecycle/LifecycleMiddleware.php | 70 +++++ .../Cryptography/BaseCryptographerTest.php | 177 ++++++++++++ .../Cipher/CreateCipherKeyFailedTest.php | 20 ++ .../Cipher/DecryptionFailedTest.php | 20 ++ .../Cipher/EncryptionFailedTest.php | 20 ++ .../Cipher/OpensslCipherKeyFactoryTest.php | 34 +++ .../Cryptography/Cipher/OpensslCipherTest.php | 79 ++++++ .../CryptographyMetadataEnricherTest.php | 191 +++++++++++++ .../CryptographyMiddlewareTest.php | 253 ++++++++++++++++++ .../Fixture/ChildWithSensitiveDataDto.php | 19 ++ ...hildWithSensitiveDataWithIdentifierDto.php | 19 ++ .../Fixture/MissingSubjectIdDto.php | 17 ++ .../Fixture/ParentWithSensitiveDataDto.php | 22 ++ ...rentWithSensitiveDataWithIdentifierDto.php | 22 ++ .../Fixture/SensitiveDataProfileCreated.php | 27 ++ ...tiveDataProfileCreatedFallbackCallback.php | 32 +++ .../SensitiveDataWithStringableSubjectId.php | 20 ++ .../Cryptography/MissingSubjectIdTest.php | 20 ++ .../Store/CipherKeyNotExistsTest.php | 20 ++ .../Store/InMemoryCipherKeyStoreTest.php | 77 ++++++ .../Extension/Cryptography/SubjectIdsTest.php | 59 ++++ .../Cryptography/UnsupportedSubjectIdTest.php | 20 ++ .../Lifecycle/Fixture/LifecycleFixture.php | 76 ++++++ .../Lifecycle/LifecycleExtensionTest.php | 33 +++ .../LifecycleMetadataEnricherTest.php | 107 ++++++++ .../Lifecycle/LifecycleMiddlewareTest.php | 130 +++++++++ 61 files changed, 2691 insertions(+) create mode 100644 src/Extension/Cryptography/Attribute/DataSubjectId.php create mode 100644 src/Extension/Cryptography/Attribute/SensitiveData.php create mode 100644 src/Extension/Cryptography/BaseCryptographer.php create mode 100644 src/Extension/Cryptography/Cipher/Cipher.php create mode 100644 src/Extension/Cryptography/Cipher/CipherKey.php create mode 100644 src/Extension/Cryptography/Cipher/CipherKeyFactory.php create mode 100644 src/Extension/Cryptography/Cipher/CreateCipherKeyFailed.php create mode 100644 src/Extension/Cryptography/Cipher/DecryptionFailed.php create mode 100644 src/Extension/Cryptography/Cipher/EncryptionFailed.php create mode 100644 src/Extension/Cryptography/Cipher/MethodNotSupported.php create mode 100644 src/Extension/Cryptography/Cipher/OpensslCipher.php create mode 100644 src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php create mode 100644 src/Extension/Cryptography/Cryptographer.php create mode 100644 src/Extension/Cryptography/CryptographyExtension.php create mode 100644 src/Extension/Cryptography/CryptographyMetadataEnricher.php create mode 100644 src/Extension/Cryptography/CryptographyMiddleware.php create mode 100644 src/Extension/Cryptography/DuplicateSubjectIdIdentifier.php create mode 100644 src/Extension/Cryptography/MissingSubjectId.php create mode 100644 src/Extension/Cryptography/MissingSubjectIdForField.php create mode 100644 src/Extension/Cryptography/SensitiveDataInfo.php create mode 100644 src/Extension/Cryptography/Store/CipherKeyNotExists.php create mode 100644 src/Extension/Cryptography/Store/CipherKeyStore.php create mode 100644 src/Extension/Cryptography/Store/InMemoryCipherKeyStore.php create mode 100644 src/Extension/Cryptography/SubjectIdAndSensitiveDataConflict.php create mode 100644 src/Extension/Cryptography/SubjectIdFieldMapping.php create mode 100644 src/Extension/Cryptography/SubjectIds.php create mode 100644 src/Extension/Cryptography/UnsupportedSubjectId.php create mode 100644 src/Extension/Lifecycle/Attribute/PostExtract.php create mode 100644 src/Extension/Lifecycle/Attribute/PostHydrate.php create mode 100644 src/Extension/Lifecycle/Attribute/PreExtract.php create mode 100644 src/Extension/Lifecycle/Attribute/PreHydrate.php create mode 100644 src/Extension/Lifecycle/Lifecycle.php create mode 100644 src/Extension/Lifecycle/LifecycleExtension.php create mode 100644 src/Extension/Lifecycle/LifecycleMetadataEnricher.php create mode 100644 src/Extension/Lifecycle/LifecycleMiddleware.php create mode 100644 tests/Unit/Extension/Cryptography/BaseCryptographerTest.php create mode 100644 tests/Unit/Extension/Cryptography/Cipher/CreateCipherKeyFailedTest.php create mode 100644 tests/Unit/Extension/Cryptography/Cipher/DecryptionFailedTest.php create mode 100644 tests/Unit/Extension/Cryptography/Cipher/EncryptionFailedTest.php create mode 100644 tests/Unit/Extension/Cryptography/Cipher/OpensslCipherKeyFactoryTest.php create mode 100644 tests/Unit/Extension/Cryptography/Cipher/OpensslCipherTest.php create mode 100644 tests/Unit/Extension/Cryptography/CryptographyMetadataEnricherTest.php create mode 100644 tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php create mode 100644 tests/Unit/Extension/Cryptography/Fixture/ChildWithSensitiveDataDto.php create mode 100644 tests/Unit/Extension/Cryptography/Fixture/ChildWithSensitiveDataWithIdentifierDto.php create mode 100644 tests/Unit/Extension/Cryptography/Fixture/MissingSubjectIdDto.php create mode 100644 tests/Unit/Extension/Cryptography/Fixture/ParentWithSensitiveDataDto.php create mode 100644 tests/Unit/Extension/Cryptography/Fixture/ParentWithSensitiveDataWithIdentifierDto.php create mode 100644 tests/Unit/Extension/Cryptography/Fixture/SensitiveDataProfileCreated.php create mode 100644 tests/Unit/Extension/Cryptography/Fixture/SensitiveDataProfileCreatedFallbackCallback.php create mode 100644 tests/Unit/Extension/Cryptography/Fixture/SensitiveDataWithStringableSubjectId.php create mode 100644 tests/Unit/Extension/Cryptography/MissingSubjectIdTest.php create mode 100644 tests/Unit/Extension/Cryptography/Store/CipherKeyNotExistsTest.php create mode 100644 tests/Unit/Extension/Cryptography/Store/InMemoryCipherKeyStoreTest.php create mode 100644 tests/Unit/Extension/Cryptography/SubjectIdsTest.php create mode 100644 tests/Unit/Extension/Cryptography/UnsupportedSubjectIdTest.php create mode 100644 tests/Unit/Extension/Lifecycle/Fixture/LifecycleFixture.php create mode 100644 tests/Unit/Extension/Lifecycle/LifecycleExtensionTest.php create mode 100644 tests/Unit/Extension/Lifecycle/LifecycleMetadataEnricherTest.php create mode 100644 tests/Unit/Extension/Lifecycle/LifecycleMiddlewareTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 76fc3b7a..7125343c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -18,6 +18,30 @@ parameters: count: 1 path: src/Cryptography/PersonalDataPayloadCryptographer.php + - + message: '#^Parameter \#3 \$iv of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' + identifier: argument.type + count: 1 + path: src/Extension/Cryptography/BaseCryptographer.php + + - + message: '#^Method Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\OpensslCipher\:\:encrypt\(\) should return non\-empty\-string but returns string\.$#' + identifier: return.type + count: 1 + path: src/Extension/Cryptography/Cipher/OpensslCipher.php + + - + message: '#^Parameter \#1 \$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: '#^Parameter \#3 \$iv 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 @@ -108,6 +132,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 diff --git a/src/Extension/Cryptography/Attribute/DataSubjectId.php b/src/Extension/Cryptography/Attribute/DataSubjectId.php new file mode 100644 index 00000000..6c3443b6 --- /dev/null +++ b/src/Extension/Cryptography/Attribute/DataSubjectId.php @@ -0,0 +1,16 @@ +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..1419249a --- /dev/null +++ b/src/Extension/Cryptography/BaseCryptographer.php @@ -0,0 +1,97 @@ +cipherKeyStore->get($subjectId); + } catch (CipherKeyNotExists) { + $cipherKey = ($this->cipherKeyFactory)(); + $this->cipherKeyStore->store($subjectId, $cipherKey); + } + + return [ + '__enc' => 'v1', + 'data' => $this->cipher->encrypt($cipherKey, $value), + 'method' => $cipherKey->method, + 'iv' => base64_encode($cipherKey->iv), + ]; + } + + /** + * @param EncryptedDataV1 $encryptedData + * + * @throws CipherKeyNotExists + * @throws DecryptionFailed + */ + public function decrypt(string $subjectId, mixed $encryptedData): mixed + { + $cipherKey = $this->cipherKeyStore->get($subjectId); + + return $this->cipher->decrypt( + new CipherKey( + $cipherKey->key, + $encryptedData['method'] ?? $cipherKey->method, + isset($encryptedData['iv']) ? base64_decode($encryptedData['iv']) : $cipherKey->iv, + ), + $encryptedData['data'], + ); + } + + public function supports(mixed $value): bool + { + return is_array($value) && array_key_exists('__enc', $value) && $value['__enc'] === 'v1'; + } + + /** @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..9652c851 --- /dev/null +++ b/src/Extension/Cryptography/Cipher/Cipher.php @@ -0,0 +1,18 @@ +dataEncode($data), + $key->method, + $key->key, + 0, + $key->iv, + ); + + if ($encryptedData === false) { + throw new EncryptionFailed(); + } + + return base64_encode($encryptedData); + } + + public function decrypt(CipherKey $key, string $data): mixed + { + $data = @openssl_decrypt( + base64_decode($data), + $key->method, + $key->key, + 0, + $key->iv, + ); + + if ($data === false) { + throw new DecryptionFailed(); + } + + try { + return $this->dataDecode($data); + } catch (JsonException) { + throw new DecryptionFailed(); + } + } + + private function dataEncode(mixed $data): string + { + return json_encode($data, JSON_THROW_ON_ERROR); + } + + private function dataDecode(string $data): mixed + { + return json_decode($data, true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php b/src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php new file mode 100644 index 00000000..3e30a638 --- /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); + } + + $ivLength = @openssl_cipher_iv_length($this->method); + + if ($keyLength === false || $ivLength === false) { + throw new MethodNotSupported($this->method); + } + + $this->keyLength = $keyLength; + $this->ivLength = $ivLength; + } + + public function __invoke(): CipherKey + { + return new CipherKey( + openssl_random_pseudo_bytes($this->keyLength), + $this->method, + openssl_random_pseudo_bytes($this->ivLength), + ); + } + + /** @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..b540ec7d --- /dev/null +++ b/src/Extension/Cryptography/Cryptographer.php @@ -0,0 +1,23 @@ +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..6357d5a4 --- /dev/null +++ b/src/Extension/Cryptography/CryptographyMetadataEnricher.php @@ -0,0 +1,77 @@ +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..304ce220 --- /dev/null +++ b/src/Extension/Cryptography/CryptographyMiddleware.php @@ -0,0 +1,182 @@ + $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..91d3a034 --- /dev/null +++ b/src/Extension/Cryptography/DuplicateSubjectIdIdentifier.php @@ -0,0 +1,28 @@ + */ + private array $keys = []; + + public function get(string $id): CipherKey + { + return $this->keys[$id] ?? throw new CipherKeyNotExists($id); + } + + public function store(string $id, CipherKey $key): void + { + $this->keys[$id] = $key; + } + + public function remove(string $id): void + { + unset($this->keys[$id]); + } + + public function clear(): void + { + $this->keys = []; + } +} diff --git a/src/Extension/Cryptography/SubjectIdAndSensitiveDataConflict.php b/src/Extension/Cryptography/SubjectIdAndSensitiveDataConflict.php new file mode 100644 index 00000000..265ff6c0 --- /dev/null +++ b/src/Extension/Cryptography/SubjectIdAndSensitiveDataConflict.php @@ -0,0 +1,25 @@ + $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..12d6d730 --- /dev/null +++ b/src/Extension/Cryptography/SubjectIds.php @@ -0,0 +1,26 @@ + $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..13fb8e43 --- /dev/null +++ b/src/Extension/Cryptography/UnsupportedSubjectId.php @@ -0,0 +1,19 @@ +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..332e8257 --- /dev/null +++ b/src/Extension/Lifecycle/LifecycleMetadataEnricher.php @@ -0,0 +1,73 @@ +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..05e51cac --- /dev/null +++ b/src/Extension/Lifecycle/LifecycleMiddleware.php @@ -0,0 +1,70 @@ + $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/tests/Unit/Extension/Cryptography/BaseCryptographerTest.php b/tests/Unit/Extension/Cryptography/BaseCryptographerTest.php new file mode 100644 index 00000000..9d714b4b --- /dev/null +++ b/tests/Unit/Extension/Cryptography/BaseCryptographerTest.php @@ -0,0 +1,177 @@ +createMock(CipherKeyStore::class); + $cipherKeyStore->method('get')->with('foo')->willReturn($cipherKey); + + $cipherKeyStore + ->expects($this->never()) + ->method('store') + ->with('foo', $this->isInstanceOf(CipherKey::class)); + + $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('encrypted'); + + $cryptographer = new BaseCryptographer( + $cipher, + $cipherKeyStore, + $cipherKeyFactory, + ); + + $expected = [ + '__enc' => 'v1', + 'data' => 'encrypted', + 'method' => 'methodA', + 'iv' => 'cmFuZG9t', + ]; + + self::assertEquals($expected, $cryptographer->encrypt('foo', 'info@patchlevel.de')); + } + + public function testEncryptWithoutKey(): void + { + $cipherKey = new CipherKey('foo', 'methodA', 'random'); + + $cipherKeyStore = $this->createMock(CipherKeyStore::class); + $cipherKeyStore->method('get')->with('foo')->willThrowException(new CipherKeyNotExists('foo')); + + $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); + $cipherKeyFactory->expects($this->once())->method('__invoke')->willReturn($cipherKey); + + $cipherKeyStore + ->expects($this->once()) + ->method('store') + ->with('foo', $cipherKey); + + $cipher = $this->createMock(Cipher::class); + $cipher->expects($this->once())->method('encrypt')->with($cipherKey, 'info@patchlevel.de') + ->willReturn('encrypted'); + + $cryptographer = new BaseCryptographer( + $cipher, + $cipherKeyStore, + $cipherKeyFactory, + ); + + $expected = [ + '__enc' => 'v1', + 'data' => 'encrypted', + 'method' => 'methodA', + 'iv' => 'cmFuZG9t', + ]; + + self::assertEquals($expected, $cryptographer->encrypt('foo', 'info@patchlevel.de')); + } + + public function testDecrypt(): void + { + $cipherKey = new CipherKey('foo', 'methodA', 'random'); + + $cipherKeyStore = $this->createMock(CipherKeyStore::class); + $cipherKeyStore->method('get')->with('foo')->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, 'encrypted') + ->willReturn('info@patchlevel.de'); + + $cryptographer = new BaseCryptographer( + $cipher, + $cipherKeyStore, + $cipherKeyFactory, + ); + + self::assertEquals( + 'info@patchlevel.de', + $cryptographer->decrypt( + 'foo', + [ + '__enc' => 'v1', + 'data' => 'encrypted', + 'method' => 'methodA', + 'iv' => 'cmFuZG9t', + ], + ), + ); + } + + public function testDecryptWithFallback(): void + { + $cipherKey = new CipherKey('foo', 'methodA', 'random'); + + $cipherKeyStore = $this->createMock(CipherKeyStore::class); + $cipherKeyStore->method('get')->with('foo')->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, 'encrypted') + ->willReturn('info@patchlevel.de'); + + $cryptographer = new BaseCryptographer( + $cipher, + $cipherKeyStore, + $cipherKeyFactory, + ); + + self::assertEquals( + 'info@patchlevel.de', + $cryptographer->decrypt( + 'foo', + [ + '__enc' => 'v1', + 'data' => 'encrypted', + ], + ), + ); + } + + #[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 [['__enc' => 'foo'], false]; + yield [['__enc' => 'v1'], 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..86ee0a2e --- /dev/null +++ b/tests/Unit/Extension/Cryptography/Cipher/CreateCipherKeyFailedTest.php @@ -0,0 +1,20 @@ +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..a23bed2e --- /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..be8d4851 --- /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..f0ea9467 --- /dev/null +++ b/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherKeyFactoryTest.php @@ -0,0 +1,34 @@ +assertSame(16, strlen($cipherKey->key)); + $this->assertSame('aes128', $cipherKey->method); + $this->assertSame(16, strlen($cipherKey->iv)); + } + + public function testMethodNotSupported(): void + { + $this->expectException(MethodNotSupported::class); + + $cipherKeyFactory = new OpensslCipherKeyFactory(method: 'foo'); + $cipherKeyFactory(); + } +} diff --git a/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherTest.php b/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherTest.php new file mode 100644 index 00000000..f584fedb --- /dev/null +++ b/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherTest.php @@ -0,0 +1,79 @@ +encrypt($this->createKey(), $value); + + self::assertEquals($encryptedString, $return); + } + + public function testEncryptFailed(): void + { + $this->expectException(EncryptionFailed::class); + + $cipher = new OpensslCipher(); + $cipher->encrypt(new CipherKey( + 'key', + 'bar', + 'abcdefg123456789', + ), ''); + } + + #[DataProvider('dataProvider')] + public function testDecrypt(mixed $value, string $encryptedString): void + { + $cipher = new OpensslCipher(); + $return = $cipher->decrypt($this->createKey(), $encryptedString); + + self::assertEquals($value, $return); + } + + public function testDecryptFailed(): void + { + $this->expectException(DecryptionFailed::class); + + $cipher = new OpensslCipher(); + $cipher->decrypt($this->createKey('foo'), 'emNpWDlMWFBnRStpZk9YZktrUStRQT09'); + } + + public static function dataProvider(): Generator + { + yield 'empty' => ['', 'emNpWDlMWFBnRStpZk9YZktrUStRQT09']; + yield 'string' => ['foo bar baz', 'YUlYRnJZMEd1RkFycjNrQitETHhqQT09']; + yield 'integer' => [42, 'M1FHSnlnbWNlZFJiV2xwdzZIZUhDdz09']; + yield 'float' => [0.5, 'N2tOWGNia3lrdUJ1ancrMFA4OEY0Zz09']; + yield 'null' => [null, 'OUE1T081cXdpNmFMc1FIMGsrME5vdz09']; + yield 'true' => [true, 'NCtWMDE4WnV5NEtCamVVdkIxZjRrdz09']; + yield 'false' => [false, 'czh5NUYxWXhQOWhSbGVwWG5ETFdVQT09']; + yield 'array' => [['foo' => 'bar'], 'cHo2QlhxSnNFZG1kUEhRZ3pjcFJrUT09']; + yield 'long text' => ['Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', 'eDNCalYzSS9LbkZIcGdKNWVmUFQwTTI0YXhhSnNmdUxXeXhGUGFwMWZkTmx1ZnNwNzBUa29NcUFxUzRFV3V2WWNlUmt6YWhTSlRzVXpqd3RLZkpzUWFWYVRCR1pvbkt3TUE4UzZmaDVQcTYzMzJoWVBRRzllbHhhNjYrenNWbzFDZ2lnVm1PRFhvamozZEVmcXFYVTZGQ1dIWEgzcE1mU2w2SWlRQ2o2WFdNPQ==']; + } + + /** @param non-empty-string $key */ + private function createKey(string $key = 'key'): CipherKey + { + return new CipherKey( + $key, + 'aes128', + 'abcdefg123456789', + ); + } +} 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..3bcd85bb --- /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(new CipherKeyNotExists('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(new DecryptionFailed()); + + $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(new DecryptionFailed()); + + $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..95c8d4f3 --- /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..a56ed9fa --- /dev/null +++ b/tests/Unit/Extension/Cryptography/Store/InMemoryCipherKeyStoreTest.php @@ -0,0 +1,77 @@ +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', + ); + + $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', + ); + + $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); + } +} From 5fd42f6280daa7a934aaebd0f60f363fb1fdea40 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:19:01 +0000 Subject: [PATCH 10/17] Lock file maintenance Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 4edc03b7..f011e87f 100644 --- a/composer.lock +++ b/composer.lock @@ -5623,16 +5623,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.5", + "version": "2.1.6", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188" + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/79155f94852fa27e2f73b459f6503f5e87e2c188", - "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", "shasum": "" }, "require": { @@ -5679,9 +5679,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.5" + "source": "https://github.com/webmozarts/assert/tree/2.1.6" }, - "time": "2026-02-18T14:09:36+00:00" + "time": "2026-02-27T10:28:38+00:00" }, { "name": "webmozart/glob", From 19d6107cb0b15cefc14cbe1fdedb0c752535ed7d Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 28 Feb 2026 10:25:35 +0100 Subject: [PATCH 11/17] mark stack hydrator experimental --- src/CoreExtension.php | 1 + src/Extension.php | 1 + src/Extension/Cryptography/Attribute/DataSubjectId.php | 1 + src/Extension/Cryptography/Attribute/SensitiveData.php | 1 + src/Extension/Cryptography/BaseCryptographer.php | 1 + src/Extension/Cryptography/Cipher/Cipher.php | 1 + src/Extension/Cryptography/Cipher/CipherKey.php | 1 + src/Extension/Cryptography/Cipher/CipherKeyFactory.php | 1 + src/Extension/Cryptography/Cipher/CreateCipherKeyFailed.php | 1 + src/Extension/Cryptography/Cipher/DecryptionFailed.php | 1 + src/Extension/Cryptography/Cipher/EncryptionFailed.php | 1 + src/Extension/Cryptography/Cipher/MethodNotSupported.php | 1 + src/Extension/Cryptography/Cipher/OpensslCipher.php | 1 + src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php | 1 + src/Extension/Cryptography/Cryptographer.php | 1 + src/Extension/Cryptography/CryptographyExtension.php | 1 + src/Extension/Cryptography/CryptographyMetadataEnricher.php | 1 + src/Extension/Cryptography/CryptographyMiddleware.php | 1 + src/Extension/Cryptography/DuplicateSubjectIdIdentifier.php | 1 + src/Extension/Cryptography/MissingSubjectId.php | 1 + src/Extension/Cryptography/MissingSubjectIdForField.php | 1 + src/Extension/Cryptography/SensitiveDataInfo.php | 1 + src/Extension/Cryptography/Store/CipherKeyNotExists.php | 1 + src/Extension/Cryptography/Store/CipherKeyStore.php | 1 + src/Extension/Cryptography/Store/InMemoryCipherKeyStore.php | 1 + src/Extension/Cryptography/SubjectIdAndSensitiveDataConflict.php | 1 + src/Extension/Cryptography/SubjectIdFieldMapping.php | 1 + src/Extension/Cryptography/SubjectIds.php | 1 + src/Extension/Cryptography/UnsupportedSubjectId.php | 1 + src/Extension/Lifecycle/Attribute/PostExtract.php | 1 + src/Extension/Lifecycle/Attribute/PostHydrate.php | 1 + src/Extension/Lifecycle/Attribute/PreExtract.php | 1 + src/Extension/Lifecycle/Attribute/PreHydrate.php | 1 + src/Extension/Lifecycle/Lifecycle.php | 1 + src/Extension/Lifecycle/LifecycleExtension.php | 1 + src/Extension/Lifecycle/LifecycleMetadataEnricher.php | 1 + src/Extension/Lifecycle/LifecycleMiddleware.php | 1 + src/Middleware/Middleware.php | 1 + src/Middleware/NoMoreMiddleware.php | 1 + src/Middleware/Stack.php | 1 + src/Middleware/TransformMiddleware.php | 1 + src/StackHydrator.php | 1 + src/StackHydratorBuilder.php | 1 + 43 files changed, 43 insertions(+) diff --git a/src/CoreExtension.php b/src/CoreExtension.php index 81cce0df..9ff16064 100644 --- a/src/CoreExtension.php +++ b/src/CoreExtension.php @@ -7,6 +7,7 @@ use Patchlevel\Hydrator\Guesser\BuiltInGuesser; use Patchlevel\Hydrator\Middleware\TransformMiddleware; +/** @experimental */ final class CoreExtension implements Extension { public function configure(StackHydratorBuilder $builder): void diff --git a/src/Extension.php b/src/Extension.php index 723e3052..3b0e83f1 100644 --- a/src/Extension.php +++ b/src/Extension.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator; +/** @experimental */ interface Extension { public function configure(StackHydratorBuilder $builder): void; diff --git a/src/Extension/Cryptography/Attribute/DataSubjectId.php b/src/Extension/Cryptography/Attribute/DataSubjectId.php index 6c3443b6..8b65ac06 100644 --- a/src/Extension/Cryptography/Attribute/DataSubjectId.php +++ b/src/Extension/Cryptography/Attribute/DataSubjectId.php @@ -6,6 +6,7 @@ use Attribute; +/** @experimental */ #[Attribute(Attribute::TARGET_PROPERTY)] final class DataSubjectId { diff --git a/src/Extension/Cryptography/Attribute/SensitiveData.php b/src/Extension/Cryptography/Attribute/SensitiveData.php index 2e0c734e..4e004ab5 100644 --- a/src/Extension/Cryptography/Attribute/SensitiveData.php +++ b/src/Extension/Cryptography/Attribute/SensitiveData.php @@ -7,6 +7,7 @@ use Attribute; use InvalidArgumentException; +/** @experimental */ #[Attribute(Attribute::TARGET_PROPERTY)] final class SensitiveData { diff --git a/src/Extension/Cryptography/BaseCryptographer.php b/src/Extension/Cryptography/BaseCryptographer.php index 1419249a..8769c6b2 100644 --- a/src/Extension/Cryptography/BaseCryptographer.php +++ b/src/Extension/Cryptography/BaseCryptographer.php @@ -20,6 +20,7 @@ use function is_array; /** + * @experimental * @phpstan-type EncryptedDataV1 array{ * __enc: 'v1', * data: non-empty-string, diff --git a/src/Extension/Cryptography/Cipher/Cipher.php b/src/Extension/Cryptography/Cipher/Cipher.php index 9652c851..03f38ec8 100644 --- a/src/Extension/Cryptography/Cipher/Cipher.php +++ b/src/Extension/Cryptography/Cipher/Cipher.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator\Extension\Cryptography\Cipher; +/** @experimental */ interface Cipher { /** diff --git a/src/Extension/Cryptography/Cipher/CipherKey.php b/src/Extension/Cryptography/Cipher/CipherKey.php index 52e7e354..ced9c9aa 100644 --- a/src/Extension/Cryptography/Cipher/CipherKey.php +++ b/src/Extension/Cryptography/Cipher/CipherKey.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator\Extension\Cryptography\Cipher; +/** @experimental */ final class CipherKey { /** diff --git a/src/Extension/Cryptography/Cipher/CipherKeyFactory.php b/src/Extension/Cryptography/Cipher/CipherKeyFactory.php index 2eeb4935..85050f1f 100644 --- a/src/Extension/Cryptography/Cipher/CipherKeyFactory.php +++ b/src/Extension/Cryptography/Cipher/CipherKeyFactory.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator\Extension\Cryptography\Cipher; +/** @experimental */ interface CipherKeyFactory { /** @throws CreateCipherKeyFailed */ diff --git a/src/Extension/Cryptography/Cipher/CreateCipherKeyFailed.php b/src/Extension/Cryptography/Cipher/CreateCipherKeyFailed.php index b54acedc..60f2fcf2 100644 --- a/src/Extension/Cryptography/Cipher/CreateCipherKeyFailed.php +++ b/src/Extension/Cryptography/Cipher/CreateCipherKeyFailed.php @@ -7,6 +7,7 @@ use Patchlevel\Hydrator\HydratorException; use RuntimeException; +/** @experimental */ final class CreateCipherKeyFailed extends RuntimeException implements HydratorException { public function __construct() diff --git a/src/Extension/Cryptography/Cipher/DecryptionFailed.php b/src/Extension/Cryptography/Cipher/DecryptionFailed.php index 56e890d8..8b7059e8 100644 --- a/src/Extension/Cryptography/Cipher/DecryptionFailed.php +++ b/src/Extension/Cryptography/Cipher/DecryptionFailed.php @@ -7,6 +7,7 @@ use Patchlevel\Hydrator\HydratorException; use RuntimeException; +/** @experimental */ final class DecryptionFailed extends RuntimeException implements HydratorException { public function __construct() diff --git a/src/Extension/Cryptography/Cipher/EncryptionFailed.php b/src/Extension/Cryptography/Cipher/EncryptionFailed.php index 43f19481..65bd1fb4 100644 --- a/src/Extension/Cryptography/Cipher/EncryptionFailed.php +++ b/src/Extension/Cryptography/Cipher/EncryptionFailed.php @@ -7,6 +7,7 @@ use Patchlevel\Hydrator\HydratorException; use RuntimeException; +/** @experimental */ final class EncryptionFailed extends RuntimeException implements HydratorException { public function __construct() diff --git a/src/Extension/Cryptography/Cipher/MethodNotSupported.php b/src/Extension/Cryptography/Cipher/MethodNotSupported.php index d3512904..2b24978e 100644 --- a/src/Extension/Cryptography/Cipher/MethodNotSupported.php +++ b/src/Extension/Cryptography/Cipher/MethodNotSupported.php @@ -9,6 +9,7 @@ use function sprintf; +/** @experimental */ final class MethodNotSupported extends RuntimeException implements HydratorException { public function __construct(string $method) diff --git a/src/Extension/Cryptography/Cipher/OpensslCipher.php b/src/Extension/Cryptography/Cipher/OpensslCipher.php index e37235a5..2fd55afa 100644 --- a/src/Extension/Cryptography/Cipher/OpensslCipher.php +++ b/src/Extension/Cryptography/Cipher/OpensslCipher.php @@ -15,6 +15,7 @@ use const JSON_THROW_ON_ERROR; +/** @experimental */ final class OpensslCipher implements Cipher { /** @return non-empty-string */ diff --git a/src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php b/src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php index 3e30a638..67f1a5b0 100644 --- a/src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php +++ b/src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php @@ -11,6 +11,7 @@ use function openssl_get_cipher_methods; use function openssl_random_pseudo_bytes; +/** @experimental */ final class OpensslCipherKeyFactory implements CipherKeyFactory { public const DEFAULT_METHOD = 'aes128'; diff --git a/src/Extension/Cryptography/Cryptographer.php b/src/Extension/Cryptography/Cryptographer.php index b540ec7d..054dc4a6 100644 --- a/src/Extension/Cryptography/Cryptographer.php +++ b/src/Extension/Cryptography/Cryptographer.php @@ -8,6 +8,7 @@ use Patchlevel\Hydrator\Extension\Cryptography\Cipher\EncryptionFailed; use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyNotExists; +/** @experimental */ interface Cryptographer { /** @throws EncryptionFailed */ diff --git a/src/Extension/Cryptography/CryptographyExtension.php b/src/Extension/Cryptography/CryptographyExtension.php index 0274a9d6..ea8a6a21 100644 --- a/src/Extension/Cryptography/CryptographyExtension.php +++ b/src/Extension/Cryptography/CryptographyExtension.php @@ -7,6 +7,7 @@ use Patchlevel\Hydrator\Extension; use Patchlevel\Hydrator\StackHydratorBuilder; +/** @experimental */ final class CryptographyExtension implements Extension { public function __construct( diff --git a/src/Extension/Cryptography/CryptographyMetadataEnricher.php b/src/Extension/Cryptography/CryptographyMetadataEnricher.php index 6357d5a4..794ccd0f 100644 --- a/src/Extension/Cryptography/CryptographyMetadataEnricher.php +++ b/src/Extension/Cryptography/CryptographyMetadataEnricher.php @@ -12,6 +12,7 @@ use function array_key_exists; +/** @experimental */ final class CryptographyMetadataEnricher implements MetadataEnricher { public function enrich(ClassMetadata $classMetadata): void diff --git a/src/Extension/Cryptography/CryptographyMiddleware.php b/src/Extension/Cryptography/CryptographyMiddleware.php index 304ce220..60616e59 100644 --- a/src/Extension/Cryptography/CryptographyMiddleware.php +++ b/src/Extension/Cryptography/CryptographyMiddleware.php @@ -19,6 +19,7 @@ use function is_int; use function is_string; +/** @experimental */ final class CryptographyMiddleware implements Middleware { public function __construct( diff --git a/src/Extension/Cryptography/DuplicateSubjectIdIdentifier.php b/src/Extension/Cryptography/DuplicateSubjectIdIdentifier.php index 91d3a034..6a93822c 100644 --- a/src/Extension/Cryptography/DuplicateSubjectIdIdentifier.php +++ b/src/Extension/Cryptography/DuplicateSubjectIdIdentifier.php @@ -9,6 +9,7 @@ use function sprintf; +/** @experimental */ final class DuplicateSubjectIdIdentifier extends RuntimeException implements MetadataException { /** @param class-string $class */ diff --git a/src/Extension/Cryptography/MissingSubjectId.php b/src/Extension/Cryptography/MissingSubjectId.php index df8a7c32..d09db6c7 100644 --- a/src/Extension/Cryptography/MissingSubjectId.php +++ b/src/Extension/Cryptography/MissingSubjectId.php @@ -9,6 +9,7 @@ use function sprintf; +/** @experimental */ final class MissingSubjectId extends RuntimeException implements HydratorException { public function __construct(string $name) diff --git a/src/Extension/Cryptography/MissingSubjectIdForField.php b/src/Extension/Cryptography/MissingSubjectIdForField.php index afc0a304..a74f1986 100644 --- a/src/Extension/Cryptography/MissingSubjectIdForField.php +++ b/src/Extension/Cryptography/MissingSubjectIdForField.php @@ -9,6 +9,7 @@ use function sprintf; +/** @experimental */ final class MissingSubjectIdForField extends RuntimeException implements HydratorException { /** @param class-string $class */ diff --git a/src/Extension/Cryptography/SensitiveDataInfo.php b/src/Extension/Cryptography/SensitiveDataInfo.php index 7f00c3f4..af3990be 100644 --- a/src/Extension/Cryptography/SensitiveDataInfo.php +++ b/src/Extension/Cryptography/SensitiveDataInfo.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator\Extension\Cryptography; +/** @experimental */ final class SensitiveDataInfo { public function __construct( diff --git a/src/Extension/Cryptography/Store/CipherKeyNotExists.php b/src/Extension/Cryptography/Store/CipherKeyNotExists.php index 1b6d7adc..8679caf9 100644 --- a/src/Extension/Cryptography/Store/CipherKeyNotExists.php +++ b/src/Extension/Cryptography/Store/CipherKeyNotExists.php @@ -9,6 +9,7 @@ use function sprintf; +/** @experimental */ final class CipherKeyNotExists extends RuntimeException implements HydratorException { public function __construct(string $id) diff --git a/src/Extension/Cryptography/Store/CipherKeyStore.php b/src/Extension/Cryptography/Store/CipherKeyStore.php index 5f0541f9..fe589f28 100644 --- a/src/Extension/Cryptography/Store/CipherKeyStore.php +++ b/src/Extension/Cryptography/Store/CipherKeyStore.php @@ -6,6 +6,7 @@ use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey; +/** @experimental */ interface CipherKeyStore { /** @throws CipherKeyNotExists */ diff --git a/src/Extension/Cryptography/Store/InMemoryCipherKeyStore.php b/src/Extension/Cryptography/Store/InMemoryCipherKeyStore.php index aa0ea334..dd328233 100644 --- a/src/Extension/Cryptography/Store/InMemoryCipherKeyStore.php +++ b/src/Extension/Cryptography/Store/InMemoryCipherKeyStore.php @@ -6,6 +6,7 @@ use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey; +/** @experimental */ final class InMemoryCipherKeyStore implements CipherKeyStore { /** @var array */ diff --git a/src/Extension/Cryptography/SubjectIdAndSensitiveDataConflict.php b/src/Extension/Cryptography/SubjectIdAndSensitiveDataConflict.php index 265ff6c0..69cdca84 100644 --- a/src/Extension/Cryptography/SubjectIdAndSensitiveDataConflict.php +++ b/src/Extension/Cryptography/SubjectIdAndSensitiveDataConflict.php @@ -9,6 +9,7 @@ use function sprintf; +/** @experimental */ final class SubjectIdAndSensitiveDataConflict extends RuntimeException implements MetadataException { /** @param class-string $class */ diff --git a/src/Extension/Cryptography/SubjectIdFieldMapping.php b/src/Extension/Cryptography/SubjectIdFieldMapping.php index 5aa9b9b8..ffef5c47 100644 --- a/src/Extension/Cryptography/SubjectIdFieldMapping.php +++ b/src/Extension/Cryptography/SubjectIdFieldMapping.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator\Extension\Cryptography; +/** @experimental */ final class SubjectIdFieldMapping { /** @param array $nameToField */ diff --git a/src/Extension/Cryptography/SubjectIds.php b/src/Extension/Cryptography/SubjectIds.php index 12d6d730..ca1df081 100644 --- a/src/Extension/Cryptography/SubjectIds.php +++ b/src/Extension/Cryptography/SubjectIds.php @@ -6,6 +6,7 @@ use function array_merge; +/** @experimental */ final class SubjectIds { /** @param array $subjectIds */ diff --git a/src/Extension/Cryptography/UnsupportedSubjectId.php b/src/Extension/Cryptography/UnsupportedSubjectId.php index 13fb8e43..ad739a38 100644 --- a/src/Extension/Cryptography/UnsupportedSubjectId.php +++ b/src/Extension/Cryptography/UnsupportedSubjectId.php @@ -10,6 +10,7 @@ use function get_debug_type; use function sprintf; +/** @experimental */ final class UnsupportedSubjectId extends RuntimeException implements HydratorException { public function __construct(string $class, string $fieldName, mixed $subjectId) diff --git a/src/Extension/Lifecycle/Attribute/PostExtract.php b/src/Extension/Lifecycle/Attribute/PostExtract.php index 6cd5810e..afb09110 100644 --- a/src/Extension/Lifecycle/Attribute/PostExtract.php +++ b/src/Extension/Lifecycle/Attribute/PostExtract.php @@ -6,6 +6,7 @@ use Attribute; +/** @experimental */ #[Attribute(Attribute::TARGET_METHOD)] final class PostExtract { diff --git a/src/Extension/Lifecycle/Attribute/PostHydrate.php b/src/Extension/Lifecycle/Attribute/PostHydrate.php index d15bba32..f4599c6c 100644 --- a/src/Extension/Lifecycle/Attribute/PostHydrate.php +++ b/src/Extension/Lifecycle/Attribute/PostHydrate.php @@ -6,6 +6,7 @@ use Attribute; +/** @experimental */ #[Attribute(Attribute::TARGET_METHOD)] final class PostHydrate { diff --git a/src/Extension/Lifecycle/Attribute/PreExtract.php b/src/Extension/Lifecycle/Attribute/PreExtract.php index 6d223f54..b42ac169 100644 --- a/src/Extension/Lifecycle/Attribute/PreExtract.php +++ b/src/Extension/Lifecycle/Attribute/PreExtract.php @@ -6,6 +6,7 @@ use Attribute; +/** @experimental */ #[Attribute(Attribute::TARGET_METHOD)] final class PreExtract { diff --git a/src/Extension/Lifecycle/Attribute/PreHydrate.php b/src/Extension/Lifecycle/Attribute/PreHydrate.php index bd4f0bb7..5ecb92bb 100644 --- a/src/Extension/Lifecycle/Attribute/PreHydrate.php +++ b/src/Extension/Lifecycle/Attribute/PreHydrate.php @@ -6,6 +6,7 @@ use Attribute; +/** @experimental */ #[Attribute(Attribute::TARGET_METHOD)] final class PreHydrate { diff --git a/src/Extension/Lifecycle/Lifecycle.php b/src/Extension/Lifecycle/Lifecycle.php index 0cd9522b..e5f15e1e 100644 --- a/src/Extension/Lifecycle/Lifecycle.php +++ b/src/Extension/Lifecycle/Lifecycle.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator\Extension\Lifecycle; +/** @experimental */ final readonly class Lifecycle { public function __construct( diff --git a/src/Extension/Lifecycle/LifecycleExtension.php b/src/Extension/Lifecycle/LifecycleExtension.php index 1bfa0d5c..47bc6113 100644 --- a/src/Extension/Lifecycle/LifecycleExtension.php +++ b/src/Extension/Lifecycle/LifecycleExtension.php @@ -7,6 +7,7 @@ use Patchlevel\Hydrator\Extension; use Patchlevel\Hydrator\StackHydratorBuilder; +/** @experimental */ final readonly class LifecycleExtension implements Extension { public function configure(StackHydratorBuilder $builder): void diff --git a/src/Extension/Lifecycle/LifecycleMetadataEnricher.php b/src/Extension/Lifecycle/LifecycleMetadataEnricher.php index 332e8257..4222ac6b 100644 --- a/src/Extension/Lifecycle/LifecycleMetadataEnricher.php +++ b/src/Extension/Lifecycle/LifecycleMetadataEnricher.php @@ -14,6 +14,7 @@ use function sprintf; +/** @experimental */ final class LifecycleMetadataEnricher implements MetadataEnricher { public function enrich(ClassMetadata $classMetadata): void diff --git a/src/Extension/Lifecycle/LifecycleMiddleware.php b/src/Extension/Lifecycle/LifecycleMiddleware.php index 05e51cac..ed9db0da 100644 --- a/src/Extension/Lifecycle/LifecycleMiddleware.php +++ b/src/Extension/Lifecycle/LifecycleMiddleware.php @@ -10,6 +10,7 @@ use function assert; +/** @experimental */ final class LifecycleMiddleware implements Middleware { /** diff --git a/src/Middleware/Middleware.php b/src/Middleware/Middleware.php index 106dab61..aa6074a5 100644 --- a/src/Middleware/Middleware.php +++ b/src/Middleware/Middleware.php @@ -6,6 +6,7 @@ use Patchlevel\Hydrator\Metadata\ClassMetadata; +/** @experimental */ interface Middleware { /** diff --git a/src/Middleware/NoMoreMiddleware.php b/src/Middleware/NoMoreMiddleware.php index 8203ab3c..4d063998 100644 --- a/src/Middleware/NoMoreMiddleware.php +++ b/src/Middleware/NoMoreMiddleware.php @@ -7,6 +7,7 @@ use Patchlevel\Hydrator\HydratorException; use RuntimeException; +/** @experimental */ final class NoMoreMiddleware extends RuntimeException implements HydratorException { public function __construct() diff --git a/src/Middleware/Stack.php b/src/Middleware/Stack.php index 7a47f627..6be11ea3 100644 --- a/src/Middleware/Stack.php +++ b/src/Middleware/Stack.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator\Middleware; +/** @experimental */ final class Stack { private int $index = 0; diff --git a/src/Middleware/TransformMiddleware.php b/src/Middleware/TransformMiddleware.php index c180d17c..c90d1fcf 100644 --- a/src/Middleware/TransformMiddleware.php +++ b/src/Middleware/TransformMiddleware.php @@ -17,6 +17,7 @@ use function array_values; use function spl_object_id; +/** @experimental */ final class TransformMiddleware implements Middleware { /** @var array */ diff --git a/src/StackHydrator.php b/src/StackHydrator.php index bfe593bb..f0bab583 100644 --- a/src/StackHydrator.php +++ b/src/StackHydrator.php @@ -18,6 +18,7 @@ use const PHP_VERSION_ID; +/** @experimental */ final class StackHydrator implements HydratorWithContext { /** @var array */ diff --git a/src/StackHydratorBuilder.php b/src/StackHydratorBuilder.php index 39c5b9b6..4d5e72f7 100644 --- a/src/StackHydratorBuilder.php +++ b/src/StackHydratorBuilder.php @@ -18,6 +18,7 @@ use function array_merge; use function krsort; +/** @experimental */ final class StackHydratorBuilder { private bool $defaultLazy = false; From 9286f6fb611f98941238ae681d1ee000bfc179fa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:54:05 +0000 Subject: [PATCH 12/17] Lock file maintenance Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.lock | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/composer.lock b/composer.lock index f011e87f..2860dc04 100644 --- a/composer.lock +++ b/composer.lock @@ -1991,16 +1991,16 @@ }, { "name": "phpbench/phpbench", - "version": "1.4.3", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/phpbench/phpbench.git", - "reference": "b641dde59d969ea42eed70a39f9b51950bc96878" + "reference": "63e5a853b84ef27729b2af7f42365a19434fdc79" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpbench/phpbench/zipball/b641dde59d969ea42eed70a39f9b51950bc96878", - "reference": "b641dde59d969ea42eed70a39f9b51950bc96878", + "url": "https://api.github.com/repos/phpbench/phpbench/zipball/63e5a853b84ef27729b2af7f42365a19434fdc79", + "reference": "63e5a853b84ef27729b2af7f42365a19434fdc79", "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.0" }, "funding": [ { @@ -2085,7 +2086,7 @@ "type": "github" } ], - "time": "2025-11-06T19:07:31+00:00" + "time": "2026-03-04T20:33:49+00:00" }, { "name": "phpstan/phpdoc-parser", From e5c60aa477c4dbfb4dd2b7910cdb0339b693f143 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 5 Mar 2026 16:26:31 +0100 Subject: [PATCH 13/17] improve cryptography implementation --- phpstan-baseline.neon | 34 +++++- .../Cryptography/BaseCryptographer.php | 72 +++++++---- src/Extension/Cryptography/Cipher/Cipher.php | 10 +- .../Cryptography/Cipher/CipherKey.php | 11 +- .../Cryptography/Cipher/CipherKeyFactory.php | 8 +- .../Cipher/CreateCipherKeyFailed.php | 17 ++- .../Cryptography/Cipher/DecryptionFailed.php | 27 ++++- .../Cryptography/Cipher/EncryptedData.php | 23 ++++ .../Cryptography/Cipher/EncryptionFailed.php | 17 ++- .../Cryptography/Cipher/OpensslCipher.php | 67 +++++++---- .../Cipher/OpensslCipherKeyFactory.php | 21 ++-- .../Cryptography/Store/CipherKeyNotExists.php | 14 ++- .../Cryptography/Store/CipherKeyStore.php | 7 +- .../Store/InMemoryCipherKeyStore.php | 70 ++++++++++- .../Cryptography/BaseCryptographerTest.php | 113 ++++++++---------- .../Cipher/CreateCipherKeyFailedTest.php | 5 +- .../Cipher/DecryptionFailedTest.php | 4 +- .../Cipher/EncryptionFailedTest.php | 4 +- .../Cipher/OpensslCipherKeyFactoryTest.php | 10 +- .../Cryptography/Cipher/OpensslCipherTest.php | 51 ++++---- .../CryptographyMiddlewareTest.php | 6 +- .../Store/CipherKeyNotExistsTest.php | 4 +- .../Store/InMemoryCipherKeyStoreTest.php | 7 ++ 23 files changed, 409 insertions(+), 193 deletions(-) create mode 100644 src/Extension/Cryptography/Cipher/EncryptedData.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7125343c..73ed976a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -19,25 +19,49 @@ parameters: path: src/Cryptography/PersonalDataPayloadCryptographer.php - - message: '#^Parameter \#3 \$iv of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' + 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: '#^Method Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\OpensslCipher\:\:encrypt\(\) should return non\-empty\-string but returns string\.$#' - identifier: return.type + 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 \$key of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' + 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 \$iv of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' + 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 diff --git a/src/Extension/Cryptography/BaseCryptographer.php b/src/Extension/Cryptography/BaseCryptographer.php index 8769c6b2..07671ab6 100644 --- a/src/Extension/Cryptography/BaseCryptographer.php +++ b/src/Extension/Cryptography/BaseCryptographer.php @@ -5,27 +5,26 @@ namespace Patchlevel\Hydrator\Extension\Cryptography; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\Cipher; -use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKeyFactory; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\DecryptionFailed; +use Patchlevel\Hydrator\Extension\Cryptography\Cipher\EncryptedData; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\EncryptionFailed; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\OpensslCipher; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\OpensslCipherKeyFactory; use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyNotExists; use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore; -use function array_key_exists; -use function base64_decode; -use function base64_encode; use function is_array; /** * @experimental - * @phpstan-type EncryptedDataV1 array{ - * __enc: 'v1', - * data: non-empty-string, - * method?: non-empty-string, - * iv?: non-empty-string, + * @phpstan-type EncryptedDataArray array{ + * v: 1, + * a: non-empty-string, + * k: non-empty-string, + * n?: non-empty-string, // base64 + * d: non-empty-string, // base64 ciphertext + * t?: non-empty-string, // base64 (for AEAD) * } */ final class BaseCryptographer implements Cryptographer @@ -38,50 +37,71 @@ public function __construct( } /** - * @return EncryptedDataV1 + * @return EncryptedDataArray * * @throws EncryptionFailed */ public function encrypt(string $subjectId, mixed $value): array { try { - $cipherKey = $this->cipherKeyStore->get($subjectId); + $cipherKey = $this->cipherKeyStore->currentKeyFor($subjectId); } catch (CipherKeyNotExists) { - $cipherKey = ($this->cipherKeyFactory)(); - $this->cipherKeyStore->store($subjectId, $cipherKey); + $cipherKey = ($this->cipherKeyFactory)($subjectId); + $this->cipherKeyStore->store($cipherKey->id, $cipherKey); } - return [ - '__enc' => 'v1', - 'data' => $this->cipher->encrypt($cipherKey, $value), - 'method' => $cipherKey->method, - 'iv' => base64_encode($cipherKey->iv), + $parameter = $this->cipher->encrypt($cipherKey, $value); + + $result = [ + 'v' => 1, + 'a' => $parameter->method, + 'k' => $cipherKey->id, + 'd' => $parameter->data, ]; + + if ($parameter->nonce !== null) { + $result['n'] = $parameter->nonce; + } + + if ($parameter->tag !== null) { + $result['t'] = $parameter->tag; + } + + return $result; } /** - * @param EncryptedDataV1 $encryptedData + * @param EncryptedDataArray $encryptedData * * @throws CipherKeyNotExists * @throws DecryptionFailed */ public function decrypt(string $subjectId, mixed $encryptedData): mixed { - $cipherKey = $this->cipherKeyStore->get($subjectId); + $keyId = $encryptedData['k'] ?? null; + + if ($keyId === null) { + throw DecryptionFailed::missingKeyId(); + } + + $cipherKey = $this->cipherKeyStore->get($keyId); return $this->cipher->decrypt( - new CipherKey( - $cipherKey->key, - $encryptedData['method'] ?? $cipherKey->method, - isset($encryptedData['iv']) ? base64_decode($encryptedData['iv']) : $cipherKey->iv, + $cipherKey, + new EncryptedData( + $encryptedData['d'], + $encryptedData['a'], + $encryptedData['n'] ?? null, + $encryptedData['t'] ?? null, ), - $encryptedData['data'], ); } public function supports(mixed $value): bool { - return is_array($value) && array_key_exists('__enc', $value) && $value['__enc'] === 'v1'; + return is_array($value) + && isset($value['v'], $value['a'], $value['k'], $value['d']) + && $value['v'] === 1; } /** @param non-empty-string $method */ diff --git a/src/Extension/Cryptography/Cipher/Cipher.php b/src/Extension/Cryptography/Cipher/Cipher.php index 03f38ec8..184829e9 100644 --- a/src/Extension/Cryptography/Cipher/Cipher.php +++ b/src/Extension/Cryptography/Cipher/Cipher.php @@ -7,13 +7,9 @@ /** @experimental */ interface Cipher { - /** - * @return non-empty-string - * - * @throws EncryptionFailed - */ - public function encrypt(CipherKey $key, mixed $data): string; + /** @throws EncryptionFailed */ + public function encrypt(CipherKey $key, mixed $data): EncryptedData; /** @throws DecryptionFailed */ - public function decrypt(CipherKey $key, string $data): mixed; + public function decrypt(CipherKey $key, EncryptedData $parameter): mixed; } diff --git a/src/Extension/Cryptography/Cipher/CipherKey.php b/src/Extension/Cryptography/Cipher/CipherKey.php index ced9c9aa..54bf4263 100644 --- a/src/Extension/Cryptography/Cipher/CipherKey.php +++ b/src/Extension/Cryptography/Cipher/CipherKey.php @@ -4,18 +4,25 @@ namespace Patchlevel\Hydrator\Extension\Cryptography\Cipher; +use DateTimeImmutable; +use SensitiveParameter; + /** @experimental */ final class CipherKey { /** + * @param non-empty-string $id + * @param non-empty-string $subjectId * @param non-empty-string $key * @param non-empty-string $method - * @param non-empty-string $iv */ public function __construct( + public readonly string $id, + public readonly string $subjectId, + #[SensitiveParameter] public readonly string $key, public readonly string $method, - public readonly string $iv, + public readonly DateTimeImmutable $createdAt, ) { } } diff --git a/src/Extension/Cryptography/Cipher/CipherKeyFactory.php b/src/Extension/Cryptography/Cipher/CipherKeyFactory.php index 85050f1f..9a82ba98 100644 --- a/src/Extension/Cryptography/Cipher/CipherKeyFactory.php +++ b/src/Extension/Cryptography/Cipher/CipherKeyFactory.php @@ -7,6 +7,10 @@ /** @experimental */ interface CipherKeyFactory { - /** @throws CreateCipherKeyFailed */ - public function __invoke(): CipherKey; + /** + * @param non-empty-string $subjectId + * + * @throws CreateCipherKeyFailed + */ + public function __invoke(string $subjectId): CipherKey; } diff --git a/src/Extension/Cryptography/Cipher/CreateCipherKeyFailed.php b/src/Extension/Cryptography/Cipher/CreateCipherKeyFailed.php index 60f2fcf2..50f0b759 100644 --- a/src/Extension/Cryptography/Cipher/CreateCipherKeyFailed.php +++ b/src/Extension/Cryptography/Cipher/CreateCipherKeyFailed.php @@ -6,12 +6,25 @@ use Patchlevel\Hydrator\HydratorException; use RuntimeException; +use Throwable; + +use function sprintf; /** @experimental */ final class CreateCipherKeyFailed extends RuntimeException implements HydratorException { - public function __construct() + private function __construct(string $message, Throwable|null $previous = null) + { + parent::__construct($message, 0, $previous); + } + + public static function forMethod(string $method, string $reason): self + { + return new self(sprintf('Failed to create cipher key for method "%s": %s', $method, $reason)); + } + + public static function invalidKeyLength(string $method): self { - parent::__construct('Create cipher key failed.'); + return new self(sprintf('Invalid key length for method "%s".', $method)); } } diff --git a/src/Extension/Cryptography/Cipher/DecryptionFailed.php b/src/Extension/Cryptography/Cipher/DecryptionFailed.php index 8b7059e8..a2f99765 100644 --- a/src/Extension/Cryptography/Cipher/DecryptionFailed.php +++ b/src/Extension/Cryptography/Cipher/DecryptionFailed.php @@ -6,12 +6,35 @@ use Patchlevel\Hydrator\HydratorException; use RuntimeException; +use Throwable; + +use function sprintf; /** @experimental */ final class DecryptionFailed extends RuntimeException implements HydratorException { - public function __construct() + private function __construct(string $message, Throwable|null $previous = null) + { + parent::__construct($message, 0, $previous); + } + + public static function forMethod(string $method, Throwable|null $previous = null): self + { + return new self(sprintf('Decryption failed for method "%s".', $method), $previous); + } + + public static function invalidBase64(string $field): self + { + return new self(sprintf('Invalid base64 encoding in field "%s".', $field)); + } + + public static function missingKeyId(): self + { + return new self('Missing key ID in encrypted data.'); + } + + public static function invalidJson(Throwable|null $previous = null): self { - parent::__construct('Decryption failed.'); + return new self('Failed to decode JSON data.', $previous); } } diff --git a/src/Extension/Cryptography/Cipher/EncryptedData.php b/src/Extension/Cryptography/Cipher/EncryptedData.php new file mode 100644 index 00000000..ec86be2e --- /dev/null +++ b/src/Extension/Cryptography/Cipher/EncryptedData.php @@ -0,0 +1,23 @@ +method); + + if ($ivLength === false) { + throw EncryptionFailed::invalidIvLength($key->method); + } + + $nonce = $ivLength > 0 ? openssl_random_pseudo_bytes($ivLength) : null; + $tag = null; + $encryptedData = @openssl_encrypt( - $this->dataEncode($data), + json_encode($data, JSON_THROW_ON_ERROR), $key->method, $key->key, 0, - $key->iv, + $nonce ?? '', + $tag, ); if ($encryptedData === false) { - throw new EncryptionFailed(); + throw EncryptionFailed::forMethod($key->method); } - return base64_encode($encryptedData); + 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, string $data): mixed + 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($data), - $key->method, + base64_decode($parameter->data, true), + $parameter->method, $key->key, 0, - $key->iv, + $nonce ?: '', + $tag ?: '', ); if ($data === false) { - throw new DecryptionFailed(); + throw DecryptionFailed::forMethod($parameter->method); } try { - return $this->dataDecode($data); - } catch (JsonException) { - throw new DecryptionFailed(); + return json_decode($data, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw DecryptionFailed::invalidJson($e); } } - - private function dataEncode(mixed $data): string - { - return json_encode($data, JSON_THROW_ON_ERROR); - } - - private function dataDecode(string $data): mixed - { - return json_decode($data, true, 512, JSON_THROW_ON_ERROR); - } } diff --git a/src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php b/src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php index 67f1a5b0..1a7442d6 100644 --- a/src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php +++ b/src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php @@ -4,9 +4,11 @@ namespace Patchlevel\Hydrator\Extension\Cryptography\Cipher; +use DateTimeImmutable; + +use function bin2hex; use function function_exists; use function in_array; -use function openssl_cipher_iv_length; use function openssl_cipher_key_length; use function openssl_get_cipher_methods; use function openssl_random_pseudo_bytes; @@ -14,12 +16,10 @@ /** @experimental */ final class OpensslCipherKeyFactory implements CipherKeyFactory { - public const DEFAULT_METHOD = 'aes128'; + public const DEFAULT_METHOD = 'aes-128-gcm'; private readonly int $keyLength; - private readonly int $ivLength; - /** @param non-empty-string $method */ public function __construct( private readonly string $method = self::DEFAULT_METHOD, @@ -34,22 +34,21 @@ public function __construct( $keyLength = @openssl_cipher_key_length($this->method); } - $ivLength = @openssl_cipher_iv_length($this->method); - - if ($keyLength === false || $ivLength === false) { + if ($keyLength === false) { throw new MethodNotSupported($this->method); } $this->keyLength = $keyLength; - $this->ivLength = $ivLength; } - public function __invoke(): CipherKey + public function __invoke(string $subjectId): CipherKey { return new CipherKey( - openssl_random_pseudo_bytes($this->keyLength), + bin2hex(openssl_random_pseudo_bytes(16)), + $subjectId, + bin2hex(openssl_random_pseudo_bytes($this->keyLength)), $this->method, - openssl_random_pseudo_bytes($this->ivLength), + new DateTimeImmutable(), ); } diff --git a/src/Extension/Cryptography/Store/CipherKeyNotExists.php b/src/Extension/Cryptography/Store/CipherKeyNotExists.php index 8679caf9..71310fbf 100644 --- a/src/Extension/Cryptography/Store/CipherKeyNotExists.php +++ b/src/Extension/Cryptography/Store/CipherKeyNotExists.php @@ -12,8 +12,18 @@ /** @experimental */ final class CipherKeyNotExists extends RuntimeException implements HydratorException { - public function __construct(string $id) + private function __construct(string $message) { - parent::__construct(sprintf('Cipher key with subject id "%s" not found.', $id)); + parent::__construct($message); + } + + public static function forKeyId(string $id): self + { + return new self(sprintf('Cipher key with id "%s" does not exist.', $id)); + } + + public static function forSubjectId(string $subjectId): self + { + return new self(sprintf('Cipher key for subject id "%s" does not exist.', $subjectId)); } } diff --git a/src/Extension/Cryptography/Store/CipherKeyStore.php b/src/Extension/Cryptography/Store/CipherKeyStore.php index fe589f28..82541c99 100644 --- a/src/Extension/Cryptography/Store/CipherKeyStore.php +++ b/src/Extension/Cryptography/Store/CipherKeyStore.php @@ -10,9 +10,14 @@ interface CipherKeyStore { /** @throws CipherKeyNotExists */ - public function get(string $id): CipherKey; + public function currentKeyFor(string $subjectId): CipherKey; + + /** @throws CipherKeyNotExists */ + public function get(string $keyId): CipherKey; public function store(string $id, CipherKey $key): void; public function remove(string $id): void; + + public function removeWithSubjectId(string $subjectId): void; } diff --git a/src/Extension/Cryptography/Store/InMemoryCipherKeyStore.php b/src/Extension/Cryptography/Store/InMemoryCipherKeyStore.php index dd328233..20a0322a 100644 --- a/src/Extension/Cryptography/Store/InMemoryCipherKeyStore.php +++ b/src/Extension/Cryptography/Store/InMemoryCipherKeyStore.php @@ -6,29 +6,87 @@ use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey; +use function array_key_last; + /** @experimental */ final class InMemoryCipherKeyStore implements CipherKeyStore { /** @var array */ - private array $keys = []; + 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]); - public function get(string $id): CipherKey + if ($lastKey === null) { + throw CipherKeyNotExists::forSubjectId($subjectId); + } + + return $this->indexBySubjectId[$subjectId][$lastKey]; + } + + public function get(string $keyId): CipherKey { - return $this->keys[$id] ?? throw new CipherKeyNotExists($id); + return $this->indexById[$keyId] ?? throw CipherKeyNotExists::forKeyId($keyId); } public function store(string $id, CipherKey $key): void { - $this->keys[$id] = $key; + $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->keys[$id]); + 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->keys = []; + $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/tests/Unit/Extension/Cryptography/BaseCryptographerTest.php b/tests/Unit/Extension/Cryptography/BaseCryptographerTest.php index 9d714b4b..296026aa 100644 --- a/tests/Unit/Extension/Cryptography/BaseCryptographerTest.php +++ b/tests/Unit/Extension/Cryptography/BaseCryptographerTest.php @@ -4,10 +4,12 @@ namespace Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography; +use DateTimeImmutable; use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\Cipher; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKeyFactory; +use Patchlevel\Hydrator\Extension\Cryptography\Cipher\EncryptedData; use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyNotExists; use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore; use PHPUnit\Framework\Attributes\CoversClass; @@ -19,22 +21,22 @@ final class BaseCryptographerTest extends TestCase { public function testEncrypt(): void { - $cipherKey = new CipherKey('foo', 'methodA', 'random'); + $cipherKey = new CipherKey('key-123', 'subject-foo', '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('get')->with('foo')->willReturn($cipherKey); + $cipherKeyStore->method('currentKeyFor')->with('subject-foo')->willReturn($cipherKey); $cipherKeyStore ->expects($this->never()) - ->method('store') - ->with('foo', $this->isInstanceOf(CipherKey::class)); + ->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('encrypted'); + ->willReturn($encryptionParameter); $cryptographer = new BaseCryptographer( $cipher, @@ -43,33 +45,36 @@ public function testEncrypt(): void ); $expected = [ - '__enc' => 'v1', - 'data' => 'encrypted', - 'method' => 'methodA', - 'iv' => 'cmFuZG9t', + 'v' => 1, + 'a' => 'aes-256-gcm', + 'k' => 'key-123', + 'd' => 'encrypted-data', + 'n' => 'random-nonce', + 't' => 'auth-tag', ]; - self::assertEquals($expected, $cryptographer->encrypt('foo', 'info@patchlevel.de')); + self::assertEquals($expected, $cryptographer->encrypt('subject-foo', 'info@patchlevel.de')); } public function testEncryptWithoutKey(): void { - $cipherKey = new CipherKey('foo', 'methodA', 'random'); + $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('get')->with('foo')->willThrowException(new CipherKeyNotExists('foo')); + $cipherKeyStore->method('currentKeyFor')->with('subject-bar')->willThrowException(CipherKeyNotExists::forSubjectId('subject-bar')); $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); - $cipherKeyFactory->expects($this->once())->method('__invoke')->willReturn($cipherKey); + $cipherKeyFactory->expects($this->once())->method('__invoke')->with('subject-bar')->willReturn($cipherKey); $cipherKeyStore ->expects($this->once()) ->method('store') - ->with('foo', $cipherKey); + ->with('key-456', $cipherKey); $cipher = $this->createMock(Cipher::class); $cipher->expects($this->once())->method('encrypt')->with($cipherKey, 'info@patchlevel.de') - ->willReturn('encrypted'); + ->willReturn($encryptionParameter); $cryptographer = new BaseCryptographer( $cipher, @@ -78,62 +83,37 @@ public function testEncryptWithoutKey(): void ); $expected = [ - '__enc' => 'v1', - 'data' => 'encrypted', - 'method' => 'methodA', - 'iv' => 'cmFuZG9t', + 'v' => 1, + 'a' => 'aes-256-gcm', + 'k' => 'key-456', + 'd' => 'encrypted-data', + 'n' => 'random-nonce', + 't' => 'auth-tag', ]; - self::assertEquals($expected, $cryptographer->encrypt('foo', 'info@patchlevel.de')); + self::assertEquals($expected, $cryptographer->encrypt('subject-bar', 'info@patchlevel.de')); } public function testDecrypt(): void { - $cipherKey = new CipherKey('foo', 'methodA', 'random'); - - $cipherKeyStore = $this->createMock(CipherKeyStore::class); - $cipherKeyStore->method('get')->with('foo')->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, 'encrypted') - ->willReturn('info@patchlevel.de'); - - $cryptographer = new BaseCryptographer( - $cipher, - $cipherKeyStore, - $cipherKeyFactory, - ); - - self::assertEquals( - 'info@patchlevel.de', - $cryptographer->decrypt( - 'foo', - [ - '__enc' => 'v1', - 'data' => 'encrypted', - 'method' => 'methodA', - 'iv' => 'cmFuZG9t', - ], - ), - ); - } - - public function testDecryptWithFallback(): void - { - $cipherKey = new CipherKey('foo', 'methodA', 'random'); + $cipherKey = new CipherKey('key-789', 'subject-baz', 'secret-key', 'aes-256-gcm', new DateTimeImmutable()); $cipherKeyStore = $this->createMock(CipherKeyStore::class); - $cipherKeyStore->method('get')->with('foo')->willReturn($cipherKey); + $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, 'encrypted') - ->willReturn('info@patchlevel.de'); + $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, @@ -144,10 +124,14 @@ public function testDecryptWithFallback(): void self::assertEquals( 'info@patchlevel.de', $cryptographer->decrypt( - 'foo', + 'subject-baz', [ - '__enc' => 'v1', - 'data' => 'encrypted', + 'v' => 1, + 'a' => 'aes-256-gcm', + 'k' => 'key-789', + 'd' => 'encrypted-data', + 'n' => 'random-nonce', + 't' => 'auth-tag', ], ), ); @@ -171,7 +155,10 @@ public static function dataProviderSupports(): iterable yield ['foo', false]; yield [[], false]; yield [null, false]; - yield [['__enc' => 'foo'], false]; - yield [['__enc' => 'v1'], true]; + 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 index 86ee0a2e..13fd8bdd 100644 --- a/tests/Unit/Extension/Cryptography/Cipher/CreateCipherKeyFailedTest.php +++ b/tests/Unit/Extension/Cryptography/Cipher/CreateCipherKeyFailedTest.php @@ -13,8 +13,9 @@ final class CreateCipherKeyFailedTest extends TestCase { public function testCreation(): void { - $exception = new CreateCipherKeyFailed(); + $exception = CreateCipherKeyFailed::forMethod('aes-256-gcm', 'test reason'); - self::assertSame('Create cipher key failed.', $exception->getMessage()); + self::assertStringContainsString('aes-256-gcm', $exception->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 index a23bed2e..a3bb2b4c 100644 --- a/tests/Unit/Extension/Cryptography/Cipher/DecryptionFailedTest.php +++ b/tests/Unit/Extension/Cryptography/Cipher/DecryptionFailedTest.php @@ -13,8 +13,8 @@ final class DecryptionFailedTest extends TestCase { public function testCreation(): void { - $exception = new DecryptionFailed(); + $exception = DecryptionFailed::forMethod('aes-256-gcm'); - self::assertSame('Decryption failed.', $exception->getMessage()); + self::assertStringContainsString('aes-256-gcm', $exception->getMessage()); } } diff --git a/tests/Unit/Extension/Cryptography/Cipher/EncryptionFailedTest.php b/tests/Unit/Extension/Cryptography/Cipher/EncryptionFailedTest.php index be8d4851..a1e8d833 100644 --- a/tests/Unit/Extension/Cryptography/Cipher/EncryptionFailedTest.php +++ b/tests/Unit/Extension/Cryptography/Cipher/EncryptionFailedTest.php @@ -13,8 +13,8 @@ final class EncryptionFailedTest extends TestCase { public function testCreation(): void { - $exception = new EncryptionFailed(); + $exception = EncryptionFailed::forMethod('aes-256-gcm'); - self::assertSame('Encryption failed.', $exception->getMessage()); + self::assertStringContainsString('aes-256-gcm', $exception->getMessage()); } } diff --git a/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherKeyFactoryTest.php b/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherKeyFactoryTest.php index f0ea9467..79f87112 100644 --- a/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherKeyFactoryTest.php +++ b/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherKeyFactoryTest.php @@ -17,11 +17,11 @@ final class OpensslCipherKeyFactoryTest extends TestCase public function testCreateKey(): void { $cipherKeyFactory = new OpensslCipherKeyFactory(); - $cipherKey = $cipherKeyFactory(); + $cipherKey = $cipherKeyFactory('test-subject'); - $this->assertSame(16, strlen($cipherKey->key)); - $this->assertSame('aes128', $cipherKey->method); - $this->assertSame(16, strlen($cipherKey->iv)); + $this->assertSame(32, strlen($cipherKey->key)); + $this->assertSame('aes-128-gcm', $cipherKey->method); + $this->assertSame('test-subject', $cipherKey->subjectId); } public function testMethodNotSupported(): void @@ -29,6 +29,6 @@ public function testMethodNotSupported(): void $this->expectException(MethodNotSupported::class); $cipherKeyFactory = new OpensslCipherKeyFactory(method: 'foo'); - $cipherKeyFactory(); + $cipherKeyFactory('test-subject'); } } diff --git a/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherTest.php b/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherTest.php index f584fedb..4d05d15d 100644 --- a/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherTest.php +++ b/tests/Unit/Extension/Cryptography/Cipher/OpensslCipherTest.php @@ -4,9 +4,11 @@ namespace Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Cipher; +use DateTimeImmutable; use Generator; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\DecryptionFailed; +use Patchlevel\Hydrator\Extension\Cryptography\Cipher\EncryptedData; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\EncryptionFailed; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\OpensslCipher; use PHPUnit\Framework\Attributes\CoversClass; @@ -17,12 +19,20 @@ final class OpensslCipherTest extends TestCase { #[DataProvider('dataProvider')] - public function testEncrypt(mixed $value, string $encryptedString): void + public function testEncryptDecrypt(mixed $value): void { $cipher = new OpensslCipher(); - $return = $cipher->encrypt($this->createKey(), $value); + $key = $this->createKey(); - self::assertEquals($encryptedString, $return); + $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 @@ -34,37 +44,32 @@ public function testEncryptFailed(): void 'key', 'bar', 'abcdefg123456789', + 'invalid-method', + new DateTimeImmutable(), ), ''); } - #[DataProvider('dataProvider')] - public function testDecrypt(mixed $value, string $encryptedString): void - { - $cipher = new OpensslCipher(); - $return = $cipher->decrypt($this->createKey(), $encryptedString); - - self::assertEquals($value, $return); - } - public function testDecryptFailed(): void { $this->expectException(DecryptionFailed::class); $cipher = new OpensslCipher(); - $cipher->decrypt($this->createKey('foo'), 'emNpWDlMWFBnRStpZk9YZktrUStRQT09'); + $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' => ['', 'emNpWDlMWFBnRStpZk9YZktrUStRQT09']; - yield 'string' => ['foo bar baz', 'YUlYRnJZMEd1RkFycjNrQitETHhqQT09']; - yield 'integer' => [42, 'M1FHSnlnbWNlZFJiV2xwdzZIZUhDdz09']; - yield 'float' => [0.5, 'N2tOWGNia3lrdUJ1ancrMFA4OEY0Zz09']; - yield 'null' => [null, 'OUE1T081cXdpNmFMc1FIMGsrME5vdz09']; - yield 'true' => [true, 'NCtWMDE4WnV5NEtCamVVdkIxZjRrdz09']; - yield 'false' => [false, 'czh5NUYxWXhQOWhSbGVwWG5ETFdVQT09']; - yield 'array' => [['foo' => 'bar'], 'cHo2QlhxSnNFZG1kUEhRZ3pjcFJrUT09']; - yield 'long text' => ['Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', 'eDNCalYzSS9LbkZIcGdKNWVmUFQwTTI0YXhhSnNmdUxXeXhGUGFwMWZkTmx1ZnNwNzBUa29NcUFxUzRFV3V2WWNlUmt6YWhTSlRzVXpqd3RLZkpzUWFWYVRCR1pvbkt3TUE4UzZmaDVQcTYzMzJoWVBRRzllbHhhNjYrenNWbzFDZ2lnVm1PRFhvamozZEVmcXFYVTZGQ1dIWEgzcE1mU2w2SWlRQ2o2WFdNPQ==']; + 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 */ @@ -74,6 +79,8 @@ private function createKey(string $key = 'key'): CipherKey $key, 'aes128', 'abcdefg123456789', + 'aes-128-cbc', + new DateTimeImmutable(), ); } } diff --git a/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php b/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php index 3bcd85bb..92648311 100644 --- a/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php +++ b/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php @@ -166,7 +166,7 @@ public function testDecryptWithCipherKeyNotExists(): void { $cryptographer = $this->createMock(Cryptographer::class); $cryptographer->method('supports')->willReturn(true); - $cryptographer->method('decrypt')->willThrowException(new CipherKeyNotExists('foo')); + $cryptographer->method('decrypt')->willThrowException(CipherKeyNotExists::forSubjectId('foo')); $middleware = new CryptographyMiddleware($cryptographer); @@ -186,7 +186,7 @@ public function testDecryptWithDecryptionFailed(): void { $cryptographer = $this->createMock(Cryptographer::class); $cryptographer->method('supports')->willReturn(true); - $cryptographer->method('decrypt')->willThrowException(new DecryptionFailed()); + $cryptographer->method('decrypt')->willThrowException(DecryptionFailed::forMethod('aes-256-gcm')); $middleware = new CryptographyMiddleware($cryptographer); @@ -206,7 +206,7 @@ public function testDecryptWithFallbackCallback(): void { $cryptographer = $this->createMock(Cryptographer::class); $cryptographer->method('supports')->willReturn(true); - $cryptographer->method('decrypt')->willThrowException(new DecryptionFailed()); + $cryptographer->method('decrypt')->willThrowException(DecryptionFailed::forMethod('aes-256-gcm')); $middleware = new CryptographyMiddleware($cryptographer); diff --git a/tests/Unit/Extension/Cryptography/Store/CipherKeyNotExistsTest.php b/tests/Unit/Extension/Cryptography/Store/CipherKeyNotExistsTest.php index 95c8d4f3..80f00e80 100644 --- a/tests/Unit/Extension/Cryptography/Store/CipherKeyNotExistsTest.php +++ b/tests/Unit/Extension/Cryptography/Store/CipherKeyNotExistsTest.php @@ -13,8 +13,8 @@ final class CipherKeyNotExistsTest extends TestCase { public function testCreation(): void { - $exception = new CipherKeyNotExists('foo'); + $exception = CipherKeyNotExists::forSubjectId('foo'); - self::assertSame('Cipher key with subject id "foo" not found.', $exception->getMessage()); + self::assertStringContainsString('foo', $exception->getMessage()); } } diff --git a/tests/Unit/Extension/Cryptography/Store/InMemoryCipherKeyStoreTest.php b/tests/Unit/Extension/Cryptography/Store/InMemoryCipherKeyStoreTest.php index a56ed9fa..99dcb441 100644 --- a/tests/Unit/Extension/Cryptography/Store/InMemoryCipherKeyStoreTest.php +++ b/tests/Unit/Extension/Cryptography/Store/InMemoryCipherKeyStoreTest.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Store; +use DateTimeImmutable; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey; use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyNotExists; use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore; @@ -19,6 +20,8 @@ public function testStoreAndLoad(): void 'foo', 'bar', 'baz', + 'aes-256-gcm', + new DateTimeImmutable(), ); $store = new InMemoryCipherKeyStore(); @@ -41,6 +44,8 @@ public function testRemove(): void 'foo', 'bar', 'baz', + 'aes-256-gcm', + new DateTimeImmutable(), ); $store = new InMemoryCipherKeyStore(); @@ -61,6 +66,8 @@ public function testClear(): void 'foo', 'bar', 'baz', + 'aes-256-gcm', + new DateTimeImmutable(), ); $store = new InMemoryCipherKeyStore(); From ad62a6258ab47baef56c68f180240018ece7b575 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 00:39:46 +0000 Subject: [PATCH 14/17] Lock file maintenance Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 2860dc04..b561566e 100644 --- a/composer.lock +++ b/composer.lock @@ -1991,16 +1991,16 @@ }, { "name": "phpbench/phpbench", - "version": "1.5.0", + "version": "1.5.1", "source": { "type": "git", "url": "https://github.com/phpbench/phpbench.git", - "reference": "63e5a853b84ef27729b2af7f42365a19434fdc79" + "reference": "9a28fd0833f11171b949843c6fd663eb69b6d14c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpbench/phpbench/zipball/63e5a853b84ef27729b2af7f42365a19434fdc79", - "reference": "63e5a853b84ef27729b2af7f42365a19434fdc79", + "url": "https://api.github.com/repos/phpbench/phpbench/zipball/9a28fd0833f11171b949843c6fd663eb69b6d14c", + "reference": "9a28fd0833f11171b949843c6fd663eb69b6d14c", "shasum": "" }, "require": { @@ -2078,7 +2078,7 @@ ], "support": { "issues": "https://github.com/phpbench/phpbench/issues", - "source": "https://github.com/phpbench/phpbench/tree/1.5.0" + "source": "https://github.com/phpbench/phpbench/tree/1.5.1" }, "funding": [ { @@ -2086,7 +2086,7 @@ "type": "github" } ], - "time": "2026-03-04T20:33:49+00:00" + "time": "2026-03-05T08:18:58+00:00" }, { "name": "phpstan/phpdoc-parser", From 9becc9018d212dbe2a85722c51b682b4f65449e0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:46:52 +0000 Subject: [PATCH 15/17] Lock file maintenance Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.lock | 24 ++++++++++++------------ tools/composer.lock | 12 ++++++------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/composer.lock b/composer.lock index b561566e..bf3fe207 100644 --- a/composer.lock +++ b/composer.lock @@ -372,16 +372,16 @@ }, { "name": "symfony/type-info", - "version": "v8.0.6", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "785992c06d07306f963ded3439036f5da9b292fe" + "reference": "3c7de103dd6cb68be24e155838a64ef4a70ae195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/785992c06d07306f963ded3439036f5da9b292fe", - "reference": "785992c06d07306f963ded3439036f5da9b292fe", + "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.6" + "source": "https://github.com/symfony/type-info/tree/v8.0.7" }, "funding": [ { @@ -450,7 +450,7 @@ "type": "tidelift" } ], - "time": "2026-02-20T07:51:53+00:00" + "time": "2026-03-04T13:55:34+00:00" } ], "packages-dev": [ @@ -4321,16 +4321,16 @@ }, { "name": "symfony/console", - "version": "v8.0.6", + "version": "v8.0.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "488285876e807a4777f074041d8bb508623419fa" + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/488285876e807a4777f074041d8bb508623419fa", - "reference": "488285876e807a4777f074041d8bb508623419fa", + "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", "shasum": "" }, "require": { @@ -4387,7 +4387,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.6" + "source": "https://github.com/symfony/console/tree/v8.0.7" }, "funding": [ { @@ -4407,7 +4407,7 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:59:43+00:00" + "time": "2026-03-06T14:06:22+00:00" }, { "name": "symfony/deprecation-contracts", diff --git a/tools/composer.lock b/tools/composer.lock index 89d26dbc..1a696591 100644 --- a/tools/composer.lock +++ b/tools/composer.lock @@ -1714,16 +1714,16 @@ }, { "name": "symfony/console", - "version": "v7.4.6", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6d643a93b47398599124022eb24d97c153c12f27" + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6d643a93b47398599124022eb24d97c153c12f27", - "reference": "6d643a93b47398599124022eb24d97c153c12f27", + "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.6" + "source": "https://github.com/symfony/console/tree/v7.4.7" }, "funding": [ { @@ -1808,7 +1808,7 @@ "type": "tidelift" } ], - "time": "2026-02-25T17:02:47+00:00" + "time": "2026-03-06T14:06:20+00:00" }, { "name": "symfony/deprecation-contracts", From 3e9e1e8be6207a3b40a8281f18dcac6f0f2896de Mon Sep 17 00:00:00 2001 From: David Badura Date: Mon, 9 Mar 2026 09:37:38 +0100 Subject: [PATCH 16/17] add stack hydrator with cryptography benchmark --- .../StackHydratorWithCryptographyBench.php | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 tests/Benchmark/StackHydratorWithCryptographyBench.php 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); + } + } +} From 24f2efcff79819adcb0087ec3585125d20c51ba5 Mon Sep 17 00:00:00 2001 From: David Badura Date: Mon, 9 Mar 2026 16:40:29 +0100 Subject: [PATCH 17/17] use constants for array keys --- .../Cryptography/BaseCryptographer.php | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/Extension/Cryptography/BaseCryptographer.php b/src/Extension/Cryptography/BaseCryptographer.php index 07671ab6..51dd0b59 100644 --- a/src/Extension/Cryptography/BaseCryptographer.php +++ b/src/Extension/Cryptography/BaseCryptographer.php @@ -29,6 +29,13 @@ */ final class BaseCryptographer implements Cryptographer { + private const VERSION_KEY = 'v'; + private const METHOD_KEY = 'a'; + private const KEY_ID_KEY = 'k'; + private const NONCE_KEY = 'n'; + private const DATA_KEY = 'd'; + private const TAG_KEY = 't'; + public function __construct( private readonly Cipher $cipher, private readonly CipherKeyStore $cipherKeyStore, @@ -53,18 +60,18 @@ public function encrypt(string $subjectId, mixed $value): array $parameter = $this->cipher->encrypt($cipherKey, $value); $result = [ - 'v' => 1, - 'a' => $parameter->method, - 'k' => $cipherKey->id, - 'd' => $parameter->data, + 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['n'] = $parameter->nonce; + $result[self::NONCE_KEY] = $parameter->nonce; } if ($parameter->tag !== null) { - $result['t'] = $parameter->tag; + $result[self::TAG_KEY] = $parameter->tag; } return $result; @@ -78,7 +85,7 @@ public function encrypt(string $subjectId, mixed $value): array */ public function decrypt(string $subjectId, mixed $encryptedData): mixed { - $keyId = $encryptedData['k'] ?? null; + $keyId = $encryptedData[self::KEY_ID_KEY] ?? null; if ($keyId === null) { throw DecryptionFailed::missingKeyId(); @@ -89,10 +96,10 @@ public function decrypt(string $subjectId, mixed $encryptedData): mixed return $this->cipher->decrypt( $cipherKey, new EncryptedData( - $encryptedData['d'], - $encryptedData['a'], - $encryptedData['n'] ?? null, - $encryptedData['t'] ?? null, + $encryptedData[self::DATA_KEY], + $encryptedData[self::METHOD_KEY], + $encryptedData[self::NONCE_KEY] ?? null, + $encryptedData[self::TAG_KEY] ?? null, ), ); } @@ -100,8 +107,8 @@ public function decrypt(string $subjectId, mixed $encryptedData): mixed public function supports(mixed $value): bool { return is_array($value) - && isset($value['v'], $value['a'], $value['k'], $value['d']) - && $value['v'] === 1; + && 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 */