diff --git a/CHANGELOG.md b/CHANGELOG.md index 3059036a10..8922dbfb93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ a release. ### Added - IP address provider for use with extensions with IP address references (#2928) +## Added +- Support for blameable on `remove` event (#2929) + ## [3.19.0] - 2025-02-24 ### Added - Actor provider for use with extensions with user references (#2914) @@ -105,7 +108,7 @@ a release. - Dropped support for doctrine/dbal < 3.2 ### Deprecated -- Calling `Gedmo\Mapping\Event\Adapter\ORM::getObjectManager()` and `getObject()` on EventArgs that do not implement `getObjectManager()` and `getObject()` (such as old EventArgs implementing `getEntityManager()` and `getEntity()`) +- Calling `Gedmo\Mapping\Event\Adapter\ORM::getObjectManager()` and `getObject()` on EventArgs that do not implement `getObjectManager()` and `getObject()` (such as old EventArgs implementing `getEntityManager()` and `getEntity()`) - Calling `Gedmo\Uploadable\Event\UploadableBaseEventArgs::getEntityManager()` and `getEntity()`. Call `getObjectManager()` and `getObject()` instead. ## [3.13.0] - 2023-09-06 diff --git a/doc/blameable.md b/doc/blameable.md index cfd2678cba..af78c338f8 100644 --- a/doc/blameable.md +++ b/doc/blameable.md @@ -136,6 +136,15 @@ The blameable extension supports the following field types for a blameable field [`symfony/doctrine-bridge`](https://github.com/symfony/doctrine-bridge)) - A many-to-one association (ORM) or reference many reference (MongoDB ODM) +### Supported Events + +The blameable extension supports the following events: + +- `#[Blameable(on: 'create')]` - The blameable field is updated when the object is created +- `#[Blameable(on: 'update')]` - The blameable field is updated when the object is updated +- `#[Blameable(on: 'change')]` - The blameable field is updated when a specific field or value is changed +- `#[Blameable(on: 'remove')]` - The blameable field is updated when the object is removed/deleted (works in conjunction with soft-delete) + ## Using Traits The blameable extension provides traits which can be used to quickly add fields, and optionally the mapping configuration, @@ -150,7 +159,7 @@ provided as a convenience for a common configuration, for other use cases it is ## Logging Changes For Specific Actions -In addition to supporting logging the user for general create and update actions, the extension can also be configured to +In addition to supporting logging the user for general create, update, and remove actions, the extension can also be configured to log the user who made a change for specific fields or values. ### Single Field Changed To Specific Value @@ -178,14 +187,14 @@ class Article public bool $published = false; /** - * Field to track the user who last made any change to this article. + * Field to track the user who last made any change to this article. */ #[ORM\Column(type: Types::STRING)] #[Gedmo\Blameable] public ?string $updatedBy = null; /** - * Field to track the user who published this article. + * Field to track the user who published this article. */ #[ORM\Column(type: Types::STRING, nullable: true)] #[Gedmo\Blameable(on: 'change', field: 'published', value: true)] @@ -216,14 +225,14 @@ class Article public ?Category $category = null; /** - * Field to track the user who last made any change to this article. + * Field to track the user who last made any change to this article. */ #[ORM\Column(type: Types::STRING)] #[Gedmo\Blameable] public ?string $updatedBy = null; /** - * Field to track the user who archived this article. + * Field to track the user who archived this article. */ #[ORM\Column(type: Types::STRING, nullable: true)] #[Gedmo\Blameable(on: 'change', field: 'category.archived', value: true)] @@ -262,14 +271,14 @@ class Article public ?string $metaKeywords = null; /** - * Field to track the user who last made any change to this article. + * Field to track the user who last made any change to this article. */ #[ORM\Column(type: Types::STRING)] #[Gedmo\Blameable] public ?string $updatedBy = null; /** - * Field to track the user who last modified this article's SEO metadata. + * Field to track the user who last modified this article's SEO metadata. */ #[ORM\Column(type: Types::STRING, nullable: true)] #[Gedmo\Blameable(on: 'change', field: ['metaDescription', 'metaKeywords', 'category.metaDescription', 'category.metaKeywords'])] diff --git a/doc/sortable.md b/doc/sortable.md index a0dfa6f4eb..e2a2d65c36 100644 --- a/doc/sortable.md +++ b/doc/sortable.md @@ -3,25 +3,27 @@ **Sortable** behavior will maintain a position field for ordering entities. Features: + - Automatic handling of position index - Group entity ordering by one or more fields - Can be nested with other behaviors - Annotation, Attribute and Xml mapping support for extensions Contents: + - [Setup and autoloading](#setup-and-autoloading) - [Sortable mapping](#sortable-mapping) - - [Annotations](#annotation-mapping-example) - - [Attributes](#attribute-mapping-example) - - [Xml](#xml-mapping-example) - [Basic usage examples](#basic-usage-examples) -- [Custom comparison method](#custom-comparison) +- [Custom comparison](#custom-comparison) +- [Blameable Support](#blameable-support) ## Setup and autoloading + Read the [documentation](./annotations.md#em-setup) or check the [example code](../example) on how to setup and use the extensions in most optimized way. ## Sortable mapping + - [SortableGroup](../src/Mapping/Annotation/SortableGroup.php) - used to specify property for **grouping** - [SortablePosition](../src/Mapping/Annotation/SortablePosition.php) - used to specify property to store **position** index @@ -340,3 +342,12 @@ class Item implements Comparable } } ``` + +## Blameable Support + +You can also use the [Blameable](./blameable.md) extension to automatically set a `deletedBy` field when an entity is removed/deleted. + +```php +#[Gedmo\Blameable(on: 'remove')] +private string $deletedBy; +``` diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a5fd58ccdf..e6709a3bfe 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -3,13 +3,13 @@ parameters: - message: '#^Call to an undefined method Doctrine\\Persistence\\Mapping\\ClassMetadata\\:\:getReflectionProperty\(\)\.$#' identifier: method.notFound - count: 4 + count: 6 path: src/AbstractTrackingListener.php - message: '#^Call to an undefined method Doctrine\\Persistence\\ObjectManager\:\:getUnitOfWork\(\)\.$#' identifier: method.notFound - count: 3 + count: 4 path: src/AbstractTrackingListener.php - @@ -1247,4 +1247,3 @@ parameters: identifier: deadCode.unreachable count: 1 path: tests/Gedmo/Tree/MaterializedPathODMMongoDBTreeLockingTest.php - diff --git a/src/AbstractTrackingListener.php b/src/AbstractTrackingListener.php index a818601ffc..7ff9097e5a 100644 --- a/src/AbstractTrackingListener.php +++ b/src/AbstractTrackingListener.php @@ -204,6 +204,39 @@ public function prePersist(EventArgs $args) } } + /** + * Checks for a soft delete event as remove + * + * @param LifecycleEventArgs $args + * + * @phpstan-param LifecycleEventArgs $args + */ + public function preRemove(EventArgs $args): void + { + $ea = $this->getEventAdapter($args); + $om = $ea->getObjectManager(); + $uow = $om->getUnitOfWork(); + $object = $ea->getObject(); + $meta = $om->getClassMetadata(get_class($object)); + $config = $this->getConfiguration($om, $meta->getName()); + + if ($config) { + if (isset($config['remove'])) { + foreach ($config['remove'] as $field) { + if (null === $meta->getReflectionProperty($field)->getValue($object)) { // let manual values + $oldValue = $meta->getReflectionProperty($field)->getValue($object); + $newValue = $this->getFieldValue($meta, $field, $ea); + $this->updateField($object, $ea, $meta, $field); + $uow->propertyChanged($object, $field, $oldValue, $newValue); + $uow->scheduleExtraUpdate($object, [ + $field => [$oldValue, $newValue], + ]); + } + } + } + } + } + /** * Get the value for an updated field. * diff --git a/src/Blameable/BlameableListener.php b/src/Blameable/BlameableListener.php index a39e4012e3..a4b264da24 100644 --- a/src/Blameable/BlameableListener.php +++ b/src/Blameable/BlameableListener.php @@ -34,6 +34,14 @@ class BlameableListener extends AbstractTrackingListener */ protected $user; + /** + * {@inheritdoc} + */ + public function getSubscribedEvents(): array + { + return array_merge(parent::getSubscribedEvents(), ['preRemove']); + } + /** * Get the user value to set on a blameable field * diff --git a/src/Blameable/Mapping/Driver/Attribute.php b/src/Blameable/Mapping/Driver/Attribute.php index de2ce4158c..84330efbc2 100644 --- a/src/Blameable/Mapping/Driver/Attribute.php +++ b/src/Blameable/Mapping/Driver/Attribute.php @@ -74,7 +74,7 @@ public function readExtendedMetadata($meta, array &$config) } } - if (!in_array($blameable->on, ['update', 'create', 'change'], true)) { + if (!in_array($blameable->on, ['update', 'create', 'change', 'remove'], true)) { throw new InvalidMappingException("Field - [{$field}] trigger 'on' is not one of [update, create, change] in class - {$meta->getName()}"); } diff --git a/tests/Gedmo/Blameable/BlameableWithSoftDeleteTest.php b/tests/Gedmo/Blameable/BlameableWithSoftDeleteTest.php new file mode 100644 index 0000000000..a7b386c1c2 --- /dev/null +++ b/tests/Gedmo/Blameable/BlameableWithSoftDeleteTest.php @@ -0,0 +1,113 @@ + 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\Blameable; + +use Doctrine\Common\EventManager; +use Gedmo\Blameable\BlameableListener; +use Gedmo\SoftDeleteable\Filter\SoftDeleteableFilter; +use Gedmo\SoftDeleteable\SoftDeleteableListener; +use Gedmo\Tests\Blameable\Fixture\Entity\Article; +use Gedmo\Tests\Blameable\Fixture\Entity\Comment; +use Gedmo\Tests\Blameable\Fixture\Entity\Type; +use Gedmo\Tests\Clock; +use Gedmo\Tests\Tool\BaseTestCaseORM; + +/** + * These are tests for Blameable behavior + * + * @author Gediminas Morkevicius + */ +final class BlameableWithSoftDeleteTest extends BaseTestCaseORM +{ + private const SOFT_DELETEABLE_FILTER_NAME = 'soft-deleteable'; + + private BlameableListener $blameableListener; + private SoftDeleteableListener $softDeleteableListener; + + protected function setUp(): void + { + parent::setUp(); + + $this->blameableListener = new BlameableListener(); + $this->blameableListener->setUserValue('testuser'); + + $this->softDeleteableListener = new SoftDeleteableListener(); + $this->softDeleteableListener->setClock(new Clock()); + + $evm = new EventManager(); + $evm->addEventSubscriber($this->blameableListener); + $evm->addEventSubscriber($this->softDeleteableListener); + + $config = $this->getDefaultConfiguration(); + $config->addFilter(self::SOFT_DELETEABLE_FILTER_NAME, SoftDeleteableFilter::class); + $this->em = $this->getDefaultMockSqliteEntityManager($evm, $config); + $this->em->getFilters()->enable(self::SOFT_DELETEABLE_FILTER_NAME); + } + + public function testBlameable(): void + { + $article = new Article(); + $article->setTitle('Sport'); + + $comment = new Comment(); + $comment->setMessage('hello'); + $comment->setArticle($article); + $comment->setStatus(0); + + $this->em->persist($article); + $this->em->persist($comment); + $this->em->flush(); + $this->em->clear(); + + $article = $this->em->getRepository(Article::class)->findOneBy(['title' => 'Sport']); + static::assertSame('testuser', $article->getCreated()); + static::assertSame('testuser', $article->getUpdated()); + static::assertNull($article->getPublished()); + + $comment = $this->em->getRepository(Comment::class)->findOneBy(['message' => 'hello']); + static::assertSame('testuser', $comment->getModified()); + static::assertNull($comment->getClosed()); + + $comment->setStatus(1); + $type = new Type(); + $type->setTitle('Published'); + + $article->setTitle('Updated'); + $article->setType($type); + $this->em->persist($article); + $this->em->persist($type); + $this->em->persist($comment); + $this->em->flush(); + $this->em->clear(); + + $comment = $this->em->getRepository(Comment::class)->findOneBy(['message' => 'hello']); + static::assertSame('testuser', $comment->getClosed()); + static::assertSame('testuser', $article->getPublished()); + + // Now delete event + $article = $this->em->getRepository(Article::class)->findOneBy(['title' => 'Updated']); + $this->em->remove($article); + $this->em->flush(); + + static::assertTrue($article->isDeleted()); + static::assertSame('testuser', $article->getDeletedBy()); + } + + protected function getUsedEntityFixtures(): array + { + return [ + Article::class, + Comment::class, + Type::class, + ]; + } +} diff --git a/tests/Gedmo/Blameable/Fixture/Entity/Article.php b/tests/Gedmo/Blameable/Fixture/Entity/Article.php index f780508a6c..3dd8b63c6c 100644 --- a/tests/Gedmo/Blameable/Fixture/Entity/Article.php +++ b/tests/Gedmo/Blameable/Fixture/Entity/Article.php @@ -17,13 +17,19 @@ use Doctrine\ORM\Mapping as ORM; use Gedmo\Blameable\Blameable; use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\SoftDeleteable\Traits\SoftDeleteableEntity; /** * @ORM\Entity + * + * @Gedmo\SoftDeleteable */ #[ORM\Entity] +#[Gedmo\SoftDeleteable] class Article implements Blameable { + use SoftDeleteableEntity; + /** * @var int|null * @@ -37,9 +43,9 @@ class Article implements Blameable private $id; /** - * @ORM\Column(name="title", type="string", length=128) + * @ORM\Column(name="title", type="string", length=128, nullable=true) */ - #[ORM\Column(name: 'title', type: Types::STRING, length: 128)] + #[ORM\Column(name: 'title', type: Types::STRING, length: 128, nullable: true)] private ?string $title = null; /** @@ -51,23 +57,32 @@ class Article implements Blameable private $comments; /** - * @Gedmo\Blameable(on="create") + * @ORM\Column(name="created", type="string", nullable=true) * - * @ORM\Column(name="created", type="string") + * @Gedmo\Blameable(on="create") */ - #[ORM\Column(name: 'created', type: Types::STRING)] + #[ORM\Column(name: 'created', type: Types::STRING, nullable: true)] #[Gedmo\Blameable(on: 'create')] private ?string $created = null; /** - * @ORM\Column(name="updated", type="string") + * @ORM\Column(name="updated", type="string", nullable=true) * - * @Gedmo\Blameable + * @Gedmo\Blameable(on="update") */ - #[Gedmo\Blameable] - #[ORM\Column(name: 'updated', type: Types::STRING)] + #[Gedmo\Blameable(on: 'update')] + #[ORM\Column(name: 'updated', type: Types::STRING, nullable: true)] private ?string $updated = null; + /** + * @ORM\Column(name="deleted_by", type="string", nullable=true) + * + * @Gedmo\Blameable(on="remove") + */ + #[Gedmo\Blameable(on: 'remove')] + #[ORM\Column(name: 'deleted_by', type: Types::STRING, nullable: true)] + private ?string $deletedBy = null; + /** * @ORM\Column(name="published", type="string", nullable=true) * @@ -151,4 +166,14 @@ public function setUpdated(?string $updated): void { $this->updated = $updated; } + + public function getDeletedBy(): ?string + { + return $this->deletedBy; + } + + public function setDeletedBy(?string $deletedBy): void + { + $this->deletedBy = $deletedBy; + } }