From faaae31c2eb471344dadba40b07500fb6f5f30bb Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Sat, 1 Feb 2025 16:57:21 -0500 Subject: [PATCH] Add support for an actor provider for extensions which use a user reference --- CHANGELOG.md | 3 + doc/blameable.md | 11 +++- doc/loggable.md | 11 +++- doc/utils/actor-provider.md | 56 +++++++++++++++++++ src/Blameable/BlameableListener.php | 37 ++++++++---- src/Loggable/LoggableListener.php | 51 ++++++++++++++++- src/Tool/ActorProviderInterface.php | 21 +++++++ tests/Gedmo/Blameable/BlameableTest.php | 54 ++++++++++++++++-- .../Loggable/AnnotationLoggableEntityTest.php | 6 +- .../Loggable/AttributeLoggableEntityTest.php | 8 +-- tests/Gedmo/Loggable/LoggableEntityTest.php | 56 +++++++++++++++++++ tests/Gedmo/TestActorProvider.php | 42 ++++++++++++++ 12 files changed, 331 insertions(+), 25 deletions(-) create mode 100644 doc/utils/actor-provider.md create mode 100644 src/Tool/ActorProviderInterface.php create mode 100644 tests/Gedmo/TestActorProvider.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 25ec72b50..d11ea97ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ a release. --- ## [Unreleased] +### Added +- Actor provider for use with extensions with user references (#2914) + ### Changed - Updated minimum versions for `doctrine/orm` to ^2.20 || ^3.3 diff --git a/doc/blameable.md b/doc/blameable.md index 76394fa43..cfd2678cb 100644 --- a/doc/blameable.md +++ b/doc/blameable.md @@ -24,9 +24,18 @@ $om->getEventManager()->addEventSubscriber($listener); ``` Then, once your application has it available (i.e. after validating the authentication for your user during an HTTP request), -you can set a reference to the user to be blamed for changes by calling the listener's `setUserValue` method. +you can set a reference to the user to be blamed for changes. + +The user reference can be set through either an [actor provider service](./utils/actor-provider.md) or by calling the +listener's `setUserValue` method with a resolved user. + +> [!TIP] +> When an actor provider is given to the extension, any data set with the `setUserValue` method will be ignored. ```php +// The $provider must be an implementation of Gedmo\Tool\ActorProviderInterface +$listener->setActorProvider($provider); + // The $user can be either an object or a string $listener->setUserValue($user); ``` diff --git a/doc/loggable.md b/doc/loggable.md index c14519d9e..3f0b9dc55 100644 --- a/doc/loggable.md +++ b/doc/loggable.md @@ -29,9 +29,18 @@ $om->getEventManager()->addEventSubscriber($listener); ``` Then, once your application has it available (i.e. after validating the authentication for your user during an HTTP request), -you can set a reference to the user who performed actions on a loggable model by calling the listener's `setUsername` method. +you can set a reference to the user who performed actions on a loggable model. + +The user reference can be set through either an [actor provider service](./utils/actor-provider.md) or by calling the +listener's `setUsername` method with a resolved user. + +> [!TIP] +> When an actor provider is given to the extension, any data set with the `setUsername` method will be ignored. ```php +// The $provider must be an implementation of Gedmo\Tool\ActorProviderInterface +$listener->setActorProvider($provider); + // The $user can be either an object or a string $listener->setUsername($user); ``` diff --git a/doc/utils/actor-provider.md b/doc/utils/actor-provider.md new file mode 100644 index 000000000..22ee883b8 --- /dev/null +++ b/doc/utils/actor-provider.md @@ -0,0 +1,56 @@ +# Actor Provider + +The Doctrine Extensions package includes support for an "actor provider" for extensions which use a user value, such as +the blameable or loggable extensions. + +## Index + +- [Getting Started](#getting-started) +- [Benefits of Actor Providers](#benefits-of-actor-providers) + +## Getting Started + +Out of the box, the library does not provide an implementation for the `Gedmo\Tool\ActorProviderInterface`, so you will +need to create a class in your application. Below is an example of an actor provider using Symfony's Security components: + +```php +namespace App\Utils; + +use Gedmo\Tool\ActorProviderInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; + +final class SymfonyActorProvider implements ActorProviderInterface +{ + private TokenStorageInterface $tokenStorage; + + public function __construct(TokenStorageInterface $tokenStorage) + { + $this->tokenStorage = $tokenStorage; + } + + /** + * @return object|string|null + */ + public function getActor() + { + $token = $this->tokenStorage->getToken(); + + return $token ? $token->getUser() : null; + } +} +``` + +Once you've created your actor provider, you can inject it into the listeners for supported extensions by calling +the `setActorProvider` method. + +```php +/** Gedmo\Blameable\BlameableListener $listener */ +$listener->setActorProvider($provider); +``` + +## Benefits of Actor Providers + +Unlike the previously existing APIs for the extensions which support user references, actor providers allow lazily +resolving the user value when it is needed instead of eagerly fetching it when the listener is created. Actor providers +would also integrate nicely with long-running processes such as FrankenPHP where the provider can be reset between +requests. diff --git a/src/Blameable/BlameableListener.php b/src/Blameable/BlameableListener.php index dbffc4e84..a39e4012e 100644 --- a/src/Blameable/BlameableListener.php +++ b/src/Blameable/BlameableListener.php @@ -13,6 +13,7 @@ use Gedmo\AbstractTrackingListener; use Gedmo\Blameable\Mapping\Event\BlameableAdapter; use Gedmo\Exception\InvalidArgumentException; +use Gedmo\Tool\ActorProviderInterface; /** * The Blameable listener handles the update of @@ -26,6 +27,8 @@ */ class BlameableListener extends AbstractTrackingListener { + protected ?ActorProviderInterface $actorProvider = null; + /** * @var mixed */ @@ -42,34 +45,46 @@ class BlameableListener extends AbstractTrackingListener */ public function getFieldValue($meta, $field, $eventAdapter) { + $actor = $this->actorProvider instanceof ActorProviderInterface ? $this->actorProvider->getActor() : $this->user; + if ($meta->hasAssociation($field)) { - if (null !== $this->user && !is_object($this->user)) { + if (null !== $actor && !is_object($actor)) { throw new InvalidArgumentException('Blame is reference, user must be an object'); } - return $this->user; + return $actor; } // ok so it's not an association, then it is a string, or an object - if (is_object($this->user)) { - if (method_exists($this->user, 'getUserIdentifier')) { - return (string) $this->user->getUserIdentifier(); + if (is_object($actor)) { + if (method_exists($actor, 'getUserIdentifier')) { + return (string) $actor->getUserIdentifier(); } - if (method_exists($this->user, 'getUsername')) { - return (string) $this->user->getUsername(); + if (method_exists($actor, 'getUsername')) { + return (string) $actor->getUsername(); } - if (method_exists($this->user, '__toString')) { - return $this->user->__toString(); + if (method_exists($actor, '__toString')) { + return $actor->__toString(); } throw new InvalidArgumentException('Field expects string, user must be a string, or object should have method getUserIdentifier, getUsername or __toString'); } - return $this->user; + return $actor; + } + + /** + * Set an actor provider for the user value. + */ + public function setActorProvider(ActorProviderInterface $actorProvider): void + { + $this->actorProvider = $actorProvider; } /** - * Set a user value to return + * Set a user value to return. + * + * If an actor provider is also provided, it will take precedence over this value. * * @param mixed $user * diff --git a/src/Loggable/LoggableListener.php b/src/Loggable/LoggableListener.php index 005447903..beaf772f4 100644 --- a/src/Loggable/LoggableListener.php +++ b/src/Loggable/LoggableListener.php @@ -17,8 +17,10 @@ use Doctrine\Persistence\Mapping\ClassMetadata; use Doctrine\Persistence\ObjectManager; use Gedmo\Exception\InvalidArgumentException; +use Gedmo\Exception\UnexpectedValueException; use Gedmo\Loggable\Mapping\Event\LoggableAdapter; use Gedmo\Mapping\MappedEventSubscriber; +use Gedmo\Tool\ActorProviderInterface; use Gedmo\Tool\Wrapper\AbstractWrapper; /** @@ -55,6 +57,8 @@ class LoggableListener extends MappedEventSubscriber */ public const ACTION_REMOVE = LogEntryInterface::ACTION_REMOVE; + protected ?ActorProviderInterface $actorProvider = null; + /** * Username for identification * @@ -85,9 +89,19 @@ class LoggableListener extends MappedEventSubscriber */ protected $pendingRelatedObjects = []; + /** + * Set an actor provider for the user value. + */ + public function setActorProvider(ActorProviderInterface $actorProvider): void + { + $this->actorProvider = $actorProvider; + } + /** * Set username for identification * + * If an actor provider is also provided, it will take precedence over this value. + * * @param mixed $username * * @throws InvalidArgumentException Invalid username @@ -229,6 +243,41 @@ protected function getLogEntryClass(LoggableAdapter $ea, $class) return self::$configurations[$this->name][$class]['logEntryClass'] ?? $ea->getDefaultLogEntryClass(); } + /** + * Retrieve the username to use for the log entry. + * + * This method will try to fetch a username from the actor provider first, falling back to the {@see $this->username} + * property if the provider is not set or does not provide a value. + * + * @throws UnexpectedValueException if the actor provider provides an unsupported username value + */ + protected function getUsername(): ?string + { + if ($this->actorProvider instanceof ActorProviderInterface) { + $actor = $this->actorProvider->getActor(); + + if (is_string($actor) || null === $actor) { + return $actor; + } + + if (method_exists($actor, 'getUserIdentifier')) { + return (string) $actor->getUserIdentifier(); + } + + if (method_exists($actor, 'getUsername')) { + return (string) $actor->getUsername(); + } + + if (method_exists($actor, '__toString')) { + return $actor->__toString(); + } + + throw new UnexpectedValueException(\sprintf('The loggable extension requires the actor provider to return a string or an object implementing the "getUserIdentifier()", "getUsername()", or "__toString()" methods. "%s" cannot be used as an actor.', get_class($actor))); + } + + return $this->username; + } + /** * Handle any custom LogEntry functionality that needs to be performed * before persisting it @@ -328,7 +377,7 @@ protected function createLogEntry($action, $object, LoggableAdapter $ea) $logEntry = $logEntryMeta->newInstance(); $logEntry->setAction($action); - $logEntry->setUsername($this->username); + $logEntry->setUsername($this->getUsername()); $logEntry->setObjectClass($meta->getName()); $logEntry->setLoggedAt(); diff --git a/src/Tool/ActorProviderInterface.php b/src/Tool/ActorProviderInterface.php new file mode 100644 index 000000000..3a8334d8d --- /dev/null +++ b/src/Tool/ActorProviderInterface.php @@ -0,0 +1,21 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tool; + +/** + * Interface for a provider for an actor for extensions supporting actor/user references. + */ +interface ActorProviderInterface +{ + /** + * @return object|string|null + */ + public function getActor(); +} diff --git a/tests/Gedmo/Blameable/BlameableTest.php b/tests/Gedmo/Blameable/BlameableTest.php index d9b226ae0..b233150a9 100644 --- a/tests/Gedmo/Blameable/BlameableTest.php +++ b/tests/Gedmo/Blameable/BlameableTest.php @@ -12,11 +12,11 @@ namespace Gedmo\Tests\Blameable; use Doctrine\Common\EventManager; -use Gedmo\Blameable\Blameable; use Gedmo\Blameable\BlameableListener; use Gedmo\Tests\Blameable\Fixture\Entity\Article; use Gedmo\Tests\Blameable\Fixture\Entity\Comment; use Gedmo\Tests\Blameable\Fixture\Entity\Type; +use Gedmo\Tests\TestActorProvider; use Gedmo\Tests\Tool\BaseTestCaseORM; /** @@ -26,15 +26,17 @@ */ final class BlameableTest extends BaseTestCaseORM { + private BlameableListener $listener; + protected function setUp(): void { parent::setUp(); - $listener = new BlameableListener(); - $listener->setUserValue('testuser'); + $this->listener = new BlameableListener(); + $this->listener->setUserValue('testuser'); $evm = new EventManager(); - $evm->addEventSubscriber($listener); + $evm->addEventSubscriber($this->listener); $this->getDefaultMockSqliteEntityManager($evm); } @@ -81,6 +83,50 @@ public function testBlameable(): void static::assertSame('testuser', $sport->getPublished()); } + public function testBlameableWithActorProvider(): void + { + $this->listener->setActorProvider(new TestActorProvider('testactor')); + + $sport = new Article(); + $sport->setTitle('Sport'); + + $sportComment = new Comment(); + $sportComment->setMessage('hello'); + $sportComment->setArticle($sport); + $sportComment->setStatus(0); + + $this->em->persist($sport); + $this->em->persist($sportComment); + $this->em->flush(); + $this->em->clear(); + + $sport = $this->em->getRepository(Article::class)->findOneBy(['title' => 'Sport']); + static::assertSame('testactor', $sport->getCreated()); + static::assertSame('testactor', $sport->getUpdated()); + static::assertNull($sport->getPublished()); + + $sportComment = $this->em->getRepository(Comment::class)->findOneBy(['message' => 'hello']); + static::assertSame('testactor', $sportComment->getModified()); + static::assertNull($sportComment->getClosed()); + + $sportComment->setStatus(1); + $published = new Type(); + $published->setTitle('Published'); + + $sport->setTitle('Updated'); + $sport->setType($published); + $this->em->persist($sport); + $this->em->persist($published); + $this->em->persist($sportComment); + $this->em->flush(); + $this->em->clear(); + + $sportComment = $this->em->getRepository(Comment::class)->findOneBy(['message' => 'hello']); + static::assertSame('testactor', $sportComment->getClosed()); + + static::assertSame('testactor', $sport->getPublished()); + } + public function testForcedValues(): void { $sport = new Article(); diff --git a/tests/Gedmo/Loggable/AnnotationLoggableEntityTest.php b/tests/Gedmo/Loggable/AnnotationLoggableEntityTest.php index e846dbcc9..8d4a5cdb9 100644 --- a/tests/Gedmo/Loggable/AnnotationLoggableEntityTest.php +++ b/tests/Gedmo/Loggable/AnnotationLoggableEntityTest.php @@ -26,9 +26,9 @@ protected function setUp(): void parent::setUp(); $evm = new EventManager(); - $loggableListener = new LoggableListener(); - $loggableListener->setUsername('jules'); - $evm->addEventSubscriber($loggableListener); + $this->listener = new LoggableListener(); + $this->listener->setUsername('jules'); + $evm->addEventSubscriber($this->listener); $this->em = $this->getDefaultMockSqliteEntityManager($evm); } diff --git a/tests/Gedmo/Loggable/AttributeLoggableEntityTest.php b/tests/Gedmo/Loggable/AttributeLoggableEntityTest.php index ec92c9291..23e6828b6 100644 --- a/tests/Gedmo/Loggable/AttributeLoggableEntityTest.php +++ b/tests/Gedmo/Loggable/AttributeLoggableEntityTest.php @@ -29,10 +29,10 @@ protected function setUp(): void parent::setUp(); $evm = new EventManager(); - $loggableListener = new LoggableListener(); - $loggableListener->setAnnotationReader(new AttributeReader()); - $loggableListener->setUsername('jules'); - $evm->addEventSubscriber($loggableListener); + $this->listener = new LoggableListener(); + $this->listener->setAnnotationReader(new AttributeReader()); + $this->listener->setUsername('jules'); + $evm->addEventSubscriber($this->listener); $this->em = $this->getDefaultMockSqliteEntityManager($evm); } diff --git a/tests/Gedmo/Loggable/LoggableEntityTest.php b/tests/Gedmo/Loggable/LoggableEntityTest.php index 83a84c0b0..21312c45a 100644 --- a/tests/Gedmo/Loggable/LoggableEntityTest.php +++ b/tests/Gedmo/Loggable/LoggableEntityTest.php @@ -14,6 +14,8 @@ use Doctrine\DBAL\Types\ArrayType; use Gedmo\Loggable\Entity\LogEntry; use Gedmo\Loggable\Entity\Repository\LogEntryRepository; +use Gedmo\Loggable\Loggable; +use Gedmo\Loggable\LoggableListener; use Gedmo\Tests\Loggable\Fixture\Entity\Address; use Gedmo\Tests\Loggable\Fixture\Entity\Article; use Gedmo\Tests\Loggable\Fixture\Entity\Comment; @@ -23,6 +25,7 @@ use Gedmo\Tests\Loggable\Fixture\Entity\GeoLocation; use Gedmo\Tests\Loggable\Fixture\Entity\Log\Comment as CommentLog; use Gedmo\Tests\Loggable\Fixture\Entity\RelatedArticle; +use Gedmo\Tests\TestActorProvider; use Gedmo\Tests\Tool\BaseTestCaseORM; /** @@ -32,6 +35,11 @@ */ abstract class LoggableEntityTest extends BaseTestCaseORM { + /** + * @var LoggableListener + */ + protected LoggableListener $listener; + public static function setUpBeforeClass(): void { if (!class_exists(ArrayType::class)) { @@ -106,6 +114,54 @@ public function testLoggable(): void static::assertNull($log->getData()); } + public function testLoggableWithActorProvider(): void + { + $this->listener->setActorProvider(new TestActorProvider('testactor')); + + $logRepo = $this->em->getRepository(LogEntry::class); + $articleRepo = $this->em->getRepository(Article::class); + static::assertCount(0, $logRepo->findAll()); + + $art0 = new Article(); + $art0->setTitle('Title'); + + $this->em->persist($art0); + $this->em->flush(); + + $log = $logRepo->findOneBy(['objectId' => $art0->getId()]); + + static::assertNotNull($log); + static::assertSame('create', $log->getAction()); + static::assertSame(get_class($art0), $log->getObjectClass()); + static::assertSame('testactor', $log->getUsername()); + static::assertSame(1, $log->getVersion()); + $data = $log->getData(); + static::assertCount(1, $data); + static::assertArrayHasKey('title', $data); + static::assertSame('Title', $data['title']); + + // test update + $article = $articleRepo->findOneBy(['title' => 'Title']); + + $article->setTitle('New'); + $this->em->persist($article); + $this->em->flush(); + $this->em->clear(); + + $log = $logRepo->findOneBy(['version' => 2, 'objectId' => $article->getId()]); + static::assertSame('update', $log->getAction()); + + // test delete + $article = $articleRepo->findOneBy(['title' => 'New']); + $this->em->remove($article); + $this->em->flush(); + $this->em->clear(); + + $log = $logRepo->findOneBy(['version' => 3, 'objectId' => 1]); + static::assertSame('remove', $log->getAction()); + static::assertNull($log->getData()); + } + public function testVersionControl(): void { $this->populate(); diff --git a/tests/Gedmo/TestActorProvider.php b/tests/Gedmo/TestActorProvider.php new file mode 100644 index 000000000..6bf03c3d7 --- /dev/null +++ b/tests/Gedmo/TestActorProvider.php @@ -0,0 +1,42 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests; + +use Gedmo\Tool\ActorProviderInterface; + +final class TestActorProvider implements ActorProviderInterface +{ + /** + * @var object|string|null + */ + private $actor; + + /** + * @param object|string|null $actor + */ + public function __construct($actor) + { + if (!is_string($actor) && !is_object($actor) && null !== $actor) { + throw new \TypeError(sprintf('The actor must be a string, an object, or null, "%s" given.', gettype($actor))); + } + + $this->actor = $actor; + } + + /** + * @return object|string|null + */ + public function getActor() + { + return $this->actor; + } +}