From 9282433fc622ba4e064a9252ddd0747b2fb3ae45 Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 2 Oct 2026 22:59:45 +0200 Subject: [PATCH 1/6] Add groups extension Properties can be assigned to groups with the new Groups attribute and selected or excluded per call via the groups and ignored_groups context keys. Filtering applies to extract and hydrate and is passed on to nested objects. --- docs/extensions.md | 3 +- docs/groups.md | 161 +++++++++++ docs/project.json | 3 +- src/Extension/Groups/Attribute/Groups.php | 23 ++ src/Extension/Groups/GroupsExtension.php | 26 ++ .../Groups/GroupsMetadataEnricher.php | 35 +++ src/Extension/Groups/GroupsMiddleware.php | 197 ++++++++++++++ .../Groups/Fixture/AddressFixture.php | 18 ++ .../Extension/Groups/Fixture/ChildFixture.php | 18 ++ .../Groups/Fixture/NoGroupsFixture.php | 14 + .../Groups/Fixture/ParentFixture.php | 18 ++ .../Groups/Fixture/RecordingMiddleware.php | 46 ++++ .../Extension/Groups/Fixture/UserFixture.php | 23 ++ .../Extension/Groups/GroupsExtensionTest.php | 152 +++++++++++ .../Groups/GroupsMetadataEnricherTest.php | 44 +++ .../Extension/Groups/GroupsMiddlewareTest.php | 251 ++++++++++++++++++ 16 files changed, 1030 insertions(+), 2 deletions(-) create mode 100644 docs/groups.md create mode 100644 src/Extension/Groups/Attribute/Groups.php create mode 100644 src/Extension/Groups/GroupsExtension.php create mode 100644 src/Extension/Groups/GroupsMetadataEnricher.php create mode 100644 src/Extension/Groups/GroupsMiddleware.php create mode 100644 tests/Unit/Extension/Groups/Fixture/AddressFixture.php create mode 100644 tests/Unit/Extension/Groups/Fixture/ChildFixture.php create mode 100644 tests/Unit/Extension/Groups/Fixture/NoGroupsFixture.php create mode 100644 tests/Unit/Extension/Groups/Fixture/ParentFixture.php create mode 100644 tests/Unit/Extension/Groups/Fixture/RecordingMiddleware.php create mode 100644 tests/Unit/Extension/Groups/Fixture/UserFixture.php create mode 100644 tests/Unit/Extension/Groups/GroupsExtensionTest.php create mode 100644 tests/Unit/Extension/Groups/GroupsMetadataEnricherTest.php create mode 100644 tests/Unit/Extension/Groups/GroupsMiddlewareTest.php diff --git a/docs/extensions.md b/docs/extensions.md index 9cd0f7f1..a2217f12 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -23,7 +23,7 @@ $hydrator = (new StackHydratorBuilder()) ``` ## Built-in extensions -The library ships with four extensions out of the box: +The library ships with five extensions out of the box: | Extension | Purpose | | --- | --- | @@ -31,6 +31,7 @@ The library ships with four extensions out of the box: | `LifecycleExtension` | [Lifecycle hooks](lifecycle-hooks.md), run code before and after the extract and hydrate process. | | `CryptographyExtension` | [Cryptography](cryptography.md), encrypt and decrypt sensitive data with crypto-shredding. | | `UpcastExtension` | [Upcasting](upcasting.md), reshape outdated stored data while it is hydrated. | +| `GroupsExtension` | [Groups](groups.md), extract and hydrate only the properties of selected groups. | ## Middleware diff --git a/docs/groups.md b/docs/groups.md new file mode 100644 index 00000000..ca8f7b70 --- /dev/null +++ b/docs/groups.md @@ -0,0 +1,161 @@ +# Groups + +Often you don't want to extract every property of an object, for example +when the same class is exposed to the public and to admins, or when only a +part of the data should be hydrated. The `GroupsExtension` lets you assign +properties to groups and select the groups via the context, similar to the +groups of the Symfony Serializer. + +## Setup + +Register the `GroupsExtension` on the builder: + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new GroupsExtension()) + ->build(); +``` +## Define groups + +Mark the properties with the `Groups` attribute. It accepts a single group or +a list of groups. + +```php +use Patchlevel\Hydrator\Extension\Groups\Attribute\Groups; + +final class User +{ + public function __construct( + #[Groups(['public', 'admin'])] + public string $id, + #[Groups('public')] + public string $name, + #[Groups('admin')] + public string $email, + public string $passwordHash, + ) { + } +} +``` +## Select groups + +Pass the groups with the `GroupsExtension::GROUPS` context key, either as a +string or as a list of strings. Only properties that are in at least one of +the given groups are extracted. + +```php +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; + +$hydrator->extract($user, [GroupsExtension::GROUPS => 'public']); +// ['id' => '...', 'name' => '...'] + +$hydrator->extract($user, [GroupsExtension::GROUPS => ['public', 'admin']]); +// ['id' => '...', 'name' => '...', 'email' => '...'] +``` +Without groups in the context, all properties are used, so the extension does +not change anything until you ask for it. Properties without the `Groups` +attribute are excluded as soon as groups are selected. Use the wildcard group +`GroupsExtension::ALL` (`*`) to include every property again. + +```php +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; + +$hydrator->extract($user, [GroupsExtension::GROUPS => GroupsExtension::ALL]); +``` +:::note +The context is passed on to nested objects, so their properties are filtered +by the same groups. Make sure nested classes have the `Groups` attribute as +well, otherwise they are extracted as an empty array. +::: + +## Ignore groups + +The other way around, `GroupsExtension::IGNORED_GROUPS` excludes every +property that is in at least one of the given groups. Properties without the +`Groups` attribute are kept, so you can hide a few properties without tagging +all the others. + +```php +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; + +$hydrator->extract($user, [GroupsExtension::IGNORED_GROUPS => 'admin']); +// ['name' => '...', 'passwordHash' => '...'] +``` +Both options can be combined. The ignored groups win, so a property that is in +a selected and in an ignored group is excluded. + +```php +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; + +$hydrator->extract($user, [ + GroupsExtension::GROUPS => ['public', 'admin'], + GroupsExtension::IGNORED_GROUPS => 'admin', +]); +// ['name' => '...'] +``` +## Circular references + +Since properties outside the selected groups are never read, groups are also a +way to break circular references. Put the back reference into a different +group than the forward reference: + +```php +use Patchlevel\Hydrator\Extension\Groups\Attribute\Groups; +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; + +final class Author +{ + #[Groups(['author', 'book'])] + public string $name; + + #[Groups('author')] + public Book $book; +} + +final class Book +{ + #[Groups(['author', 'book'])] + public string $title; + + #[Groups('book')] + public Author $author; +} + +$hydrator->extract($author, [GroupsExtension::GROUPS => 'author']); +// ['name' => '...', 'book' => ['title' => '...']] +``` +:::warning +Without groups, or with the wildcard group, every property is followed again +and a `CircularReference` exception is thrown as usual. +::: + +## Hydrate with groups + +Groups also work while hydrating. Fields of properties outside the selected +groups are ignored, even if they are present in the data. Promoted properties +with a default value fall back to it, all others stay uninitialized. + +```php +use Patchlevel\Hydrator\Extension\Groups\GroupsExtension; + +$user = $hydrator->hydrate( + User::class, + ['id' => '1', 'name' => 'John', 'email' => 'john@example.com'], + [GroupsExtension::GROUPS => 'public'], +); +``` +:::tip +Combine it with `Hydrator::OBJECT_TO_POPULATE` to update only the properties +of a specific group on an existing object. +::: + +## Learn more + +* [How extensions and middlewares work](extensions.md) +* [How to use the hydrator](hydrator.md) +* [How to ignore a property completely](hydrator.md#ignore-properties) diff --git a/docs/project.json b/docs/project.json index 5fae563a..66455feb 100644 --- a/docs/project.json +++ b/docs/project.json @@ -18,7 +18,8 @@ "subEntries": [ { "title": "Lifecycle Hooks", "file": "lifecycle-hooks.md" }, { "title": "Cryptography", "file": "cryptography.md" }, - { "title": "Upcasting", "file": "upcasting.md" } + { "title": "Upcasting", "file": "upcasting.md" }, + { "title": "Groups", "file": "groups.md" } ] }, { diff --git a/src/Extension/Groups/Attribute/Groups.php b/src/Extension/Groups/Attribute/Groups.php new file mode 100644 index 00000000..40cd6f8d --- /dev/null +++ b/src/Extension/Groups/Attribute/Groups.php @@ -0,0 +1,23 @@ + */ + public readonly array $groups; + + /** @param string|array $groups */ + public function __construct(string|array $groups) + { + $this->groups = is_string($groups) ? [$groups] : array_values($groups); + } +} diff --git a/src/Extension/Groups/GroupsExtension.php b/src/Extension/Groups/GroupsExtension.php new file mode 100644 index 00000000..d178bb8e --- /dev/null +++ b/src/Extension/Groups/GroupsExtension.php @@ -0,0 +1,26 @@ +addMiddleware(new GroupsMiddleware(), Extension::PRIORITY_BEFORE_TRANSFORM); + $builder->addMetadataEnricher(new GroupsMetadataEnricher()); + } +} diff --git a/src/Extension/Groups/GroupsMetadataEnricher.php b/src/Extension/Groups/GroupsMetadataEnricher.php new file mode 100644 index 00000000..6451419f --- /dev/null +++ b/src/Extension/Groups/GroupsMetadataEnricher.php @@ -0,0 +1,35 @@ +properties as $property) { + $attributeReflectionList = $property->reflection->getAttributes(Groups::class); + + if ($attributeReflectionList === []) { + continue; + } + + $property->extras[Groups::class] = $attributeReflectionList[0]->newInstance()->groups; + $hasGroups = true; + } + + if (!$hasGroups) { + return; + } + + // lets the middleware skip the filtering for classes without any groups + $classMetadata->extras[Groups::class] = true; + } +} diff --git a/src/Extension/Groups/GroupsMiddleware.php b/src/Extension/Groups/GroupsMiddleware.php new file mode 100644 index 00000000..d0cb199c --- /dev/null +++ b/src/Extension/Groups/GroupsMiddleware.php @@ -0,0 +1,197 @@ +}> */ + private array $selections = []; + + /** + * @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 + { + if (!isset($context[GroupsExtension::GROUPS]) && !isset($context[GroupsExtension::IGNORED_GROUPS])) { + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + + foreach ($this->selection($metadata, $context)[1] as $fieldName) { + unset($data[$fieldName]); + } + + 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 + { + if (!isset($context[GroupsExtension::GROUPS]) && !isset($context[GroupsExtension::IGNORED_GROUPS])) { + return $stack->next()->extract($metadata, $object, $context, $stack); + } + + return $stack->next()->extract($this->selection($metadata, $context)[0], $object, $context, $stack); + } + + /** + * Returns the filtered metadata and the excluded field names. + * + * @param ClassMetadata $metadata + * @param array $context + * + * @return array{ClassMetadata, list} + * + * @template T of object + */ + private function selection(ClassMetadata $metadata, array $context): array + { + $groups = $context[GroupsExtension::GROUPS] ?? null; + + // ignored groups can't match a class without any groups + if ($groups === null && !isset($metadata->extras[Groups::class])) { + return [$metadata, []]; + } + + $ignoredGroups = $context[GroupsExtension::IGNORED_GROUPS] ?? null; + $key = $metadata->className . "\0" . $this->key($groups) . "\0" . $this->key($ignoredGroups); + + /** @var array{ClassMetadata, list} $selection */ + $selection = $this->selections[$key] ??= $this->createSelection( + $metadata, + $this->normalize($groups), + $this->normalize($ignoredGroups), + ); + + return $selection; + } + + /** + * @param ClassMetadata $metadata + * @param list|null $groups + * @param list|null $ignoredGroups + * + * @return array{ClassMetadata, list} + * + * @template T of object + */ + private function createSelection(ClassMetadata $metadata, array|null $groups, array|null $ignoredGroups): array + { + if ($groups !== null && in_array(GroupsExtension::ALL, $groups, true)) { + $groups = null; + } + + if ($groups === null && $ignoredGroups === null) { + return [$metadata, []]; + } + + $properties = []; + $excludedFields = []; + + foreach ($metadata->properties as $propertyMetadata) { + /** @var list $propertyGroups */ + $propertyGroups = $propertyMetadata->extras[Groups::class] ?? []; + + if ( + ($groups !== null && array_intersect($propertyGroups, $groups) === []) + || ($ignoredGroups !== null && array_intersect($propertyGroups, $ignoredGroups) !== []) + ) { + $excludedFields[] = $propertyMetadata->fieldName; + + continue; + } + + $properties[] = $propertyMetadata; + } + + if ($excludedFields === []) { + return [$metadata, []]; + } + + return [ + new ClassMetadata( + $metadata->reflection, + $metadata->normalizer, + $properties, + $metadata->lazy, + $metadata->extras, + ), + $excludedFields, + ]; + } + + /** + * Builds a cheap cache key from the raw context value, values with the same key normalize to the same groups. + */ + private function key(mixed $groups): string + { + if (is_string($groups)) { + return "\1" . $groups; + } + + if (!is_array($groups)) { + return ''; + } + + $key = ''; + + foreach ($groups as $group) { + if (!is_string($group)) { + continue; + } + + $key .= "\1" . $group; + } + + return $key; + } + + /** @return list|null */ + private function normalize(mixed $groups): array|null + { + if (is_string($groups)) { + return [$groups]; + } + + if (!is_array($groups)) { + return null; + } + + $result = []; + + foreach ($groups as $group) { + if (!is_string($group)) { + continue; + } + + $result[] = $group; + } + + return $result === [] ? null : $result; + } +} diff --git a/tests/Unit/Extension/Groups/Fixture/AddressFixture.php b/tests/Unit/Extension/Groups/Fixture/AddressFixture.php new file mode 100644 index 00000000..ac175ce1 --- /dev/null +++ b/tests/Unit/Extension/Groups/Fixture/AddressFixture.php @@ -0,0 +1,18 @@ + */ + public array $data = []; + + /** + * @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 + { + $this->metadata = $metadata; + $this->data = $data; + + return $metadata->newInstance(); + } + + /** + * @param array $context + * + * @return array + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + $this->metadata = $metadata; + + return []; + } +} diff --git a/tests/Unit/Extension/Groups/Fixture/UserFixture.php b/tests/Unit/Extension/Groups/Fixture/UserFixture.php new file mode 100644 index 00000000..fbada20b --- /dev/null +++ b/tests/Unit/Extension/Groups/Fixture/UserFixture.php @@ -0,0 +1,23 @@ + '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => ['city' => 'Berlin', 'street' => 'Main Street'], + 'internal' => 'secret', + ], + $this->hydrator()->extract($this->user()), + ); + } + + public function testExtractWithGroup(): void + { + self::assertSame( + [ + 'id' => '1', + 'name' => 'John', + 'address' => ['city' => 'Berlin'], + ], + $this->hydrator()->extract($this->user(), [GroupsExtension::GROUPS => 'public']), + ); + } + + public function testExtractWithMultipleGroups(): void + { + self::assertSame( + [ + 'id' => '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => ['city' => 'Berlin', 'street' => 'Main Street'], + ], + $this->hydrator()->extract($this->user(), [GroupsExtension::GROUPS => ['public', 'admin']]), + ); + } + + public function testExtractWithAllGroup(): void + { + self::assertSame( + [ + 'id' => '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => ['city' => 'Berlin', 'street' => 'Main Street'], + 'internal' => 'secret', + ], + $this->hydrator()->extract($this->user(), [GroupsExtension::GROUPS => GroupsExtension::ALL]), + ); + } + + public function testExtractWithIgnoredGroup(): void + { + self::assertSame( + [ + 'name' => 'John', + 'internal' => 'secret', + ], + $this->hydrator()->extract($this->user(), [GroupsExtension::IGNORED_GROUPS => 'admin']), + ); + } + + public function testHydrateWithGroup(): void + { + $user = $this->hydrator()->hydrate( + UserFixture::class, + [ + 'id' => '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => ['city' => 'Berlin', 'street' => 'Main Street'], + 'internal' => 'secret', + ], + [GroupsExtension::GROUPS => ['public']], + ); + + self::assertSame('1', $user->id); + self::assertSame('John', $user->name); + self::assertSame('Berlin', $user->address->city); + self::assertSame('default', $user->internal); + self::assertFalse(isset($user->email)); + self::assertFalse(isset($user->address->street)); + } + + public function testGroupsBreakCircularReference(): void + { + $parent = new ParentFixture('parent'); + $parent->child = new ChildFixture('child', $parent); + + self::assertSame( + ['name' => 'parent', 'child' => ['name' => 'child']], + $this->hydrator()->extract($parent, [GroupsExtension::GROUPS => 'parent']), + ); + + self::assertSame( + ['name' => 'child', 'parent' => ['name' => 'parent']], + $this->hydrator()->extract($parent->child, [GroupsExtension::GROUPS => 'child']), + ); + } + + public function testCircularReferenceWithoutGroups(): void + { + $parent = new ParentFixture('parent'); + $parent->child = new ChildFixture('child', $parent); + + $this->expectException(CircularReference::class); + + $this->hydrator()->extract($parent); + } + + private function hydrator(): StackHydrator + { + return (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new GroupsExtension()) + ->build(); + } + + private function user(): UserFixture + { + return new UserFixture( + '1', + 'John', + 'john@example.com', + new AddressFixture('Berlin', 'Main Street'), + 'secret', + ); + } +} diff --git a/tests/Unit/Extension/Groups/GroupsMetadataEnricherTest.php b/tests/Unit/Extension/Groups/GroupsMetadataEnricherTest.php new file mode 100644 index 00000000..9ea1e9c0 --- /dev/null +++ b/tests/Unit/Extension/Groups/GroupsMetadataEnricherTest.php @@ -0,0 +1,44 @@ +metadata(UserFixture::class); + + (new GroupsMetadataEnricher())->enrich($metadata); + + self::assertSame(['public', 'admin'], $metadata->properties['id']->extras[Groups::class]); + self::assertSame(['public'], $metadata->properties['name']->extras[Groups::class]); + self::assertSame(['admin'], $metadata->properties['email']->extras[Groups::class]); + self::assertArrayNotHasKey(Groups::class, $metadata->properties['internal']->extras); + self::assertTrue($metadata->extras[Groups::class]); + } + + public function testClassWithoutGroups(): void + { + $object = new class { + public string $name = 'foo'; + }; + + $metadata = (new AttributeMetadataFactory())->metadata($object::class); + + (new GroupsMetadataEnricher())->enrich($metadata); + + self::assertArrayNotHasKey(Groups::class, $metadata->extras); + self::assertArrayNotHasKey(Groups::class, $metadata->properties['name']->extras); + } +} diff --git a/tests/Unit/Extension/Groups/GroupsMiddlewareTest.php b/tests/Unit/Extension/Groups/GroupsMiddlewareTest.php new file mode 100644 index 00000000..682ae2da --- /dev/null +++ b/tests/Unit/Extension/Groups/GroupsMiddlewareTest.php @@ -0,0 +1,251 @@ +}> */ + public static function provideGroups(): iterable + { + yield 'no groups' => [null, ['id', 'name', 'email', 'address', 'internal']]; + yield 'empty list' => [[], ['id', 'name', 'email', 'address', 'internal']]; + yield 'invalid value' => [42, ['id', 'name', 'email', 'address', 'internal']]; + yield 'only invalid entries' => [[42, null], ['id', 'name', 'email', 'address', 'internal']]; + yield 'string' => ['admin', ['id', 'email', 'address']]; + yield 'list' => [['public'], ['id', 'name', 'address']]; + yield 'invalid entries are ignored' => [['public', 42], ['id', 'name', 'address']]; + yield 'unknown group' => [['unknown'], []]; + yield 'all' => [['unknown', GroupsExtension::ALL], ['id', 'name', 'email', 'address', 'internal']]; + } + + /** @param list $expectedProperties */ + #[DataProvider('provideGroups')] + public function testExtract(mixed $groups, array $expectedProperties): void + { + $inner = new RecordingMiddleware(); + + (new GroupsMiddleware())->extract( + $this->metadata(), + $this->user(), + [GroupsExtension::GROUPS => $groups], + new Stack([$inner]), + ); + + self::assertNotNull($inner->metadata); + self::assertSame($expectedProperties, array_keys($inner->metadata->properties)); + } + + /** @param list $expectedProperties */ + #[DataProvider('provideGroups')] + public function testHydrate(mixed $groups, array $expectedProperties): void + { + $inner = new RecordingMiddleware(); + $metadata = $this->metadata(); + + (new GroupsMiddleware())->hydrate( + $metadata, + [ + 'id' => '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => [], + 'internal' => 'secret', + 'unknown' => 'value', + ], + [GroupsExtension::GROUPS => $groups], + new Stack([$inner]), + ); + + self::assertSame($metadata, $inner->metadata); + + // fields without a property are not touched + self::assertSame([...$expectedProperties, 'unknown'], array_keys($inner->data)); + } + + /** @return iterable}> */ + public static function provideIgnoredGroups(): iterable + { + yield 'string' => [null, 'admin', ['name', 'internal']]; + yield 'list' => [null, ['admin', 'public'], ['internal']]; + yield 'unknown group' => [null, ['unknown'], ['id', 'name', 'email', 'address', 'internal']]; + yield 'empty list' => [null, [], ['id', 'name', 'email', 'address', 'internal']]; + yield 'with groups' => ['public', 'admin', ['name']]; + yield 'with all' => [GroupsExtension::ALL, 'admin', ['name', 'internal']]; + } + + /** @param list $expectedProperties */ + #[DataProvider('provideIgnoredGroups')] + public function testExtractWithIgnoredGroups(mixed $groups, mixed $ignoredGroups, array $expectedProperties): void + { + $inner = new RecordingMiddleware(); + + (new GroupsMiddleware())->extract( + $this->metadata(), + $this->user(), + [ + GroupsExtension::GROUPS => $groups, + GroupsExtension::IGNORED_GROUPS => $ignoredGroups, + ], + new Stack([$inner]), + ); + + self::assertNotNull($inner->metadata); + self::assertSame($expectedProperties, array_keys($inner->metadata->properties)); + } + + /** @param list $expectedProperties */ + #[DataProvider('provideIgnoredGroups')] + public function testHydrateWithIgnoredGroups(mixed $groups, mixed $ignoredGroups, array $expectedProperties): void + { + $inner = new RecordingMiddleware(); + + (new GroupsMiddleware())->hydrate( + $this->metadata(), + [ + 'id' => '1', + 'name' => 'John', + 'email' => 'john@example.com', + 'address' => [], + 'internal' => 'secret', + ], + [ + GroupsExtension::GROUPS => $groups, + GroupsExtension::IGNORED_GROUPS => $ignoredGroups, + ], + new Stack([$inner]), + ); + + self::assertSame($expectedProperties, array_keys($inner->data)); + } + + /** @return iterable}> */ + public static function provideClassWithoutGroups(): iterable + { + yield 'groups' => ['public', null, []]; + yield 'all' => [GroupsExtension::ALL, null, ['name', 'age']]; + yield 'ignored groups' => [null, 'admin', ['name', 'age']]; + yield 'groups and ignored groups' => ['public', 'admin', []]; + } + + /** @param list $expectedProperties */ + #[DataProvider('provideClassWithoutGroups')] + public function testExtractClassWithoutGroups(mixed $groups, mixed $ignoredGroups, array $expectedProperties): void + { + $inner = new RecordingMiddleware(); + + (new GroupsMiddleware())->extract( + $this->noGroupsMetadata(), + new NoGroupsFixture(), + [ + GroupsExtension::GROUPS => $groups, + GroupsExtension::IGNORED_GROUPS => $ignoredGroups, + ], + new Stack([$inner]), + ); + + self::assertNotNull($inner->metadata); + self::assertSame($expectedProperties, array_keys($inner->metadata->properties)); + } + + /** @param list $expectedProperties */ + #[DataProvider('provideClassWithoutGroups')] + public function testHydrateClassWithoutGroups(mixed $groups, mixed $ignoredGroups, array $expectedProperties): void + { + $inner = new RecordingMiddleware(); + $metadata = $this->noGroupsMetadata(); + + (new GroupsMiddleware())->hydrate( + $metadata, + ['name' => 'John', 'age' => 42], + [ + GroupsExtension::GROUPS => $groups, + GroupsExtension::IGNORED_GROUPS => $ignoredGroups, + ], + new Stack([$inner]), + ); + + self::assertSame($metadata, $inner->metadata); + self::assertSame($expectedProperties, array_keys($inner->data)); + } + + public function testSelectionIsReused(): void + { + $middleware = new GroupsMiddleware(); + $metadata = $this->metadata(); + + $first = new RecordingMiddleware(); + $middleware->extract($metadata, $this->user(), [GroupsExtension::GROUPS => 'public'], new Stack([$first])); + + $second = new RecordingMiddleware(); + $middleware->extract($metadata, $this->user(), [GroupsExtension::GROUPS => ['public']], new Stack([$second])); + + $third = new RecordingMiddleware(); + $middleware->extract( + $metadata, + $this->user(), + [GroupsExtension::GROUPS => 'public', GroupsExtension::IGNORED_GROUPS => 'admin'], + new Stack([$third]), + ); + + self::assertNotSame($metadata, $first->metadata); + self::assertSame($first->metadata, $second->metadata); + self::assertNotSame($first->metadata, $third->metadata); + } + + public function testEmptyStringGroupIsNotTreatedAsMissing(): void + { + $inner = new RecordingMiddleware(); + + (new GroupsMiddleware())->extract( + $this->metadata(), + $this->user(), + [GroupsExtension::GROUPS => ''], + new Stack([$inner]), + ); + + self::assertNotNull($inner->metadata); + self::assertSame([], $inner->metadata->properties); + } + + /** @return ClassMetadata */ + private function metadata(): ClassMetadata + { + $metadata = (new AttributeMetadataFactory())->metadata(UserFixture::class); + (new GroupsMetadataEnricher())->enrich($metadata); + + return $metadata; + } + + /** @return ClassMetadata */ + private function noGroupsMetadata(): ClassMetadata + { + $metadata = (new AttributeMetadataFactory())->metadata(NoGroupsFixture::class); + (new GroupsMetadataEnricher())->enrich($metadata); + + return $metadata; + } + + private function user(): UserFixture + { + return new UserFixture('1', 'John', 'john@example.com', new AddressFixture('Berlin', 'Main Street')); + } +} From fd4b2fd2fbc9bf879e81e8fccec9822d30e4677c Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Tue, 6 Oct 2026 21:20:55 +0200 Subject: [PATCH 2/6] Fix cache keys of the metadata factories The PSR-6 and PSR-16 metadata factories used the class name as cache key. Class names contain a backslash, which is a reserved character in PSR-6 and PSR-16, so the factories failed with any compliant cache like symfony/cache. The class name is now hashed and prefixed, and a cache hit is only accepted if the cached metadata really belongs to the requested class. Unserialized metadata also missed some values, so metadata loaded from a cache failed as soon as they were accessed: the class name of the class metadata, and the property name, type and personal data fallback callable of the property metadata. --- src/Metadata/CacheKey.php | 22 ++++++++ src/Metadata/ClassMetadata.php | 1 + src/Metadata/PropertyMetadata.php | 9 ++- src/Metadata/Psr16MetadataFactory.php | 7 +-- src/Metadata/Psr6MetadataFactory.php | 7 ++- tests/Unit/Metadata/CacheKeyTest.php | 22 ++++++++ tests/Unit/Metadata/ClassMetadataTest.php | 29 ++++++++++ tests/Unit/Metadata/PropertyMetadataTest.php | 45 +++++++++++++++ .../Metadata/Psr16MetadataFactoryTest.php | 49 ++++++++++++++++- .../Unit/Metadata/Psr6MetadataFactoryTest.php | 55 ++++++++++++++++++- 10 files changed, 234 insertions(+), 12 deletions(-) create mode 100644 src/Metadata/CacheKey.php create mode 100644 tests/Unit/Metadata/CacheKeyTest.php create mode 100644 tests/Unit/Metadata/ClassMetadataTest.php create mode 100644 tests/Unit/Metadata/PropertyMetadataTest.php diff --git a/src/Metadata/CacheKey.php b/src/Metadata/CacheKey.php new file mode 100644 index 00000000..95f07492 --- /dev/null +++ b/src/Metadata/CacheKey.php @@ -0,0 +1,22 @@ +reflection = new ReflectionClass($data['className']); + $this->className = $data['className']; $this->properties = $data['properties']; $this->dataSubjectIdField = $data['dataSubjectIdField']; $this->postHydrateCallbacks = $data['postHydrateCallbacks']; diff --git a/src/Metadata/PropertyMetadata.php b/src/Metadata/PropertyMetadata.php index 5577271e..cca80931 100644 --- a/src/Metadata/PropertyMetadata.php +++ b/src/Metadata/PropertyMetadata.php @@ -20,7 +20,9 @@ * normalizer: Normalizer|null, * isPersonalData: bool, * personalDataFallback: mixed, - * extras: array + * personalDataFallbackCallable: (callable(string, mixed):mixed)|null, + * extras: array, + * type: Type|null * } */ final class PropertyMetadata @@ -115,7 +117,9 @@ public function __serialize(): array 'normalizer' => $this->normalizer, 'isPersonalData' => $this->isPersonalData, 'personalDataFallback' => $this->personalDataFallback, + 'personalDataFallbackCallable' => $this->personalDataFallbackCallable, 'extras' => $this->extras, + 'type' => $this->type, ]; } @@ -123,10 +127,13 @@ public function __serialize(): array public function __unserialize(array $data): void { $this->reflection = new ReflectionProperty($data['className'], $data['property']); + $this->propertyName = $data['property']; $this->fieldName = $data['fieldName']; $this->normalizer = $data['normalizer']; $this->isPersonalData = $data['isPersonalData']; $this->personalDataFallback = $data['personalDataFallback']; + $this->personalDataFallbackCallable = $data['personalDataFallbackCallable']; $this->extras = $data['extras']; + $this->type = $data['type']; } } diff --git a/src/Metadata/Psr16MetadataFactory.php b/src/Metadata/Psr16MetadataFactory.php index 603e674f..f8ec5901 100644 --- a/src/Metadata/Psr16MetadataFactory.php +++ b/src/Metadata/Psr16MetadataFactory.php @@ -23,16 +23,15 @@ public function __construct( */ public function metadata(string $class): ClassMetadata { - /** @var ?ClassMetadata $metadata */ - $metadata = $this->cache->get($class); + $metadata = $this->cache->get(CacheKey::forClass($class)); - if ($metadata !== null) { + if ($metadata instanceof ClassMetadata && $metadata->className === $class) { return $metadata; } $metadata = $this->metadataFactory->metadata($class); - $this->cache->set($class, $metadata); + $this->cache->set(CacheKey::forClass($class), $metadata); return $metadata; } diff --git a/src/Metadata/Psr6MetadataFactory.php b/src/Metadata/Psr6MetadataFactory.php index f2cf0b5a..907eca1e 100644 --- a/src/Metadata/Psr6MetadataFactory.php +++ b/src/Metadata/Psr6MetadataFactory.php @@ -23,13 +23,14 @@ public function __construct( */ public function metadata(string $class): ClassMetadata { - $item = $this->cache->getItem($class); + $item = $this->cache->getItem(CacheKey::forClass($class)); if ($item->isHit()) { - /** @var ClassMetadata $data */ $data = $item->get(); - return $data; + if ($data instanceof ClassMetadata && $data->className === $class) { + return $data; + } } $metadata = $this->metadataFactory->metadata($class); diff --git a/tests/Unit/Metadata/CacheKeyTest.php b/tests/Unit/Metadata/CacheKeyTest.php new file mode 100644 index 00000000..42d6476b --- /dev/null +++ b/tests/Unit/Metadata/CacheKeyTest.php @@ -0,0 +1,22 @@ +metadata(ProfileCreated::class); + + $unserialized = unserialize(serialize($classMetadata)); + + self::assertInstanceOf(ClassMetadata::class, $unserialized); + self::assertSame(ProfileCreated::class, $unserialized->className); + self::assertEquals($classMetadata, $unserialized); + } +} diff --git a/tests/Unit/Metadata/PropertyMetadataTest.php b/tests/Unit/Metadata/PropertyMetadataTest.php new file mode 100644 index 00000000..b95d458e --- /dev/null +++ b/tests/Unit/Metadata/PropertyMetadataTest.php @@ -0,0 +1,45 @@ + 'bar'], + Type::object(Email::class), + ); + + $unserialized = unserialize(serialize($propertyMetadata)); + + self::assertInstanceOf(PropertyMetadata::class, $unserialized); + self::assertSame('email', $unserialized->propertyName()); + self::assertEquals($propertyMetadata, $unserialized); + $callback = $unserialized->personalDataFallbackCallback(); + + self::assertNotNull($callback); + self::assertEquals(new Email('foo@example.com'), $callback('foo', null)); + } +} diff --git a/tests/Unit/Metadata/Psr16MetadataFactoryTest.php b/tests/Unit/Metadata/Psr16MetadataFactoryTest.php index 6f356bc8..4fd5740a 100644 --- a/tests/Unit/Metadata/Psr16MetadataFactoryTest.php +++ b/tests/Unit/Metadata/Psr16MetadataFactoryTest.php @@ -4,14 +4,19 @@ namespace Patchlevel\Hydrator\Tests\Unit\Metadata; +use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; +use Patchlevel\Hydrator\Metadata\CacheKey; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Metadata\MetadataFactory; use Patchlevel\Hydrator\Metadata\Psr16MetadataFactory; +use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Psr\SimpleCache\CacheInterface; use ReflectionClass; use stdClass; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Psr16Cache; #[CoversClass(Psr16MetadataFactory::class)] final class Psr16MetadataFactoryTest extends TestCase @@ -23,7 +28,7 @@ public function testMetadataWithHit(): void $cache = $this->createMock(CacheInterface::class); $cache->expects(self::once()) ->method('get') - ->with(stdClass::class) + ->with(CacheKey::forClass(stdClass::class)) ->willReturn($classMetadata); $innerFactory = $this->createMock(MetadataFactory::class); @@ -43,11 +48,37 @@ public function testMetadataWithMiss(): void $cache = $this->createMock(CacheInterface::class); $cache->expects(self::once()) ->method('get') - ->with(stdClass::class) + ->with(CacheKey::forClass(stdClass::class)) ->willReturn(null); $cache->expects(self::once()) ->method('set') - ->with(stdClass::class, $classMetadata); + ->with(CacheKey::forClass(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); + } + + public function testMetadataIgnoresEntryOfOtherClass(): void + { + $otherMetadata = new ClassMetadata(new ReflectionClass(ProfileCreated::class)); + $classMetadata = new ClassMetadata(new ReflectionClass(stdClass::class)); + + $cache = $this->createMock(CacheInterface::class); + $cache->expects(self::once()) + ->method('get') + ->with(CacheKey::forClass(stdClass::class)) + ->willReturn($otherMetadata); + $cache->expects(self::once()) + ->method('set') + ->with(CacheKey::forClass(stdClass::class), $classMetadata); $innerFactory = $this->createMock(MetadataFactory::class); $innerFactory->expects(self::once()) @@ -60,4 +91,16 @@ public function testMetadataWithMiss(): void self::assertSame($classMetadata, $result); } + + public function testMetadataWithSymfonyCache(): void + { + $cache = new Psr16Cache(new ArrayAdapter()); + $factory = new Psr16MetadataFactory(new AttributeMetadataFactory(), $cache); + + $metadata = $factory->metadata(ProfileCreated::class); + + self::assertSame(ProfileCreated::class, $metadata->className); + self::assertTrue($cache->has(CacheKey::forClass(ProfileCreated::class))); + self::assertEquals($metadata, $factory->metadata(ProfileCreated::class)); + } } diff --git a/tests/Unit/Metadata/Psr6MetadataFactoryTest.php b/tests/Unit/Metadata/Psr6MetadataFactoryTest.php index 0d6d5f24..e72762fa 100644 --- a/tests/Unit/Metadata/Psr6MetadataFactoryTest.php +++ b/tests/Unit/Metadata/Psr6MetadataFactoryTest.php @@ -4,15 +4,19 @@ namespace Patchlevel\Hydrator\Tests\Unit\Metadata; +use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; +use Patchlevel\Hydrator\Metadata\CacheKey; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Metadata\MetadataFactory; use Patchlevel\Hydrator\Metadata\Psr6MetadataFactory; +use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; use ReflectionClass; use stdClass; +use Symfony\Component\Cache\Adapter\ArrayAdapter; #[CoversClass(Psr6MetadataFactory::class)] final class Psr6MetadataFactoryTest extends TestCase @@ -32,7 +36,7 @@ public function testMetadataWithHit(): void $cache = $this->createMock(CacheItemPoolInterface::class); $cache->expects(self::once()) ->method('getItem') - ->with(stdClass::class) + ->with(CacheKey::forClass(stdClass::class)) ->willReturn($item); $innerFactory = $this->createMock(MetadataFactory::class); @@ -60,7 +64,44 @@ public function testMetadataWithMiss(): void $cache = $this->createMock(CacheItemPoolInterface::class); $cache->expects(self::once()) ->method('getItem') + ->with(CacheKey::forClass(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); + } + + public function testMetadataIgnoresEntryOfOtherClass(): void + { + $otherMetadata = new ClassMetadata(new ReflectionClass(ProfileCreated::class)); + $classMetadata = new ClassMetadata(new ReflectionClass(stdClass::class)); + + $item = $this->createMock(CacheItemInterface::class); + $item->expects(self::once()) + ->method('isHit') + ->willReturn(true); + $item->expects(self::once()) + ->method('get') + ->willReturn($otherMetadata); + $item->expects(self::once()) + ->method('set') + ->with($classMetadata); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $cache->expects(self::once()) + ->method('getItem') + ->with(CacheKey::forClass(stdClass::class)) ->willReturn($item); $cache->expects(self::once()) ->method('save') @@ -77,4 +118,16 @@ public function testMetadataWithMiss(): void self::assertSame($classMetadata, $result); } + + public function testMetadataWithSymfonyCache(): void + { + $cache = new ArrayAdapter(); + $factory = new Psr6MetadataFactory(new AttributeMetadataFactory(), $cache); + + $metadata = $factory->metadata(ProfileCreated::class); + + self::assertSame(ProfileCreated::class, $metadata->className); + self::assertTrue($cache->hasItem(CacheKey::forClass(ProfileCreated::class))); + self::assertEquals($metadata, $factory->metadata(ProfileCreated::class)); + } } From dd0be1f8eca325a5a210a1a4c48eea9eba731c90 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:08:18 +0000 Subject: [PATCH 3/6] Update shivammathur/setup-php action to v2.40.0 | datasource | package | from | to | | ----------- | ---------------------- | ------ | ------ | | github-tags | shivammathur/setup-php | 2.37.2 | 2.40.0 | Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/backward-compatibility-check.yml | 2 +- .github/workflows/benchmark.yml | 2 +- .github/workflows/coding-standard.yml | 2 +- .github/workflows/docs-check.yml | 2 +- .github/workflows/mutation-tests-diff.yml | 2 +- .github/workflows/mutation-tests.yml | 2 +- .github/workflows/phpstan.yml | 2 +- .github/workflows/unit.yml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/backward-compatibility-check.yml b/.github/workflows/backward-compatibility-check.yml index e4cccf12..e086865c 100644 --- a/.github/workflows/backward-compatibility-check.yml +++ b/.github/workflows/backward-compatibility-check.yml @@ -27,7 +27,7 @@ jobs: fetch-depth: 0 - name: "Install PHP" - uses: "shivammathur/setup-php@2.37.2" + uses: "shivammathur/setup-php@2.40.0" with: php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 23ef92b0..4b6f2aba 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -22,7 +22,7 @@ jobs: steps: - name: "Install PHP" - uses: "shivammathur/setup-php@2.37.2" + uses: "shivammathur/setup-php@2.40.0" with: coverage: "none" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/coding-standard.yml b/.github/workflows/coding-standard.yml index ebe345c9..a4e67668 100644 --- a/.github/workflows/coding-standard.yml +++ b/.github/workflows/coding-standard.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@v7 - name: "Install PHP" - uses: "shivammathur/setup-php@2.37.2" + uses: "shivammathur/setup-php@2.40.0" with: php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index b6c8c9fb..d7e13bdd 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -28,7 +28,7 @@ jobs: uses: actions/checkout@v7 - name: "Install PHP" - uses: "shivammathur/setup-php@2.37.2" + uses: "shivammathur/setup-php@2.40.0" with: coverage: none php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/mutation-tests-diff.yml b/.github/workflows/mutation-tests-diff.yml index 2add7db0..999fe818 100644 --- a/.github/workflows/mutation-tests-diff.yml +++ b/.github/workflows/mutation-tests-diff.yml @@ -27,7 +27,7 @@ jobs: fetch-depth: 0 - name: "Install PHP" - uses: "shivammathur/setup-php@2.37.2" + uses: "shivammathur/setup-php@2.40.0" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index 22599648..bc7e3020 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@v7 - name: "Install PHP" - uses: "shivammathur/setup-php@2.37.2" + uses: "shivammathur/setup-php@2.40.0" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index a658039f..c4ed1073 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@v7 - name: "Install PHP" - uses: "shivammathur/setup-php@2.37.2" + uses: "shivammathur/setup-php@2.40.0" with: php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 1396ecc3..76f87457 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -46,7 +46,7 @@ jobs: uses: actions/checkout@v7 - name: "Install PHP" - uses: "shivammathur/setup-php@2.37.2" + uses: "shivammathur/setup-php@2.40.0" with: coverage: "pcov" php-version: "${{ matrix.php-version }}" From c34894c75fe663045ccc710c9133079f846f5601 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Fri, 16 Oct 2026 14:12:11 +0200 Subject: [PATCH 4/6] Show the benchmark results as markdown tables and fail on regressions The pull request comment shows one markdown table per benchmark class with the base time, the current time and the difference, instead of the raw console output in a collapsed block. phpbench runs 5 iterations with opcache enabled and the JIT disabled, and asserts that no benchmark gets more than 25% slower than the base. The job fails after the comment was written if one does. The single object benchmarks run 1000 revs, the 1,000,000 object benchmarks are removed, they took most of the time and measure the same per object as the 1000 object benchmarks. HydratorBench and HydratorWithCryptographyBench were copies of the StackHydrator benchmarks and are removed, HydratorWithLazyBench is renamed to StackHydratorWithLazyBench. --- .github/workflows/benchmark.yml | 62 +++---- Makefile | 12 +- phpbench.json | 77 ++++----- tests/Benchmark/HydratorBench.php | 135 --------------- .../HydratorWithCryptographyBench.php | 154 ------------------ tests/Benchmark/StackHydratorBench.php | 36 +--- .../StackHydratorWithCryptographyBench.php | 39 +---- ...nch.php => StackHydratorWithLazyBench.php} | 38 +---- 8 files changed, 75 insertions(+), 478 deletions(-) delete mode 100644 tests/Benchmark/HydratorBench.php delete mode 100644 tests/Benchmark/HydratorWithCryptographyBench.php rename tests/Benchmark/{HydratorWithLazyBench.php => StackHydratorWithLazyBench.php} (72%) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 4b6f2aba..12d36ad5 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -26,7 +26,7 @@ jobs: with: coverage: "none" php-version: "${{ matrix.php-version }}" - ini-values: memory_limit=-1, opcache.enable_cli=1, opcache.jit=tracing, opcache.jit_buffer_size=64M + ini-values: memory_limit=-1 - name: "Checkout base" uses: actions/checkout@v7 @@ -38,7 +38,7 @@ jobs: dependency-versions: ${{ matrix.dependencies }} - name: "phpbench on base" - run: "vendor/bin/phpbench run tests/Benchmark --progress=none --report=default --tag=base" + run: "vendor/bin/phpbench run tests/Benchmark --progress=none --tag=base" - name: "Checkout" uses: actions/checkout@v7 @@ -50,18 +50,35 @@ jobs: dependency-versions: ${{ matrix.dependencies }} - name: "phpbench diff" + id: bench + continue-on-error: true run: "vendor/bin/phpbench run tests/Benchmark --progress=none --report=diff --ref=base > bench.txt" - - name: "Get Bench Result" - id: phpbench - run: | - echo 'BENCH_RESULT<> $GITHUB_ENV - cat bench.txt >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV - - uses: actions/github-script@v9 with: script: | + const fs = require('fs'); + + // the diff report prints one section per benchmark class: a title line, a console table, a blank line. + // The console table becomes a markdown table by dropping the outer borders and turning the border + // below the header into the separator row. + const sections = fs.readFileSync('bench.txt', 'utf8').trim().split(/\n\s*\n/).map((section) => { + const [title, , header, separator, ...rows] = section.trim().split('\n'); + const table = [header, separator.replace(/\+/g, '|'), ...rows.filter((row) => !row.startsWith('+'))]; + + return `### ${title.replace(/Bench$/, '')}\n\n${table.join('\n')}`; + }).join('\n\n'); + + const failed = ${{ steps.bench.outcome == 'failure' }}; + const commentBody = `Hello :wave: + + Here are the most recent benchmark results, compared against \`${{ github.base_ref }}\`. Time is the mode over the iterations in the \`its\` column, with the relative standard deviation in brackets. + + ${sections} + + ${failed ? ':warning: At least one benchmark got slower than the tolerance in phpbench.json allows.' : ''} + This comment gets updated every time a new commit comes in.`; + // Get the existing comments. const {data: comments} = await github.rest.issues.listComments({ owner: context.repo.owner, @@ -69,27 +86,8 @@ jobs: issue_number: context.payload.number, }) - // Find any comment already made by the bot. - const botComment = comments.find(comment => comment.user.id === 41898282) - const commentBody = ` - - Hello :wave: - -
- here is the most recent benchmark result: - -

- - \`\`\` - ${{ env.BENCH_RESULT }} - \`\`\` - -

-
- - This comment gets update everytime a new commit comes in! - - `; + // Find any comment already made by the bot. + const botComment = comments.find(comment => comment.user.id === 41898282) if (!context.payload.pull_request.head.repo.full_name.startsWith('patchlevel/')) { console.log('Not attempting to write comment on PR from fork'); @@ -110,3 +108,7 @@ jobs: }) } } + + - name: "Fail on regression" + if: steps.bench.outcome == 'failure' + run: "exit 1" diff --git a/Makefile b/Makefile index faf64b56..b0b48531 100644 --- a/Makefile +++ b/Makefile @@ -35,13 +35,13 @@ static: phpstan cs ## run stat test: phpunit ## run tests .PHONY: benchmark -benchmark: vendor ## run benchmarks - vendor/bin/phpbench run tests/Benchmark --report=default +benchmark: vendor ## run all benchmarks + vendor/bin/phpbench run --report=diff -.PHONY: benchmark-diff-test -benchmark-diff-test: vendor ## run benchmarks - vendor/bin/phpbench run tests/Benchmark --revs=1 --report=default --progress=none --tag=base - vendor/bin/phpbench run tests/Benchmark --revs=1 --report=diff --progress=none --ref=base +.PHONY: benchmark-diff +benchmark-diff: vendor ## run the benchmarks twice and compare the second run against the first, like the CI does + vendor/bin/phpbench run --progress=none --tag=base + vendor/bin/phpbench run --progress=none --report=diff --ref=base .PHONY: docs diff --git a/phpbench.json b/phpbench.json index 729b39d4..60c604cd 100644 --- a/phpbench.json +++ b/phpbench.json @@ -1,54 +1,37 @@ { - "$schema":"./vendor/phpbench/phpbench/phpbench.schema.json", + "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", "runner.bootstrap": "vendor/autoload.php", + "runner.path": "tests/Benchmark", "runner.file_pattern": "*Bench.php", + "runner.iterations": 5, + "runner.php_config": { + "memory_limit": "-1", + "opcache.enable_cli": 1, + "opcache.jit_buffer_size": 0, + "xdebug.mode": "off" + }, + "runner.assert": "mode(variant.time.avg) <= mode(baseline.time.avg) +/- 25%", "report.generators": { "diff": { - "generator": "component", - "partition": ["benchmark_name"], - "components": [ - { - "component": "section", - "title": "{{ first(frame[\"benchmark_name\"]) }}", - "components": [ - { - "component": "table_aggregate", - "partition": ["subject_name", "variant_name"], - "groups": - { - "time (kde mode)": - { - "cols": ["time"] - }, - "memory": - { - "cols": ["memory"] - } - }, - "row": - { - "subject": "first(partition[\"subject_name\"]) ~ \" (\" ~ first(partition[\"variant_name\"]) ~ \")\"", - "time": - { - "type": "expand", - "partition": "suite_tag", - "cols": - { - "Tag: {{ key }}": "mode(partition[\"result_time_avg\"]) as time ~ ' (' ~ rstdev(partition['result_time_avg']) ~ ')'" - } - }, - "memory": - { - "type": "expand", - "partition": "suite_tag", - "cols": - { - "Tag: {{ key }} ": "mode(partition[\"result_mem_peak\"]) as memory" - } - } - } - }] - }] + "generator": "expression", + "break": [ + "benchmark" + ], + "cols": [ + "subject", + "revs", + "its", + "memory", + "base", + "current", + "diff" + ], + "expressions": { + "memory": "if(first(has_baseline), (first(result_mem_peak) as memory) ~ ' ' ~ percent_diff(first(baseline_mem_peak), first(result_mem_peak)), first(result_mem_peak) as memory)", + "base": "if(first(has_baseline), mode(baseline_time_avg) as time ~ ' (' ~ rstdev(baseline_time_avg) ~ ')', '')", + "current": "mode(result_time_avg) as time ~ ' (' ~ rstdev(result_time_avg) ~ ')'", + "diff": "if(first(has_baseline), percent_diff(mode(baseline_time_avg), mode(result_time_avg), rstdev(result_time_avg)), '')" + } } } -} \ No newline at end of file +} diff --git a/tests/Benchmark/HydratorBench.php b/tests/Benchmark/HydratorBench.php deleted file mode 100644 index 0af5f89c..00000000 --- a/tests/Benchmark/HydratorBench.php +++ /dev/null @@ -1,135 +0,0 @@ -hydrator = (new StackHydratorBuilder()) - ->useExtension(new CoreExtension()) - ->build(); - } - - public function setUp(): void - { - $object = $this->hydrator->hydrate( - ProfileCreated::class, - [ - 'profileId' => '1', - 'name' => 'foo', - 'skills' => [ - ['name' => 'php'], - ['name' => 'symfony'], - ], - ], - ); - - $this->hydrator->extract($object); - } - - #[Bench\Revs(5)] - public function benchHydrate1Object(): void - { - $this->hydrator->hydrate(ProfileCreated::class, [ - 'profileId' => '1', - 'name' => 'foo', - 'skills' => [ - ['name' => 'php'], - ['name' => 'symfony'], - ], - ]); - } - - #[Bench\Revs(5)] - public function benchExtract1Object(): void - { - $object = new ProfileCreated( - ProfileId::fromString('1'), - 'foo', - [ - new Skill('php'), - new Skill('symfony'), - ], - ); - - $this->hydrator->extract($object); - } - - #[Bench\Revs(3)] - public function benchHydrate1000Objects(): void - { - for ($i = 0; $i < 1_000; $i++) { - $this->hydrator->hydrate(ProfileCreated::class, [ - 'profileId' => '1', - 'name' => 'foo', - 'skills' => [ - ['name' => 'php'], - ['name' => 'symfony'], - ], - ]); - } - } - - #[Bench\Revs(3)] - public function benchExtract1000Objects(): void - { - $object = new ProfileCreated( - ProfileId::fromString('1'), - 'foo', - [ - new Skill('php'), - new Skill('symfony'), - ], - ); - - for ($i = 0; $i < 1_000; $i++) { - $this->hydrator->extract($object); - } - } - - #[Bench\Revs(3)] - public function benchHydrate1000000Objects(): void - { - for ($i = 0; $i < 1_000_000; $i++) { - $this->hydrator->hydrate(ProfileCreated::class, [ - 'profileId' => '1', - 'name' => 'foo', - 'skills' => [ - ['name' => 'php'], - ['name' => 'symfony'], - ], - ]); - } - } - - #[Bench\Revs(3)] - public function benchExtract1000000Objects(): void - { - $object = new ProfileCreated( - ProfileId::fromString('1'), - 'foo', - [ - new Skill('php'), - new Skill('symfony'), - ], - ); - - for ($i = 0; $i < 1_000_000; $i++) { - $this->hydrator->extract($object); - } - } -} diff --git a/tests/Benchmark/HydratorWithCryptographyBench.php b/tests/Benchmark/HydratorWithCryptographyBench.php deleted file mode 100644 index 1b93a77a..00000000 --- a/tests/Benchmark/HydratorWithCryptographyBench.php +++ /dev/null @@ -1,154 +0,0 @@ -store = new InMemoryCipherKeyStore(); - - $this->hydrator = (new StackHydratorBuilder()) - ->useExtension(new CoreExtension()) - ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($this->store))) - ->build(); - } - - public function setUp(): void - { - $this->store->clear(); - - $object = $this->hydrator->hydrate( - ProfileCreated::class, - [ - 'profileId' => '1', - 'name' => 'foo', - 'skills' => [ - ['name' => 'php'], - ['name' => 'symfony'], - ], - ], - ); - - $this->hydrator->extract($object); - } - - #[Bench\Revs(5)] - public function benchHydrate1Object(): void - { - $this->hydrator->hydrate( - ProfileCreated::class, - [ - 'profileId' => '1', - 'name' => 'foo', - 'skills' => [ - ['name' => 'php'], - ['name' => 'symfony'], - ], - ], - ); - } - - #[Bench\Revs(5)] - public function benchExtract1Object(): void - { - $object = new ProfileCreated( - ProfileId::fromString('1'), - 'foo', - [ - new Skill('php'), - new Skill('symfony'), - ], - ); - - $this->hydrator->extract($object); - } - - #[Bench\Revs(3)] - public function benchHydrate1000Objects(): void - { - for ($i = 0; $i < 1_000; $i++) { - $this->hydrator->hydrate( - ProfileCreated::class, - [ - 'profileId' => '1', - 'name' => 'foo', - 'skills' => [ - ['name' => 'php'], - ['name' => 'symfony'], - ], - ], - ); - } - } - - #[Bench\Revs(3)] - public function benchExtract1000Objects(): void - { - $object = new ProfileCreated( - ProfileId::fromString('1'), - 'foo', - [ - new Skill('php'), - new Skill('symfony'), - ], - ); - - for ($i = 0; $i < 1_000; $i++) { - $this->hydrator->extract($object); - } - } - - #[Bench\Revs(3)] - public function benchHydrate1000000Objects(): void - { - for ($i = 0; $i < 1_000_000; $i++) { - $this->hydrator->hydrate( - ProfileCreated::class, - [ - 'profileId' => '1', - 'name' => 'foo', - 'skills' => [ - ['name' => 'php'], - ['name' => 'symfony'], - ], - ], - ); - } - } - - #[Bench\Revs(3)] - public function benchExtract1000000Objects(): void - { - $object = new ProfileCreated( - ProfileId::fromString('1'), - 'foo', - [ - new Skill('php'), - new Skill('symfony'), - ], - ); - - for ($i = 0; $i < 1_000_000; $i++) { - $this->hydrator->extract($object); - } - } -} diff --git a/tests/Benchmark/StackHydratorBench.php b/tests/Benchmark/StackHydratorBench.php index 4b1f7cf8..7287694b 100644 --- a/tests/Benchmark/StackHydratorBench.php +++ b/tests/Benchmark/StackHydratorBench.php @@ -41,7 +41,7 @@ public function setUp(): void $this->hydrator->extract($object); } - #[Bench\Revs(5)] + #[Bench\Revs(1000)] public function benchHydrate1Object(): void { $this->hydrator->hydrate(ProfileCreated::class, [ @@ -54,7 +54,7 @@ public function benchHydrate1Object(): void ]); } - #[Bench\Revs(5)] + #[Bench\Revs(1000)] public function benchExtract1Object(): void { $object = new ProfileCreated( @@ -100,36 +100,4 @@ public function benchExtract1000Objects(): void $this->hydrator->extract($object); } } - - #[Bench\Revs(3)] - public function benchHydrate1000000Objects(): void - { - for ($i = 0; $i < 1_000_000; $i++) { - $this->hydrator->hydrate(ProfileCreated::class, [ - 'profileId' => '1', - 'name' => 'foo', - 'skills' => [ - ['name' => 'php'], - ['name' => 'symfony'], - ], - ]); - } - } - - #[Bench\Revs(3)] - public function benchExtract1000000Objects(): void - { - $object = new ProfileCreated( - ProfileId::fromString('1'), - 'foo', - [ - new Skill('php'), - new Skill('symfony'), - ], - ); - - for ($i = 0; $i < 1_000_000; $i++) { - $this->hydrator->extract($object); - } - } } diff --git a/tests/Benchmark/StackHydratorWithCryptographyBench.php b/tests/Benchmark/StackHydratorWithCryptographyBench.php index 5d5f54b9..44f4f37a 100644 --- a/tests/Benchmark/StackHydratorWithCryptographyBench.php +++ b/tests/Benchmark/StackHydratorWithCryptographyBench.php @@ -51,7 +51,7 @@ public function setUp(): void $this->hydrator->extract($object); } - #[Bench\Revs(5)] + #[Bench\Revs(1000)] public function benchHydrate1Object(): void { $this->hydrator->hydrate( @@ -67,7 +67,7 @@ public function benchHydrate1Object(): void ); } - #[Bench\Revs(5)] + #[Bench\Revs(1000)] public function benchExtract1Object(): void { $object = new ProfileCreated( @@ -116,39 +116,4 @@ public function benchExtract1000Objects(): void $this->hydrator->extract($object); } } - - #[Bench\Revs(3)] - public function benchHydrate1000000Objects(): void - { - for ($i = 0; $i < 1_000_000; $i++) { - $this->hydrator->hydrate( - ProfileCreated::class, - [ - 'profileId' => '1', - 'name' => 'foo', - 'skills' => [ - ['name' => 'php'], - ['name' => 'symfony'], - ], - ], - ); - } - } - - #[Bench\Revs(3)] - public function benchExtract1000000Objects(): void - { - $object = new ProfileCreated( - ProfileId::fromString('1'), - 'foo', - [ - new Skill('php'), - new Skill('symfony'), - ], - ); - - for ($i = 0; $i < 1_000_000; $i++) { - $this->hydrator->extract($object); - } - } } diff --git a/tests/Benchmark/HydratorWithLazyBench.php b/tests/Benchmark/StackHydratorWithLazyBench.php similarity index 72% rename from tests/Benchmark/HydratorWithLazyBench.php rename to tests/Benchmark/StackHydratorWithLazyBench.php index a85224b7..c8504280 100644 --- a/tests/Benchmark/HydratorWithLazyBench.php +++ b/tests/Benchmark/StackHydratorWithLazyBench.php @@ -11,7 +11,7 @@ use PhpBench\Attributes as Bench; #[Bench\BeforeMethods('setUp')] -final class HydratorWithLazyBench +final class StackHydratorWithLazyBench { private Hydrator $hydrator; @@ -40,7 +40,7 @@ public function setUp(): void $this->hydrator->extract($object); } - #[Bench\Revs(5)] + #[Bench\Revs(1000)] public function benchHydrate1Object(): void { $this->hydrator->hydrate(ProfileCreated::class, [ @@ -53,7 +53,7 @@ public function benchHydrate1Object(): void ]); } - #[Bench\Revs(5)] + #[Bench\Revs(1000)] public function benchHydrate1ObjectTriggerInit(): void { $object = $this->hydrator->hydrate(ProfileCreated::class, [ @@ -99,36 +99,4 @@ public function benchHydrate1000ObjectsTriggerInit(): void $name = $object->name; } } - - #[Bench\Revs(3)] - public function benchHydrate1000000Objects(): void - { - for ($i = 0; $i < 1_000_000; $i++) { - $this->hydrator->hydrate(ProfileCreated::class, [ - 'profileId' => '1', - 'name' => 'foo', - 'skills' => [ - ['name' => 'php'], - ['name' => 'symfony'], - ], - ]); - } - } - - #[Bench\Revs(3)] - public function benchHydrate1000000ObjectsTriggerInit(): void - { - for ($i = 0; $i < 1_000_000; $i++) { - $object = $this->hydrator->hydrate(ProfileCreated::class, [ - 'profileId' => '1', - 'name' => 'foo', - 'skills' => [ - ['name' => 'php'], - ['name' => 'symfony'], - ], - ]); - - $object = $object->name; - } - } } From 68c5d9e17d66eb12e88e6ba895a1fe3ec4f74257 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 17 Oct 2026 19:37:41 +0200 Subject: [PATCH 5/6] Cancel outdated CI runs Adds a concurrency group to the CI workflows, so a new push to a pull request or branch cancels the runs still going for the previous commit instead of letting them finish for nothing. It uses the same group the app repositories already have (github.workflow plus head_ref || ref), so pull requests and branches never cancel each other. Release, notify and docs deploy workflows are left as they are. --- .github/workflows/backward-compatibility-check.yml | 4 ++++ .github/workflows/benchmark.yml | 4 ++++ .github/workflows/coding-standard.yml | 4 ++++ .github/workflows/docs-check.yml | 4 ++++ .github/workflows/mutation-tests-diff.yml | 4 ++++ .github/workflows/mutation-tests.yml | 4 ++++ .github/workflows/phpstan.yml | 4 ++++ .github/workflows/unit.yml | 4 ++++ 8 files changed, 32 insertions(+) diff --git a/.github/workflows/backward-compatibility-check.yml b/.github/workflows/backward-compatibility-check.yml index e086865c..cd86d466 100644 --- a/.github/workflows/backward-compatibility-check.yml +++ b/.github/workflows/backward-compatibility-check.yml @@ -5,6 +5,10 @@ name: "Backward Compatibility Check" on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: roave-backwards-compatibility-check: name: "Backward Compatibility Check" diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 12d36ad5..3989870b 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -5,6 +5,10 @@ name: "Benchmark" on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: phpbench: name: "Benchmark" diff --git a/.github/workflows/coding-standard.yml b/.github/workflows/coding-standard.yml index a4e67668..fbc49fde 100644 --- a/.github/workflows/coding-standard.yml +++ b/.github/workflows/coding-standard.yml @@ -9,6 +9,10 @@ on: - "[0-9]+.[0-9]+.x" - "renovate/*" +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: static-analysis-coding-standard: name: "Static Analysis by patchlevel/coding-standard" diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index d7e13bdd..d0bd5963 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -8,6 +8,10 @@ on: branches: - "[0-9]+.[0-9]+.x" +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: checkdocs: name: "Check Docs" diff --git a/.github/workflows/mutation-tests-diff.yml b/.github/workflows/mutation-tests-diff.yml index 999fe818..935e4ce2 100644 --- a/.github/workflows/mutation-tests-diff.yml +++ b/.github/workflows/mutation-tests-diff.yml @@ -5,6 +5,10 @@ name: "Mutation tests on diff" on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: mutation-tests-diff: name: "Mutation tests on diff" diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index bc7e3020..9d753990 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -9,6 +9,10 @@ on: - "[0-9]+.[0-9]+.x" - "renovate/*" +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: mutation-tests: name: "Mutation tests" diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index c4ed1073..b27e8847 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -9,6 +9,10 @@ on: - "[0-9]+.[0-9]+.x" - "renovate/*" +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: static-analysis-phpstan: name: "Static Analysis by PHPStan" diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 76f87457..3a843586 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -9,6 +9,10 @@ on: - "[0-9]+.[0-9]+.x" - "renovate/*" +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: phpunit: name: "Unit tests" From acb1f4a7eafc5fd25b5f279835c8767010aab1cc Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 17 Oct 2026 19:47:00 +0200 Subject: [PATCH 6/6] Use workflow_ref for the concurrency groups Follow-up to the concurrency groups added in the last pull request. They used the workflow name plus the branch name, which lets two workflows with the same name cancel each other on the same branch, and two fork pull requests with the same branch name (e.g. patch-1) cancel each other. github.workflow_ref is the workflow file plus the ref (refs/pull//merge for pull requests) and has neither problem. --- .github/workflows/backward-compatibility-check.yml | 2 +- .github/workflows/benchmark.yml | 2 +- .github/workflows/coding-standard.yml | 2 +- .github/workflows/docs-check.yml | 2 +- .github/workflows/mutation-tests-diff.yml | 2 +- .github/workflows/mutation-tests.yml | 2 +- .github/workflows/phpstan.yml | 2 +- .github/workflows/unit.yml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/backward-compatibility-check.yml b/.github/workflows/backward-compatibility-check.yml index cd86d466..71083207 100644 --- a/.github/workflows/backward-compatibility-check.yml +++ b/.github/workflows/backward-compatibility-check.yml @@ -6,7 +6,7 @@ on: pull_request: concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + group: ${{ github.workflow_ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 3989870b..e70800d2 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -6,7 +6,7 @@ on: pull_request: concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + group: ${{ github.workflow_ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/coding-standard.yml b/.github/workflows/coding-standard.yml index fbc49fde..76af27a3 100644 --- a/.github/workflows/coding-standard.yml +++ b/.github/workflows/coding-standard.yml @@ -10,7 +10,7 @@ on: - "renovate/*" concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + group: ${{ github.workflow_ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index d0bd5963..fc3ad5e2 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -9,7 +9,7 @@ on: - "[0-9]+.[0-9]+.x" concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + group: ${{ github.workflow_ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/mutation-tests-diff.yml b/.github/workflows/mutation-tests-diff.yml index 935e4ce2..b187988c 100644 --- a/.github/workflows/mutation-tests-diff.yml +++ b/.github/workflows/mutation-tests-diff.yml @@ -6,7 +6,7 @@ on: pull_request: concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + group: ${{ github.workflow_ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index 9d753990..fea09354 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -10,7 +10,7 @@ on: - "renovate/*" concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + group: ${{ github.workflow_ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index b27e8847..abbf99ed 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -10,7 +10,7 @@ on: - "renovate/*" concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + group: ${{ github.workflow_ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 3a843586..07ee4bc2 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -10,7 +10,7 @@ on: - "renovate/*" concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + group: ${{ github.workflow_ref }} cancel-in-progress: true jobs: