Skip to content

Commit 2404a30

Browse files
Feature: add a new event for many-to-many relationships (#498)
* feat: add a new event for many-to-many relationships * fix: change in strategy for adapt to adr * feat: new workflow for cases where the collection has already started and there are no diffs * fix: new test and remove unused parameters
1 parent 906a7a5 commit 2404a30

18 files changed

Lines changed: 1968 additions & 27 deletions

app/Audit/AbstractAuditLogFormatter.php

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace App\Audit;
44

55
use App\Audit\Utils\DateFormatter;
6+
use Doctrine\ORM\PersistentCollection;
67

78
/**
89
* Copyright 2025 OpenStack Foundation
@@ -32,10 +33,73 @@ final public function setContext(AuditContext $ctx): void
3233
$this->ctx = $ctx;
3334
}
3435

36+
protected function handleManyToManyCollection(array $change_set): ?PersistentCollectionMetadata
37+
{
38+
if (!isset($change_set['collection'])) {
39+
return null;
40+
}
41+
42+
$collection = $change_set['collection'];
43+
if (!($collection instanceof PersistentCollection)) {
44+
return null;
45+
}
46+
47+
$preloadedDeletedIds = $change_set['deleted_ids'] ?? [];
48+
if (!is_array($preloadedDeletedIds)) {
49+
$preloadedDeletedIds = [];
50+
}
51+
52+
return PersistentCollectionMetadata::fromCollection($collection, $preloadedDeletedIds);
53+
}
54+
55+
protected function processCollection(PersistentCollectionMetadata $metadata): ?array
56+
{
57+
$addedIds = [];
58+
$removedIds = [];
59+
60+
if (!empty($metadata->preloadedDeletedIds)) {
61+
$removedIds = array_values(array_unique(array_map('intval', $metadata->preloadedDeletedIds)));
62+
sort($removedIds);
63+
} else {
64+
$addedIds = $this->extractCollectionEntityIds($metadata->collection->getInsertDiff());
65+
$removedIds = $this->extractCollectionEntityIds($metadata->collection->getDeleteDiff());
66+
}
67+
68+
return [
69+
'field' => $metadata->fieldName,
70+
'target_entity' => class_basename($metadata->targetEntity),
71+
'is_deletion' => $this->event_type === Interfaces\IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE,
72+
'added_ids' => $addedIds,
73+
'removed_ids' => $removedIds,
74+
];
75+
}
76+
77+
78+
/**
79+
* Extract IDs from entity objects in collection
80+
*/
81+
protected function extractCollectionEntityIds(array $entities): array
82+
{
83+
$ids = [];
84+
foreach ($entities as $entity) {
85+
if (method_exists($entity, 'getId')) {
86+
$id = $entity->getId();
87+
if ($id !== null) {
88+
$ids[] = $id;
89+
}
90+
}
91+
}
92+
93+
$uniqueIds = array_unique($ids);
94+
sort($uniqueIds);
95+
96+
return array_values($uniqueIds);
97+
}
98+
3599
protected function getUserInfo(): string
36100
{
37101
if (app()->runningInConsole()) {
38-
return 'Worker Job';
102+
return 'Worker Job';
39103
}
40104
if (!$this->ctx) {
41105
return 'Unknown (unknown)';
@@ -129,5 +193,27 @@ protected function formatFieldChange(string $prop_name, $old_value, $new_value):
129193
return sprintf("Property \"%s\" has changed from \"%s\" to \"%s\"", $prop_name, $old_display, $new_display);
130194
}
131195

196+
/**
197+
* Format detailed message for many-to-many collection changes
198+
*/
199+
protected static function formatManyToManyDetailedMessage(array $details, int $addCount, int $removeCount, string $action): string
200+
{
201+
$field = $details['field'] ?? 'unknown';
202+
$target = $details['target_entity'] ?? 'unknown';
203+
$addedIds = $details['added_ids'] ?? [];
204+
$removedIds = $details['removed_ids'] ?? [];
205+
206+
$parts = [];
207+
if (!empty($addedIds)) {
208+
$parts[] = sprintf("Added %d %s(s): %s", $addCount, $target, implode(', ', $addedIds));
209+
}
210+
if (!empty($removedIds)) {
211+
$parts[] = sprintf("Removed %d %s(s): %s", $removeCount, $target, implode(', ', $removedIds));
212+
}
213+
214+
$detailStr = implode(' | ', $parts);
215+
return sprintf("Many-to-Many collection '%s' %s: %s", $field, $action, $detailStr);
216+
}
217+
132218
abstract public function format(mixed $subject, array $change_set): ?string;
133219
}

app/Audit/AuditEventListener.php

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
use App\Audit\Interfaces\IAuditStrategy;
1616
use Doctrine\ORM\Event\OnFlushEventArgs;
17+
use Doctrine\ORM\EntityManagerInterface;
18+
use Doctrine\ORM\PersistentCollection;
1719
use Illuminate\Support\Facades\App;
1820
use Illuminate\Support\Facades\Log;
1921
use Illuminate\Support\Facades\Route;
@@ -25,16 +27,16 @@
2527
class AuditEventListener
2628
{
2729
private const ROUTE_METHOD_SEPARATOR = '|';
28-
30+
private $em;
2931
public function onFlush(OnFlushEventArgs $eventArgs): void
3032
{
3133
if (app()->environment('testing')) {
3234
return;
3335
}
34-
$em = $eventArgs->getObjectManager();
35-
$uow = $em->getUnitOfWork();
36+
$this->em = $eventArgs->getObjectManager();
37+
$uow = $this->em->getUnitOfWork();
3638
// Strategy selection based on environment configuration
37-
$strategy = $this->getAuditStrategy($em);
39+
$strategy = $this->getAuditStrategy($this->em);
3840
if (!$strategy) {
3941
return; // No audit strategy enabled
4042
}
@@ -52,11 +54,23 @@ public function onFlush(OnFlushEventArgs $eventArgs): void
5254

5355
foreach ($uow->getScheduledEntityDeletions() as $entity) {
5456
$strategy->audit($entity, [], IAuditStrategy::EVENT_ENTITY_DELETION, $ctx);
57+
}
58+
foreach ($uow->getScheduledCollectionDeletions() as $col) {
59+
[$subject, $payload, $eventType] = $this->auditCollection($col, IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE);
60+
61+
if (!is_null($subject)) {
62+
$strategy->audit($subject, $payload, $eventType, $ctx);
63+
}
5564
}
5665

5766
foreach ($uow->getScheduledCollectionUpdates() as $col) {
58-
$strategy->audit($col, [], IAuditStrategy::EVENT_COLLECTION_UPDATE, $ctx);
67+
[$subject, $payload, $eventType] = $this->auditCollection($col, IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE);
68+
69+
if (!is_null($subject)) {
70+
$strategy->audit($subject, $payload, $eventType, $ctx);
71+
}
5972
}
73+
6074
} catch (\Exception $e) {
6175
Log::error('Audit event listener failed', [
6276
'error' => $e->getMessage(),
@@ -98,7 +112,7 @@ private function buildAuditContext(): AuditContext
98112
$member = $memberRepo->findOneBy(["user_external_id" => $userExternalId]);
99113
}
100114

101-
//$ui = app()->bound('ui.context') ? app('ui.context') : [];
115+
$ui = [];
102116

103117
$req = request();
104118
$rawRoute = null;
@@ -127,4 +141,101 @@ private function buildAuditContext(): AuditContext
127141
rawRoute: $rawRoute
128142
);
129143
}
144+
145+
/**
146+
* Audit collection changes
147+
* Returns triple: [$subject, $payload, $eventType]
148+
* Subject will be null if collection should not be audited
149+
*
150+
* @param object $subject The collection
151+
* @param string $eventType The event type constant (EVENT_COLLECTION_MANYTOMANY_DELETE or EVENT_COLLECTION_MANYTOMANY_UPDATE)
152+
* @return array [$subject, $payload, $eventType]
153+
*/
154+
private function auditCollection($subject, string $eventType): array
155+
{
156+
if (!$subject instanceof PersistentCollection) {
157+
return [null, null, null];
158+
}
159+
160+
$mapping = $subject->getMapping();
161+
162+
if (!$mapping->isManyToMany()) {
163+
return [$subject, [], IAuditStrategy::EVENT_COLLECTION_UPDATE];
164+
}
165+
166+
if (!$mapping->isOwningSide()) {
167+
Log::debug("AuditEventListener::Skipping audit for non-owning side of many-to-many collection");
168+
return [null, null, null];
169+
}
170+
171+
$owner = $subject->getOwner();
172+
if ($owner === null) {
173+
return [null, null, null];
174+
}
175+
176+
$payload = ['collection' => $subject];
177+
178+
if ($eventType === IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE
179+
&& (
180+
!$subject->isInitialized()
181+
|| ($subject->isInitialized() && count($subject->getDeleteDiff()) === 0)
182+
)) {
183+
if ($this->em instanceof EntityManagerInterface) {
184+
$payload['deleted_ids'] = $this->fetchManyToManyIds($subject, $this->em);
185+
}
186+
}
187+
188+
return [$owner, $payload, $eventType];
189+
}
190+
191+
192+
private function fetchManyToManyIds(PersistentCollection $collection, EntityManagerInterface $em): array
193+
{
194+
try {
195+
$mapping = $collection->getMapping();
196+
$joinTable = $mapping->joinTable;
197+
$tableName = is_array($joinTable) ? ($joinTable['name'] ?? null) : ($joinTable->name ?? null);
198+
$joinColumns = is_array($joinTable) ? ($joinTable['joinColumns'] ?? []) : ($joinTable->joinColumns ?? []);
199+
$inverseJoinColumns = is_array($joinTable) ? ($joinTable['inverseJoinColumns'] ?? []) : ($joinTable->inverseJoinColumns ?? []);
200+
201+
$joinColumn = $joinColumns[0] ?? null;
202+
$inverseJoinColumn = $inverseJoinColumns[0] ?? null;
203+
$sourceColumn = is_array($joinColumn) ? ($joinColumn['name'] ?? null) : ($joinColumn->name ?? null);
204+
$targetColumn = is_array($inverseJoinColumn) ? ($inverseJoinColumn['name'] ?? null) : ($inverseJoinColumn->name ?? null);
205+
206+
if (!$sourceColumn || !$targetColumn || !$tableName) {
207+
return [];
208+
}
209+
210+
$owner = $collection->getOwner();
211+
if ($owner === null) {
212+
return [];
213+
}
214+
215+
$ownerId = method_exists($owner, 'getId') ? $owner->getId() : null;
216+
if ($ownerId === null) {
217+
$ownerMeta = $em->getClassMetadata(get_class($owner));
218+
$ownerIds = $ownerMeta->getIdentifierValues($owner);
219+
$ownerId = empty($ownerIds) ? null : reset($ownerIds);
220+
}
221+
222+
if ($ownerId === null) {
223+
return [];
224+
}
225+
226+
$ids = $em->getConnection()->fetchFirstColumn(
227+
"SELECT {$targetColumn} FROM {$tableName} WHERE {$sourceColumn} = ?",
228+
[$ownerId]
229+
);
230+
231+
return array_values(array_map('intval', $ids));
232+
233+
} catch (\Exception $e) {
234+
Log::error("AuditEventListener::fetchManyToManyIds error: " . $e->getMessage(), [
235+
'exception' => get_class($e),
236+
'trace' => $e->getTraceAsString()
237+
]);
238+
return [];
239+
}
240+
}
130241
}

app/Audit/AuditLogFormatterFactory.php

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
use App\Audit\ConcreteFormatters\EntityCollectionUpdateAuditLogFormatter;
1919
use App\Audit\ConcreteFormatters\EntityCreationAuditLogFormatter;
2020
use App\Audit\ConcreteFormatters\EntityDeletionAuditLogFormatter;
21+
use App\Audit\ConcreteFormatters\DefaultEntityManyToManyCollectionUpdateAuditLogFormatter;
22+
use App\Audit\ConcreteFormatters\DefaultEntityManyToManyCollectionDeleteAuditLogFormatter;
2123
use App\Audit\ConcreteFormatters\EntityUpdateAuditLogFormatter;
2224
use App\Audit\Interfaces\IAuditStrategy;
2325
use Doctrine\ORM\PersistentCollection;
@@ -57,9 +59,7 @@ public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLo
5759
);
5860
if (method_exists($subject, 'getTypeClass')) {
5961
$type = $subject->getTypeClass();
60-
// Your log shows this is ClassMetadata
6162
if ($type instanceof ClassMetadata) {
62-
// Doctrine supports either getName() or public $name
6363
$targetEntity = method_exists($type, 'getName') ? $type->getName() : ($type->name ?? null);
6464
} elseif (is_string($type)) {
6565
$targetEntity = $type;
@@ -71,7 +71,6 @@ public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLo
7171
$targetEntity = $mapping['targetEntity'] ?? null;
7272
Log::debug("AuditLogFormatterFactory::make getMapping targetEntity {$targetEntity}");
7373
} else {
74-
// last-resort: read private association metadata (still no hydration)
7574
$ref = new \ReflectionObject($subject);
7675
foreach (['association', 'mapping', 'associationMapping'] as $propName) {
7776
if ($ref->hasProperty($propName)) {
@@ -107,6 +106,18 @@ public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLo
107106

108107
$formatter = new EntityCollectionUpdateAuditLogFormatter($child_entity_formatter);
109108
break;
109+
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE:
110+
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
111+
if (is_null($formatter)) {
112+
$formatter = new DefaultEntityManyToManyCollectionUpdateAuditLogFormatter();
113+
}
114+
break;
115+
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE:
116+
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
117+
if (is_null($formatter)) {
118+
$formatter = new DefaultEntityManyToManyCollectionDeleteAuditLogFormatter();
119+
}
120+
break;
110121
case IAuditStrategy::EVENT_ENTITY_CREATION:
111122
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
112123
if(is_null($formatter)) {

0 commit comments

Comments
 (0)