Skip to content

Commit 02a1841

Browse files
feat: add a new event for many-to-many relationships
1 parent 8188557 commit 02a1841

12 files changed

Lines changed: 1522 additions & 4 deletions

app/Audit/AbstractAuditLogFormatter.php

Lines changed: 103 additions & 1 deletion
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,10 +34,110 @@ final public function setContext(AuditContext $ctx): void
3234
$this->ctx = $ctx;
3335
}
3436

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

app/Audit/AuditEventListener.php

Lines changed: 50 additions & 3 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,13 @@ 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+
6065
} catch (\Exception $e) {
6166
Log::error('Audit event listener failed', [
6267
'error' => $e->getMessage(),
@@ -98,7 +103,7 @@ private function buildAuditContext(): AuditContext
98103
$member = $memberRepo->findOneBy(["user_external_id" => $userExternalId]);
99104
}
100105

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

103108
$req = request();
104109
$rawRoute = null;
@@ -127,4 +132,46 @@ private function buildAuditContext(): AuditContext
127132
rawRoute: $rawRoute
128133
);
129134
}
135+
136+
137+
/**
138+
* Audit collection changes
139+
* Only determines if it's ManyToMany and emits appropriate event
140+
*/
141+
private function auditCollection($subject, IAuditStrategy $strategy, AuditContext $ctx, $uow, bool $isDeletion = false): void
142+
{
143+
if (!$subject instanceof PersistentCollection) {
144+
return;
145+
}
146+
147+
$mapping = $subject->getMapping();
148+
$isManyToMany = ($mapping['type'] ?? null) === ClassMetadata::MANY_TO_MANY;
149+
150+
if (!$isManyToMany) {
151+
$strategy->audit($subject, [], IAuditStrategy::EVENT_COLLECTION_UPDATE, $ctx);
152+
return;
153+
}
154+
155+
if (empty($mapping['isOwningSide'])) {
156+
Log::debug("Skipping audit for non-owning side of many-to-many collection: " . ($mapping['fieldName'] ?? 'unknown'));
157+
return;
158+
}
159+
160+
$owner = $subject->getOwner();
161+
if ($owner === null) {
162+
return;
163+
}
164+
165+
$payload = [
166+
'collection' => $subject,
167+
'uow' => $uow,
168+
'is_deletion' => $isDeletion,
169+
];
170+
171+
$eventType = $isDeletion
172+
? IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE
173+
: IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE;
174+
175+
$strategy->audit($owner, $payload, $eventType, $ctx);
176+
}
130177
}

app/Audit/AuditLogFormatterFactory.php

Lines changed: 14 additions & 0 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\EntityManyToManyCollectionUpdateAuditLogFormatter;
22+
use App\Audit\ConcreteFormatters\EntityManyToManyCollectionDeleteAuditLogFormatter;
2123
use App\Audit\ConcreteFormatters\EntityUpdateAuditLogFormatter;
2224
use App\Audit\Interfaces\IAuditStrategy;
2325
use Doctrine\ORM\PersistentCollection;
@@ -107,6 +109,18 @@ public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLo
107109

108110
$formatter = new EntityCollectionUpdateAuditLogFormatter($child_entity_formatter);
109111
break;
112+
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE:
113+
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
114+
if (is_null($formatter)) {
115+
$formatter = new EntityManyToManyCollectionUpdateAuditLogFormatter();
116+
}
117+
break;
118+
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE:
119+
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
120+
if (is_null($formatter)) {
121+
$formatter = new EntityManyToManyCollectionDeleteAuditLogFormatter();
122+
}
123+
break;
110124
case IAuditStrategy::EVENT_ENTITY_CREATION:
111125
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
112126
if(is_null($formatter)) {

app/Audit/AuditLogOtlpStrategy.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,19 @@ private function buildAuditLogData($entity, $subject, array $change_set, string
176176
$data['audit.collection_is_dirty'] = $changes['is_dirty'] ? 'true' : 'false';
177177
}
178178
break;
179+
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE:
180+
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE:
181+
if (isset($change_set['collection']) && $change_set['collection'] instanceof PersistentCollection) {
182+
$collection = $change_set['collection'];
183+
$data['audit.collection_type'] = $this->getCollectionType($collection);
184+
$data['audit.collection_count'] = count($collection);
185+
186+
$changes = $this->getCollectionChanges($collection, $change_set);
187+
$data['audit.collection_current_count'] = $changes['current_count'];
188+
$data['audit.collection_snapshot_count'] = $changes['snapshot_count'];
189+
$data['audit.collection_is_dirty'] = $changes['is_dirty'] ? 'true' : 'false';
190+
}
191+
break;
179192
}
180193

181194
return $data;
@@ -216,6 +229,8 @@ private function mapEventTypeToAction(string $event_type): string
216229
IAuditStrategy::EVENT_ENTITY_UPDATE => IAuditStrategy::ACTION_UPDATE,
217230
IAuditStrategy::EVENT_ENTITY_DELETION => IAuditStrategy::ACTION_DELETE,
218231
IAuditStrategy::EVENT_COLLECTION_UPDATE => IAuditStrategy::ACTION_COLLECTION_UPDATE,
232+
IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE => IAuditStrategy::ACTION_COLLECTION_UPDATE,
233+
IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE => IAuditStrategy::ACTION_DELETE,
219234
default => IAuditStrategy::ACTION_UNKNOWN
220235
};
221236
}
@@ -227,6 +242,8 @@ private function getLogMessage(string $event_type): string
227242
IAuditStrategy::EVENT_ENTITY_UPDATE => IAuditStrategy::LOG_MESSAGE_UPDATED,
228243
IAuditStrategy::EVENT_ENTITY_DELETION => IAuditStrategy::LOG_MESSAGE_DELETED,
229244
IAuditStrategy::EVENT_COLLECTION_UPDATE => IAuditStrategy::LOG_MESSAGE_COLLECTION_UPDATED,
245+
IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE => IAuditStrategy::LOG_MESSAGE_COLLECTION_UPDATED,
246+
IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE => IAuditStrategy::LOG_MESSAGE_DELETED,
230247
default => IAuditStrategy::LOG_MESSAGE_CHANGED
231248
};
232249
}

0 commit comments

Comments
 (0)