Skip to content

Commit bd0d027

Browse files
feat: add a new event for many-to-many relationships
1 parent 27c0e84 commit bd0d027

File tree

6 files changed

+488
-2
lines changed

6 files changed

+488
-2
lines changed

app/Audit/AbstractAuditLogFormatter.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
namespace App\Audit;
44

55
use App\Audit\Utils\DateFormatter;
6+
use Doctrine\ORM\PersistentCollection;
7+
use Illuminate\Support\Facades\Log;
68

79
/**
810
* Copyright 2025 OpenStack Foundation
@@ -32,6 +34,90 @@ final public function setContext(AuditContext $ctx): void
3234
$this->ctx = $ctx;
3335
}
3436

37+
38+
protected function processCollection(
39+
mixed $owner,
40+
mixed $col,
41+
mixed $uow,
42+
bool $isDeletion = false
43+
): ?array
44+
{
45+
if (!is_object($col) || !method_exists($col, 'getMapping') || !method_exists($col, 'getInsertDiff')) {
46+
return null;
47+
}
48+
49+
$mapping = $col->getMapping();
50+
51+
$addedEntities = $col->getInsertDiff();
52+
$removedEntities = $col->getDeleteDiff();
53+
54+
$addedIds = $this->extractCollectionEntityIds($addedEntities);
55+
$removedIds = $this->extractCollectionEntityIds($removedEntities);
56+
57+
if (empty($removedIds) && !empty($addedIds)) {
58+
$this->recoverCollectionRemovalIds($uow, $owner, $mapping, $removedIds);
59+
}
60+
61+
if (empty($addedIds) && empty($removedIds)) {
62+
return null;
63+
}
64+
65+
return [
66+
'field' => $mapping['fieldName'] ?? 'unknown',
67+
'target_entity' => $mapping['targetEntity'] ?? null,
68+
'is_deletion' => $isDeletion,
69+
'added_ids' => $addedIds,
70+
'removed_ids' => $removedIds,
71+
'join_table' => $mapping['joinTable']['name'] ?? null,
72+
];
73+
}
74+
75+
/**
76+
* Recover removed IDs from original entity data
77+
*/
78+
protected function recoverCollectionRemovalIds($uow, $owner, $mapping, &$removedIds): void
79+
{
80+
try {
81+
$originalData = $uow->getOriginalEntityData($owner);
82+
$fieldName = $mapping['fieldName'] ?? null;
83+
84+
if ($fieldName && isset($originalData[$fieldName])) {
85+
$originalCollection = $originalData[$fieldName];
86+
if ($originalCollection instanceof PersistentCollection || is_array($originalCollection)) {
87+
$originalEntities = is_array($originalCollection)
88+
? $originalCollection
89+
: $originalCollection->toArray();
90+
$removedIds = $this->extractCollectionEntityIds($originalEntities);
91+
}
92+
}
93+
} catch (\Exception $e) {
94+
Log::warning('Failed to recover removed IDs from original entity data', [
95+
'error' => $e->getMessage()
96+
]);
97+
}
98+
}
99+
100+
/**
101+
* Extract IDs from entity objects in collection
102+
*/
103+
protected function extractCollectionEntityIds(array $entities): array
104+
{
105+
$ids = [];
106+
foreach ($entities as $entity) {
107+
if (method_exists($entity, 'getId')) {
108+
$id = $entity->getId();
109+
if ($id !== null) {
110+
$ids[] = $id;
111+
}
112+
}
113+
}
114+
115+
$uniqueIds = array_unique($ids);
116+
sort($uniqueIds);
117+
118+
return array_values($uniqueIds);
119+
}
120+
35121
protected function getUserInfo(): string
36122
{
37123
if (app()->runningInConsole()) {

app/Audit/AuditEventListener.php

Lines changed: 45 additions & 2 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\Mapping\ClassMetadata;
18+
use Doctrine\ORM\PersistentCollection;
1719
use Illuminate\Support\Facades\App;
1820
use Illuminate\Support\Facades\Log;
1921
use Illuminate\Support\Facades\Route;
@@ -53,10 +55,14 @@ public function onFlush(OnFlushEventArgs $eventArgs): void
5355
foreach ($uow->getScheduledEntityDeletions() as $entity) {
5456
$strategy->audit($entity, [], IAuditStrategy::EVENT_ENTITY_DELETION, $ctx);
5557
}
56-
58+
foreach ($uow->getScheduledCollectionDeletions() as $col) {
59+
$this->auditCollection($col, $strategy, $ctx, $uow, true);
60+
}
5761
foreach ($uow->getScheduledCollectionUpdates() as $col) {
58-
$strategy->audit($col, [], IAuditStrategy::EVENT_COLLECTION_UPDATE, $ctx);
62+
$this->auditCollection($col, $strategy, $ctx, $uow, false);
5963
}
64+
65+
6066
} catch (\Exception $e) {
6167
Log::error('Audit event listener failed', [
6268
'error' => $e->getMessage(),
@@ -127,4 +133,41 @@ private function buildAuditContext(): AuditContext
127133
rawRoute: $rawRoute
128134
);
129135
}
136+
137+
138+
/**
139+
* Audit collection changes
140+
* Only determines if it's ManyToMany and emits appropriate event
141+
*/
142+
private function auditCollection($subject, IAuditStrategy $strategy, AuditContext $ctx, $uow, bool $isDeletion = false): void
143+
{
144+
if (!$subject instanceof PersistentCollection) {
145+
return;
146+
}
147+
148+
$mapping = $subject->getMapping();
149+
$isManyToMany = ($mapping['type'] ?? null) === ClassMetadata::MANY_TO_MANY;
150+
151+
if ($isManyToMany && empty($mapping['isOwningSide'])) {
152+
return;
153+
}
154+
155+
$owner = $subject->getOwner();
156+
if ($owner === null) {
157+
return;
158+
}
159+
160+
161+
$payload = [
162+
'collection' => $subject,
163+
'uow' => $uow,
164+
'is_deletion' => $isDeletion,
165+
];
166+
167+
$eventType = $isManyToMany
168+
? ($isDeletion ? IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE : IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE)
169+
: IAuditStrategy::EVENT_COLLECTION_UPDATE;
170+
171+
$strategy->audit($owner, $payload, $eventType, $ctx);
172+
}
130173
}

app/Audit/AuditLogFormatterFactory.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,14 @@ public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLo
107107

108108
$formatter = new EntityCollectionUpdateAuditLogFormatter($child_entity_formatter);
109109
break;
110+
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE:
111+
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE:
112+
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
113+
if(is_null($formatter)) {
114+
$child_entity_formatter = ChildEntityFormatterFactory::build($subject);
115+
$formatter = $child_entity_formatter;
116+
}
117+
break;
110118
case IAuditStrategy::EVENT_ENTITY_CREATION:
111119
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
112120
if(is_null($formatter)) {

app/Audit/ConcreteFormatters/SummitAttendeeAuditLogFormatter.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,85 @@ public function format($subject, array $change_set): ?string
4242

4343
case IAuditStrategy::EVENT_ENTITY_DELETION:
4444
return sprintf("Attendee (%s) '%s' deleted by user %s", $id, $name, $this->getUserInfo());
45+
46+
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE:
47+
return $this->handleManyToManyCollection($subject, $change_set, $id, $name, false);
48+
49+
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE:
50+
return $this->handleManyToManyCollection($subject, $change_set, $id, $name, true);
4551
}
4652
} catch (\Exception $ex) {
4753
Log::warning("SummitAttendeeAuditLogFormatter error: " . $ex->getMessage());
4854
}
4955

5056
return null;
5157
}
58+
59+
private function handleManyToManyCollection(SummitAttendee $subject, array $change_set, $id, $name, bool $isDeletion): ?string
60+
{
61+
if (!isset($change_set['collection']) || !isset($change_set['uow'])) {
62+
return null;
63+
}
64+
65+
$col = $change_set['collection'];
66+
$uow = $change_set['uow'];
67+
68+
$collectionData = $this->processCollection($subject, $col, $uow, $isDeletion);
69+
if (!$collectionData) {
70+
return null;
71+
}
72+
73+
return $isDeletion
74+
? $this->formatManyToManyDelete($subject, $collectionData, $id, $name)
75+
: $this->formatManyToManyUpdate($subject, $collectionData, $id, $name);
76+
}
77+
78+
private function formatManyToManyUpdate(SummitAttendee $subject, array $collectionData, $id, $name): ?string
79+
{
80+
try {
81+
$field = $collectionData['field'] ?? 'unknown';
82+
$targetEntity = $collectionData['target_entity'] ?? 'unknown';
83+
$added_ids = $collectionData['added_ids'] ?? [];
84+
85+
$ownerId = $subject->getId();
86+
87+
$description = sprintf(
88+
"Attendee (%s), Field: %s, Target: %s, Added IDs: %s, by user %s",
89+
$ownerId,
90+
$field,
91+
class_basename($targetEntity),
92+
json_encode($added_ids),
93+
$this->getUserInfo()
94+
);
95+
96+
return $description;
97+
98+
} catch (\Exception $ex) {
99+
Log::warning("SummitAttendeeAuditLogFormatter::formatManyToManyUpdate error: " . $ex->getMessage());
100+
return sprintf("Attendee (%s) '%s' association updated by user %s", $id, $name, $this->getUserInfo());
101+
}
102+
}
103+
104+
private function formatManyToManyDelete(SummitAttendee $subject, array $collectionData, $id, $name): ?string
105+
{
106+
try {
107+
$field = $collectionData['field'] ?? 'unknown';
108+
$targetEntity = $collectionData['target_entity'] ?? 'unknown';
109+
$removed_ids = $collectionData['removed_ids'] ?? [];
110+
111+
$description = sprintf(
112+
"Attendee Delete: Field: %s, Target: %s, Cleared IDs: %s, by user %s",
113+
$field,
114+
class_basename($targetEntity),
115+
json_encode($removed_ids),
116+
$this->getUserInfo()
117+
);
118+
119+
return $description;
120+
121+
} catch (\Exception $ex) {
122+
Log::warning("SummitAttendeeAuditLogFormatter::formatManyToManyDelete error: " . $ex->getMessage());
123+
return sprintf("Attendee (%s) '%s' association deleted by user %s", $id, $name, $this->getUserInfo());
124+
}
125+
}
52126
}

app/Audit/Interfaces/IAuditStrategy.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ public function audit($subject, array $change_set, string $event_type, AuditCont
3333
public const EVENT_ENTITY_CREATION = 'event_entity_creation';
3434
public const EVENT_ENTITY_DELETION = 'event_entity_deletion';
3535
public const EVENT_ENTITY_UPDATE = 'event_entity_update';
36+
public const EVENT_COLLECTION_MANYTOMANY_UPDATE = 'event_collection_manytomany_update';
37+
public const EVENT_COLLECTION_MANYTOMANY_DELETE = 'event_collection_manytomany_delete';
3638

3739
public const ACTION_CREATE = 'create';
3840
public const ACTION_UPDATE = 'update';

0 commit comments

Comments
 (0)