Skip to content

Commit 8b0e4f8

Browse files
committed
Fixed issues raised by code review
1 parent 11cc87c commit 8b0e4f8

17 files changed

Lines changed: 287 additions & 13 deletions

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,11 @@ itk_dev_entity:
126126
enabled: true
127127
```
128128

129-
When enabled, the `BlameableListener` (Doctrine `onFlush`) sets `createdBy` on insert and `modifiedBy` on every flush
130-
from `Symfony\Bundle\SecurityBundle\Security`. Bundle code types these as `?UserInterface`; the concrete class is
131-
resolved at runtime via Doctrine `resolve_target_entities`, configured by `itk_dev_entity.user_class`.
129+
When enabled, the `BlameableListener` (Doctrine `onFlush`) reads the current user from
130+
`Symfony\Bundle\SecurityBundle\Security` and sets `createdBy` + `modifiedBy` on insert, and `modifiedBy` on every
131+
update. Flushes that happen outside an authenticated request (workers, console commands) leave the existing blame
132+
fields untouched. Bundle code types these as `?UserInterface`; the concrete class is resolved at runtime via Doctrine
133+
`resolve_target_entities`, configured by `itk_dev_entity.user_class`.
132134

133135
### Opt-in: archivable
134136

docker-compose.postgres.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ services:
99
image: postgres:16
1010
ports: !reset []
1111
environment: !override
12-
POSTGRES_DB: app_bundle_test
13-
POSTGRES_USER: app
14-
POSTGRES_PASSWORD: "!ChangeMe!"
12+
POSTGRES_DB: db
13+
POSTGRES_USER: db
14+
POSTGRES_PASSWORD: db
1515
healthcheck:
16-
test: ["CMD-SHELL", "pg_isready -U app -d app_bundle_test"]
16+
test: ["CMD-SHELL", "pg_isready -U db -d db"]
1717
start_period: 10s
1818
interval: 10s
1919
timeout: 5s
@@ -29,7 +29,7 @@ services:
2929
args:
3030
PHP_FPM_IMAGE: ${PHP_FPM_IMAGE:-itkdev/php8.4-fpm:latest}
3131
environment:
32-
- DATABASE_URL=postgresql://app:!ChangeMe!@database:5432/app_bundle_test?serverVersion=16&charset=utf8
32+
- DATABASE_URL=postgresql://db:db@database:5432/db?serverVersion=16&charset=utf8
3333

3434
volumes:
3535
postgres-data:

src/DependencyInjection/ITKDevEntityExtension.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use ITKDev\EntityBundle\Doctrine\Listener\SoftDeleteListener;
1515
use ITKDev\EntityBundle\Doctrine\Listener\TimestampableListener;
1616
use ITKDev\EntityBundle\Privacy\Attribute\Anonymize;
17+
use ITKDev\EntityBundle\Privacy\Strategy;
1718
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
1819
use Symfony\Component\Config\FileLocator;
1920
use Symfony\Component\Config\Resource\DirectoryResource;
@@ -180,6 +181,10 @@ private function mergeAnonymizationRules(array $discovered, array $configRules):
180181
if (!\is_string($property) || !\is_array($spec) || !isset($spec['strategy']) || !\is_string($spec['strategy'])) {
181182
throw new InvalidConfigurationException(sprintf('itk_dev_entity.anonymization.rules[%s][%s] must be { strategy: ..., replacement?: ... }', $class, (string) $property));
182183
}
184+
if (null === Strategy::tryFrom($spec['strategy'])) {
185+
$valid = implode(', ', array_map(static fn (Strategy $s): string => $s->value, Strategy::cases()));
186+
throw new InvalidConfigurationException(sprintf('itk_dev_entity.anonymization.rules[%s][%s].strategy must be one of: %s. Got "%s".', $class, $property, $valid, $spec['strategy']));
187+
}
183188
$replacement = $spec['replacement'] ?? null;
184189
if (null !== $replacement && !\is_string($replacement)) {
185190
throw new InvalidConfigurationException(sprintf('itk_dev_entity.anonymization.rules[%s][%s].replacement must be a string or null.', $class, $property));

src/Doctrine/Filter/ArchivableFilter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public function addFilterConstraint(ClassMetadata $targetEntity, string $targetT
1616
return '';
1717
}
1818

19-
$column = $targetEntity->getColumnName('archivedAt');
19+
$column = $this->getConnection()->quoteIdentifier($targetEntity->getColumnName('archivedAt'));
2020

2121
return sprintf('%s.%s IS NULL', $targetTableAlias, $column);
2222
}

src/Doctrine/Filter/SoftDeleteFilter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public function addFilterConstraint(ClassMetadata $targetEntity, string $targetT
1616
return '';
1717
}
1818

19-
$column = $targetEntity->getColumnName('deletedAt');
19+
$column = $this->getConnection()->quoteIdentifier($targetEntity->getColumnName('deletedAt'));
2020

2121
return sprintf('%s.%s IS NULL', $targetTableAlias, $column);
2222
}

src/Doctrine/Listener/BlameableListener.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,17 @@ public function onFlush(OnFlushEventArgs $args): void
4141
}
4242
}
4343

44+
if (null === $user) {
45+
return;
46+
}
47+
4448
foreach ($uow->getScheduledEntityUpdates() as $entity) {
4549
if (!$entity instanceof BlameableInterface) {
4650
continue;
4751
}
52+
if ($entity->getModifiedBy() === $user) {
53+
continue;
54+
}
4855
$entity->setModifiedBy($user);
4956
$uow->recomputeSingleEntityChangeSet($em->getClassMetadata($entity::class), $entity);
5057
}

src/Doctrine/Listener/SoftDeleteListener.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ public function onFlush(OnFlushEventArgs $args): void
3333
$entity->setDeletedAt($now);
3434

3535
$meta = $em->getClassMetadata($entity::class);
36+
// remove() left the entity in STATE_REMOVED; persist() brings it back to
37+
// STATE_MANAGED so scheduleExtraUpdate() will accept it.
3638
$em->persist($entity);
3739
$uow->scheduleExtraUpdate($entity, [
3840
'deletedAt' => [null, $now],
@@ -43,6 +45,12 @@ public function onFlush(OnFlushEventArgs $args): void
4345
}
4446
}
4547

48+
/**
49+
* Doctrine ORM 3 has no public API to cancel a scheduled deletion, so we reach into
50+
* the UnitOfWork's private $entityDeletions map and remove the entry directly. The
51+
* composer.json constraint pins doctrine/orm to ^3 — if that constraint is widened,
52+
* verify this property still exists (see SoftDeleteListenerInternalsTest).
53+
*/
4654
private static function cancelDeletion(UnitOfWork $uow, object $entity): void
4755
{
4856
$ref = new \ReflectionProperty(UnitOfWork::class, 'entityDeletions');

src/Entity/Trait/TimestampableTrait.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ trait TimestampableTrait
1414
#[ORM\Column(type: 'datetime_immutable')]
1515
private \DateTimeImmutable $updatedAt;
1616

17+
// Properties are filled by TimestampableListener on first flush, so they may be
18+
// uninitialized between `new Entity()` and `$em->flush()`. `?? null` is safe on
19+
// uninitialized typed properties (PHP 8+) and lets callers null-check before flush.
1720
public function getCreatedAt(): ?\DateTimeImmutable
1821
{
1922
return $this->createdAt ?? null;

src/Privacy/StrategyApplier.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ public function apply(Strategy $strategy, mixed $value, ?string $replacement = n
1919
return match ($strategy) {
2020
Strategy::NullValue => null,
2121
Strategy::Redact => $replacement ?? '[REDACTED]',
22-
Strategy::Hash => null === $value ? null : hash('sha256', (string) $value),
22+
Strategy::Hash => null === $value ? null : hash('sha256', (string) $value.$this->pepper),
2323
Strategy::Pseudonymize => null === $value
2424
? null
25-
: 'user_'.substr(sha1((string) $value.$this->pepper), 0, 12),
25+
: 'user_'.substr(hash('sha256', (string) $value.$this->pepper), 0, 12),
2626
};
2727
}
2828
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ITKDev\EntityBundle\Tests\Fixtures\Inheritance;
6+
7+
use ITKDev\EntityBundle\Audit\Attribute\Auditable;
8+
9+
/**
10+
* Concrete leaf two levels below the class carrying #[ITKDevEntity]. The bundle's
11+
* parent-chain walk must reach Grandparent for this class to be discovered.
12+
*/
13+
#[Auditable]
14+
final class GrandchildLeaf extends IntermediateParent
15+
{
16+
}

0 commit comments

Comments
 (0)