|
|
|
@@ -0,0 +1,513 @@
|
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Module\Core\Infrastructure\Doctrine;
|
|
|
|
|
|
|
|
|
|
use App\Module\Core\Infrastructure\Audit\AuditLogWriter;
|
|
|
|
|
use App\Shared\Domain\Attribute\Auditable;
|
|
|
|
|
use App\Shared\Domain\Attribute\AuditIgnore;
|
|
|
|
|
use DateTimeInterface;
|
|
|
|
|
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
|
|
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
|
|
|
use Doctrine\ORM\Event\OnFlushEventArgs;
|
|
|
|
|
use Doctrine\ORM\Event\PostFlushEventArgs;
|
|
|
|
|
use Doctrine\ORM\Events;
|
|
|
|
|
use Doctrine\ORM\Mapping\ClassMetadata;
|
|
|
|
|
use Doctrine\ORM\PersistentCollection;
|
|
|
|
|
use Doctrine\ORM\UnitOfWork;
|
|
|
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
|
use ReflectionClass;
|
|
|
|
|
use ReflectionProperty;
|
|
|
|
|
use Throwable;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Listener Doctrine qui produit les lignes d'audit pour les entites portant
|
|
|
|
|
* l'attribut #[Auditable].
|
|
|
|
|
*
|
|
|
|
|
* Pipeline en deux temps :
|
|
|
|
|
* 1. onFlush : on traverse UnitOfWork (insertions / updates / deletions) et
|
|
|
|
|
* on capture les changements en memoire. Aucune ecriture SQL cote audit
|
|
|
|
|
* a ce stade pour ne pas interferer avec la transaction ORM en cours.
|
|
|
|
|
* 2. postFlush : on ecrit via AuditLogWriter (connexion DBAL dediee).
|
|
|
|
|
*
|
|
|
|
|
* Pattern swap-and-clear dans postFlush :
|
|
|
|
|
* - on copie localement la liste des evenements ;
|
|
|
|
|
* - on vide la propriete pendingLogs immediatement ;
|
|
|
|
|
* - on itere la copie.
|
|
|
|
|
* Pourquoi : si une ecriture audit declenchait un flush re-entrant (cas rare,
|
|
|
|
|
* ex: callback listener externe), l'etat de pendingLogs serait deja nettoye —
|
|
|
|
|
* pas de double insertion, pas de boucle infinie.
|
|
|
|
|
*
|
|
|
|
|
* Erreurs silencieuses : un INSERT audit qui echoue est logue en error mais
|
|
|
|
|
* jamais propage. Acceptable pour un CRM interne ; a reconsiderer si besoin
|
|
|
|
|
* de garantie forte (dead-letter queue, retry).
|
|
|
|
|
*
|
|
|
|
|
* Collections (OneToMany / ManyToMany) :
|
|
|
|
|
* - Les modifications de collections sont tracees via
|
|
|
|
|
* `getScheduledCollectionUpdates()` et reportees comme un changement
|
|
|
|
|
* `{fieldName: {added: [ids], removed: [ids]}}` dans le changeset de
|
|
|
|
|
* l'entite proprietaire.
|
|
|
|
|
* - Si l'entite proprietaire est deja scheduled pour insertion, la diff
|
|
|
|
|
* est merge dans le snapshot create (en tant que liste d'IDs initiaux).
|
|
|
|
|
* - Si l'entite proprietaire est scheduled pour deletion, les collections
|
|
|
|
|
* associees sont ignorees (deja couvertes par le snapshot delete).
|
|
|
|
|
*
|
|
|
|
|
* Limitations connues :
|
|
|
|
|
* - Les ManyToOne sont tracees par ID (null-safe via `?->getId()`).
|
|
|
|
|
* - Les DELETE / UPDATE bulk DQL et les `Connection::executeStatement()`
|
|
|
|
|
* bruts BYPASSENT le listener : onFlush n'est jamais appele. Toute
|
|
|
|
|
* operation de purge/nettoyage qui doit etre auditee doit passer par
|
|
|
|
|
* `EntityManager::remove()` + `flush()`. Si un futur batch (ex: commande
|
|
|
|
|
* "purger users inactifs") utilise du DQL bulk, les suppressions ne
|
|
|
|
|
* seront pas dans `audit_log` — choix d'architecture explicite a faire.
|
|
|
|
|
*/
|
|
|
|
|
#[AsDoctrineListener(event: Events::onFlush)]
|
|
|
|
|
#[AsDoctrineListener(event: Events::postFlush)]
|
|
|
|
|
final class AuditListener
|
|
|
|
|
{
|
|
|
|
|
/**
|
|
|
|
|
* Cache par FQCN : true si la classe porte #[Auditable], false sinon.
|
|
|
|
|
* Evite une ReflectionClass par entite a chaque flush.
|
|
|
|
|
*
|
|
|
|
|
* @var array<class-string, bool>
|
|
|
|
|
*/
|
|
|
|
|
private array $auditableCache = [];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cache par FQCN : liste des noms de proprietes ignorees (#[AuditIgnore]).
|
|
|
|
|
*
|
|
|
|
|
* @var array<class-string, list<string>>
|
|
|
|
|
*/
|
|
|
|
|
private array $ignoredPropertiesCache = [];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Logs en attente d'ecriture (remplis en onFlush, consommes en postFlush).
|
|
|
|
|
*
|
|
|
|
|
* Pour les inserts, l'ID est assignee DURANT le flush : on capture la
|
|
|
|
|
* reference de l'entite et on resout l'ID au moment du postFlush.
|
|
|
|
|
*
|
|
|
|
|
* @var list<array{entity: object, metadata: ClassMetadata, entityType: string, action: string, changes: array<string, mixed>, capturedId: ?string}>
|
|
|
|
|
*/
|
|
|
|
|
private array $pendingLogs = [];
|
|
|
|
|
|
|
|
|
|
public function __construct(
|
|
|
|
|
private readonly AuditLogWriter $writer,
|
|
|
|
|
private readonly LoggerInterface $logger,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
public function onFlush(OnFlushEventArgs $args): void
|
|
|
|
|
{
|
|
|
|
|
/** @var EntityManagerInterface $em */
|
|
|
|
|
$em = $args->getObjectManager();
|
|
|
|
|
$uow = $em->getUnitOfWork();
|
|
|
|
|
|
|
|
|
|
// Reset defensif en debut de cycle : si un flush precedent a leve une
|
|
|
|
|
// exception, Doctrine n'appelle PAS postFlush et pendingLogs reste
|
|
|
|
|
// rempli avec des changements jamais committes. Sans ce reset, un
|
|
|
|
|
// flush ulterieur reussi ecrirait les fausses entrees dans audit_log.
|
|
|
|
|
// Le swap-and-clear dans postFlush couvre deja les flushes re-entrants,
|
|
|
|
|
// ce reset ne le fragilise donc pas.
|
|
|
|
|
$this->pendingLogs = [];
|
|
|
|
|
|
|
|
|
|
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
|
|
|
|
$this->capturePendingLog($entity, $em, $uow, 'create');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
|
|
|
|
$this->capturePendingLog($entity, $em, $uow, 'update');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
|
|
|
|
$this->capturePendingLog($entity, $em, $uow, 'delete');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collections to-many (OneToMany / ManyToMany) : `getEntityChangeSet()`
|
|
|
|
|
// ne les expose pas, il faut interroger `UnitOfWork` separement. On
|
|
|
|
|
// merge la diff dans le log de l'entite proprietaire si elle est deja
|
|
|
|
|
// scheduled, sinon on cree une entree "update" dediee.
|
|
|
|
|
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
|
|
|
|
|
$this->captureCollectionChange($collection, $em, cleared: false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
|
|
|
|
|
$this->captureCollectionChange($collection, $em, cleared: true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function postFlush(PostFlushEventArgs $args): void
|
|
|
|
|
{
|
|
|
|
|
// Swap-and-clear : protege d'un flush re-entrant (aucune double
|
|
|
|
|
// insertion meme si un callback utilisateur re-declenche un flush).
|
|
|
|
|
$logs = $this->pendingLogs;
|
|
|
|
|
$this->pendingLogs = [];
|
|
|
|
|
|
|
|
|
|
foreach ($logs as $log) {
|
|
|
|
|
// Pour les inserts, l'ID n'etait pas encore disponible en onFlush :
|
|
|
|
|
// on la resout maintenant (Doctrine l'a hydratee pendant le flush).
|
|
|
|
|
$entityId = $log['capturedId'] ?? $this->resolveEntityId($log['entity'], $log['metadata']);
|
|
|
|
|
|
|
|
|
|
if (null === $entityId) {
|
|
|
|
|
$this->logger->warning(
|
|
|
|
|
'AuditListener : impossible de resoudre l\'ID de l\'entite apres flush, entree ignoree',
|
|
|
|
|
['entityType' => $log['entityType'], 'action' => $log['action']]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$this->writer->log(
|
|
|
|
|
$log['entityType'],
|
|
|
|
|
$entityId,
|
|
|
|
|
$log['action'],
|
|
|
|
|
$log['changes'],
|
|
|
|
|
);
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
// Erreur audit : logue mais ne crashe jamais le flux metier.
|
|
|
|
|
$this->logger->error(
|
|
|
|
|
'Echec d\'ecriture audit_log',
|
|
|
|
|
[
|
|
|
|
|
'exception' => $e,
|
|
|
|
|
'entityType' => $log['entityType'],
|
|
|
|
|
'entityId' => $entityId,
|
|
|
|
|
'action' => $log['action'],
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function capturePendingLog(object $entity, EntityManagerInterface $em, UnitOfWork $uow, string $action): void
|
|
|
|
|
{
|
|
|
|
|
// Resolution via ClassMetadata : `$entity::class` renvoie le FQCN du
|
|
|
|
|
// proxy Doctrine pour une entite chargee en lazy (ex:
|
|
|
|
|
// `Proxies\__CG__\App\Module\Core\Domain\Entity\User`) — `isAuditable()`
|
|
|
|
|
// le verrait comme non-auditable car `#[Auditable]` n'est declare que
|
|
|
|
|
// sur la classe parente.
|
|
|
|
|
$metadata = $em->getClassMetadata($entity::class);
|
|
|
|
|
$class = $metadata->getName();
|
|
|
|
|
|
|
|
|
|
if (!$this->isAuditable($class)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sur `delete`, on inclut aussi les collections to-many dans le
|
|
|
|
|
// snapshot : c'est la derniere occasion de capturer l'etat complet
|
|
|
|
|
// (ex: quelles permissions etaient rattachees au role supprime).
|
|
|
|
|
// Sur `create`, les collections initiales sont rapportees via
|
|
|
|
|
// captureCollectionChange quand l'entite est scheduled avec un
|
|
|
|
|
// collection update dans le meme flush.
|
|
|
|
|
$changes = match ($action) {
|
|
|
|
|
'update' => $this->buildUpdateChanges($entity, $uow, $class),
|
|
|
|
|
'create' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: false),
|
|
|
|
|
'delete' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: true),
|
|
|
|
|
default => [],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if ('update' === $action && [] === $changes) {
|
|
|
|
|
// Flush sans changement reel sur une entite auditable : on n'emet pas.
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pour delete/update, l'ID est deja set en onFlush — on la capture
|
|
|
|
|
// maintenant (apres postFlush, l'entite detachee peut perdre sa ref
|
|
|
|
|
// dans l'identity map). Pour create (IDENTITY), l'ID est generee par
|
|
|
|
|
// le flush — on differe a postFlush.
|
|
|
|
|
$capturedId = 'create' === $action ? null : $this->resolveEntityId($entity, $metadata);
|
|
|
|
|
|
|
|
|
|
$this->pendingLogs[] = [
|
|
|
|
|
'entity' => $entity,
|
|
|
|
|
'metadata' => $metadata,
|
|
|
|
|
'entityType' => $this->formatEntityType($class),
|
|
|
|
|
'action' => $action,
|
|
|
|
|
'changes' => $changes,
|
|
|
|
|
'capturedId' => $capturedId,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Capture la modification d'une collection to-many.
|
|
|
|
|
*
|
|
|
|
|
* Strategie de merge :
|
|
|
|
|
* - Si l'entite proprietaire est deja scheduled pour `delete` → ignore
|
|
|
|
|
* (redondant avec le snapshot delete deja produit).
|
|
|
|
|
* - Si l'entite est deja scheduled pour `create` → on ajoute le champ
|
|
|
|
|
* collection au snapshot initial, sous forme de liste d'IDs ajoutes.
|
|
|
|
|
* - Si l'entite est deja scheduled pour `update` → on merge la diff
|
|
|
|
|
* {added, removed} dans le changeset existant.
|
|
|
|
|
* - Sinon → on cree une nouvelle entree `update` dediee pour l'entite
|
|
|
|
|
* proprietaire (cas d'une collection modifiee sans autre changement
|
|
|
|
|
* sur l'entite elle-meme, ex : ajout d'une permission a un role).
|
|
|
|
|
*
|
|
|
|
|
* @param bool $cleared true si la collection entiere est supprimee
|
|
|
|
|
* (getScheduledCollectionDeletions) — tous les
|
|
|
|
|
* items du snapshot sont consideres comme retires
|
|
|
|
|
*/
|
|
|
|
|
private function captureCollectionChange(PersistentCollection $collection, EntityManagerInterface $em, bool $cleared): void
|
|
|
|
|
{
|
|
|
|
|
$owner = $collection->getOwner();
|
|
|
|
|
if (null === $owner) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Voir capturePendingLog : meme contournement proxy Doctrine.
|
|
|
|
|
$class = $em->getClassMetadata($owner::class)->getName();
|
|
|
|
|
if (!$this->isAuditable($class)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$fieldName = $collection->getMapping()->fieldName;
|
|
|
|
|
if (in_array($fieldName, $this->getIgnoredProperties($class), true)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($cleared) {
|
|
|
|
|
$added = [];
|
|
|
|
|
$removed = array_map(
|
|
|
|
|
fn ($item): mixed => $this->normalizeValue($item),
|
|
|
|
|
$collection->getSnapshot(),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
$added = array_map(
|
|
|
|
|
fn ($item): mixed => $this->normalizeValue($item),
|
|
|
|
|
$collection->getInsertDiff(),
|
|
|
|
|
);
|
|
|
|
|
$removed = array_map(
|
|
|
|
|
fn ($item): mixed => $this->normalizeValue($item),
|
|
|
|
|
$collection->getDeleteDiff(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ([] === $added && [] === $removed) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Chercher un log deja en attente pour cette entite, pour merger la
|
|
|
|
|
// diff au lieu de creer une entree d'audit redondante.
|
|
|
|
|
foreach ($this->pendingLogs as $idx => $log) {
|
|
|
|
|
if ($log['entity'] !== $owner) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ('delete' === $log['action']) {
|
|
|
|
|
// Deletion de l'entite : la collection suit mecaniquement,
|
|
|
|
|
// pas d'entree dediee (le snapshot delete contient deja
|
|
|
|
|
// l'etat a supprimer).
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ('create' === $log['action']) {
|
|
|
|
|
// Insertion : le snapshot create ne contient pas les
|
|
|
|
|
// collections (buildSnapshot ignore les to-many). On ajoute
|
|
|
|
|
// donc la liste des items initiaux comme IDs, pour avoir
|
|
|
|
|
// une trace complete de l'etat a la creation. array_values
|
|
|
|
|
// garantit un array JSON (pas un objet) si les cles du diff
|
|
|
|
|
// ne sont pas sequentielles.
|
|
|
|
|
$this->pendingLogs[$idx]['changes'][$fieldName] = array_values($added);
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update : on merge dans le changeset existant.
|
|
|
|
|
$this->pendingLogs[$idx]['changes'][$fieldName] = [
|
|
|
|
|
'added' => array_values($added),
|
|
|
|
|
'removed' => array_values($removed),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Aucun log existant : l'entite n'a eu QUE des changements de
|
|
|
|
|
// collection. On cree une entree update minimale.
|
|
|
|
|
$metadata = $em->getClassMetadata($class);
|
|
|
|
|
|
|
|
|
|
$this->pendingLogs[] = [
|
|
|
|
|
'entity' => $owner,
|
|
|
|
|
'metadata' => $metadata,
|
|
|
|
|
'entityType' => $this->formatEntityType($class),
|
|
|
|
|
'action' => 'update',
|
|
|
|
|
'changes' => [$fieldName => [
|
|
|
|
|
'added' => array_values($added),
|
|
|
|
|
'removed' => array_values($removed),
|
|
|
|
|
]],
|
|
|
|
|
'capturedId' => $this->resolveEntityId($owner, $metadata),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build du changeset "update" : {champ: {old, new}} a partir de
|
|
|
|
|
* `UnitOfWork::getEntityChangeSet()`. ManyToOne : on log l'ID,
|
|
|
|
|
* null-safe via `?->getId()`.
|
|
|
|
|
*
|
|
|
|
|
* @return array<string, array{old: mixed, new: mixed}>
|
|
|
|
|
*/
|
|
|
|
|
private function buildUpdateChanges(object $entity, UnitOfWork $uow, string $class): array
|
|
|
|
|
{
|
|
|
|
|
$changeSet = $uow->getEntityChangeSet($entity);
|
|
|
|
|
$ignored = $this->getIgnoredProperties($class);
|
|
|
|
|
$filteredChanges = [];
|
|
|
|
|
|
|
|
|
|
foreach ($changeSet as $field => [$oldValue, $newValue]) {
|
|
|
|
|
if (in_array($field, $ignored, true)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$filteredChanges[$field] = [
|
|
|
|
|
'old' => $this->normalizeValue($oldValue),
|
|
|
|
|
'new' => $this->normalizeValue($newValue),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $filteredChanges;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build d'un snapshot complet (create / delete) : lit toutes les
|
|
|
|
|
* proprietes non-ignorees via Reflection.
|
|
|
|
|
*
|
|
|
|
|
* @param bool $includeCollections si true, les associations to-many sont
|
|
|
|
|
* aussi snapshotees (liste d'IDs). Utilise
|
|
|
|
|
* uniquement sur `delete` pour preserver
|
|
|
|
|
* l'etat des relations au moment de la
|
|
|
|
|
* suppression. En create, on laisse
|
|
|
|
|
* captureCollectionChange enrichir le
|
|
|
|
|
* snapshot si une collection est modifiee
|
|
|
|
|
* dans le meme flush.
|
|
|
|
|
*
|
|
|
|
|
* @return array<string, mixed>
|
|
|
|
|
*/
|
|
|
|
|
private function buildSnapshot(object $entity, ClassMetadata $metadata, string $class, bool $includeCollections): array
|
|
|
|
|
{
|
|
|
|
|
$ignored = $this->getIgnoredProperties($class);
|
|
|
|
|
$snapshot = [];
|
|
|
|
|
|
|
|
|
|
foreach ($metadata->getFieldNames() as $field) {
|
|
|
|
|
if (in_array($field, $ignored, true)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$snapshot[$field] = $this->normalizeValue($metadata->getFieldValue($entity, $field));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($metadata->getAssociationNames() as $assoc) {
|
|
|
|
|
if (in_array($assoc, $ignored, true)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($metadata->isSingleValuedAssociation($assoc)) {
|
|
|
|
|
$related = $metadata->getFieldValue($entity, $assoc);
|
|
|
|
|
$snapshot[$assoc] = null !== $related && method_exists($related, 'getId')
|
|
|
|
|
? $related->getId()
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!$includeCollections) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collection to-many : snapshot = liste d'IDs. On itere la
|
|
|
|
|
// Collection (PersistentCollection ou ArrayCollection) pour
|
|
|
|
|
// obtenir les elements. Pour un delete, la collection est deja
|
|
|
|
|
// chargee (Doctrine en a besoin pour les cascades).
|
|
|
|
|
$collection = $metadata->getFieldValue($entity, $assoc);
|
|
|
|
|
if (!is_iterable($collection)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$ids = [];
|
|
|
|
|
foreach ($collection as $item) {
|
|
|
|
|
$ids[] = $this->normalizeValue($item);
|
|
|
|
|
}
|
|
|
|
|
$snapshot[$assoc] = $ids;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $snapshot;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function isAuditable(string $class): bool
|
|
|
|
|
{
|
|
|
|
|
if (array_key_exists($class, $this->auditableCache)) {
|
|
|
|
|
return $this->auditableCache[$class];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$reflection = new ReflectionClass($class);
|
|
|
|
|
$isAuditable = [] !== $reflection->getAttributes(Auditable::class);
|
|
|
|
|
$this->auditableCache[$class] = $isAuditable;
|
|
|
|
|
|
|
|
|
|
return $isAuditable;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return list<string>
|
|
|
|
|
*/
|
|
|
|
|
private function getIgnoredProperties(string $class): array
|
|
|
|
|
{
|
|
|
|
|
if (array_key_exists($class, $this->ignoredPropertiesCache)) {
|
|
|
|
|
return $this->ignoredPropertiesCache[$class];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$ignored = [];
|
|
|
|
|
$reflection = new ReflectionClass($class);
|
|
|
|
|
|
|
|
|
|
foreach ($reflection->getProperties(ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE | ReflectionProperty::IS_PUBLIC) as $property) {
|
|
|
|
|
if ([] !== $property->getAttributes(AuditIgnore::class)) {
|
|
|
|
|
$ignored[] = $property->getName();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->ignoredPropertiesCache[$class] = $ignored;
|
|
|
|
|
|
|
|
|
|
return $ignored;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Transforme un FQCN `App\Module\Core\Domain\Entity\User` en `core.User`.
|
|
|
|
|
*
|
|
|
|
|
* Format `module.Entity` pour eviter les collisions inter-modules.
|
|
|
|
|
*/
|
|
|
|
|
private function formatEntityType(string $class): string
|
|
|
|
|
{
|
|
|
|
|
if (1 === preg_match('#^App\\\Module\\\(?<module>[^\\\]+)\\\.+\\\(?<entity>[^\\\]+)$#', $class, $matches)) {
|
|
|
|
|
return strtolower($matches['module']).'.'.$matches['entity'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback : on retourne le FQCN complet si la regex ne matche pas
|
|
|
|
|
// (entite hors structure modulaire — ne devrait pas arriver).
|
|
|
|
|
return $class;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function resolveEntityId(object $entity, ClassMetadata $metadata): ?string
|
|
|
|
|
{
|
|
|
|
|
$identifier = $metadata->getIdentifierValues($entity);
|
|
|
|
|
if ([] === $identifier) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cle composee : on concatene les valeurs. Cas rare sur le projet.
|
|
|
|
|
return implode('-', array_map(static fn ($v) => (string) $v, $identifier));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Normalise une valeur pour encodage JSON stable.
|
|
|
|
|
*/
|
|
|
|
|
private function normalizeValue(mixed $value): mixed
|
|
|
|
|
{
|
|
|
|
|
if ($value instanceof DateTimeInterface) {
|
|
|
|
|
return $value->format(DateTimeInterface::ATOM);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (is_object($value)) {
|
|
|
|
|
// Relation to-one non parsee par buildSnapshot (cas update sur
|
|
|
|
|
// un champ qui devient un objet) : on tente getId() si possible.
|
|
|
|
|
if (method_exists($value, 'getId')) {
|
|
|
|
|
return $value->getId();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (string) $value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $value;
|
|
|
|
|
}
|
|
|
|
|
}
|