Skip to content

Commit 6b78204

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

12 files changed

+957
-17
lines changed

app/Audit/AbstractAuditLogFormatter.php

Lines changed: 104 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,59 @@ final public function setContext(AuditContext $ctx): void
3234
$this->ctx = $ctx;
3335
}
3436

37+
protected function processCollection(
38+
object $col,
39+
bool $isDeletion = false
40+
): ?array
41+
{
42+
if (!($col instanceof PersistentCollection)) {
43+
return null;
44+
}
45+
46+
$mapping = $col->getMapping();
47+
48+
$addedEntities = $col->getInsertDiff();
49+
$removedEntities = $col->getDeleteDiff();
50+
51+
$addedIds = $this->extractCollectionEntityIds($addedEntities);
52+
$removedIds = $this->extractCollectionEntityIds($removedEntities);
53+
54+
55+
return [
56+
'field' => $mapping->fieldName ?? 'unknown',
57+
'target_entity' => $mapping->targetEntity ?? 'unknown',
58+
'is_deletion' => $isDeletion,
59+
'added_ids' => $addedIds,
60+
'removed_ids' => $removedIds,
61+
];
62+
}
63+
64+
65+
/**
66+
* Extract IDs from entity objects in collection
67+
*/
68+
protected function extractCollectionEntityIds(array $entities): array
69+
{
70+
$ids = [];
71+
foreach ($entities as $entity) {
72+
if (method_exists($entity, 'getId')) {
73+
$id = $entity->getId();
74+
if ($id !== null) {
75+
$ids[] = $id;
76+
}
77+
}
78+
}
79+
80+
$uniqueIds = array_unique($ids);
81+
sort($uniqueIds);
82+
83+
return array_values($uniqueIds);
84+
}
85+
3586
protected function getUserInfo(): string
3687
{
3788
if (app()->runningInConsole()) {
38-
return 'Worker Job';
89+
return 'Worker Job';
3990
}
4091
if (!$this->ctx) {
4192
return 'Unknown (unknown)';
@@ -129,5 +180,57 @@ protected function formatFieldChange(string $prop_name, $old_value, $new_value):
129180
return sprintf("Property \"%s\" has changed from \"%s\" to \"%s\"", $prop_name, $old_display, $new_display);
130181
}
131182

183+
/**
184+
* Build detailed message for many-to-many collection changes
185+
*/
186+
protected function buildManyToManyDetailedMessage(PersistentCollection $collection, array $insertDiff, array $deleteDiff): array
187+
{
188+
$fieldName = 'unknown';
189+
$targetEntity = 'unknown';
190+
191+
try {
192+
$mapping = $collection->getMapping();
193+
$fieldName = $mapping->fieldName ?? 'unknown';
194+
$targetEntity = $mapping->targetEntity ?? 'unknown';
195+
if ($targetEntity) {
196+
$targetEntity = class_basename($targetEntity);
197+
}
198+
} catch (\Exception $e) {
199+
Log::debug("AbstractAuditLogFormatter::Could not extract collection metadata: " . $e->getMessage());
200+
}
201+
202+
$addedIds = $this->extractCollectionEntityIds($insertDiff);
203+
$removedIds = $this->extractCollectionEntityIds($deleteDiff);
204+
205+
return [
206+
'field' => $fieldName,
207+
'target_entity' => $targetEntity,
208+
'added_ids' => $addedIds,
209+
'removed_ids' => $removedIds,
210+
];
211+
}
212+
213+
/**
214+
* Format detailed message for many-to-many collection changes
215+
*/
216+
protected static function formatManyToManyDetailedMessage(array $details, int $addCount, int $removeCount, string $action): string
217+
{
218+
$field = $details['field'] ?? 'unknown';
219+
$target = $details['target_entity'] ?? 'unknown';
220+
$addedIds = $details['added_ids'] ?? [];
221+
$removedIds = $details['removed_ids'] ?? [];
222+
223+
$parts = [];
224+
if (!empty($addedIds)) {
225+
$parts[] = sprintf("Added %d %s(s): %s", $addCount, $target, implode(', ', $addedIds));
226+
}
227+
if (!empty($removedIds)) {
228+
$parts[] = sprintf("Removed %d %s(s): %s", $removeCount, $target, implode(', ', $removedIds));
229+
}
230+
231+
$detailStr = implode(' | ', $parts);
232+
return sprintf("Many-to-Many collection '%s' %s: %s", $field, $action, $detailStr);
233+
}
234+
132235
abstract public function format(mixed $subject, array $change_set): ?string;
133236
}

app/Audit/AuditEventListener.php

Lines changed: 47 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,43 @@ private function buildAuditContext(): AuditContext
127132
rawRoute: $rawRoute
128133
);
129134
}
135+
136+
/**
137+
* Audit collection changes
138+
* Only determines if it's ManyToMany and emits appropriate event
139+
*/
140+
private function auditCollection($subject, IAuditStrategy $strategy, AuditContext $ctx, $uow, bool $isDeletion = false): void
141+
{
142+
if (!$subject instanceof PersistentCollection) {
143+
return;
144+
}
145+
146+
$mapping = $subject->getMapping();
147+
if (!$mapping->isManyToMany()) {
148+
$strategy->audit($subject, [], IAuditStrategy::EVENT_COLLECTION_UPDATE, $ctx);
149+
return;
150+
}
151+
152+
$isOwningSide = $mapping->isOwningSide();
153+
if (!$isOwningSide) {
154+
Log::debug("AuditEventListerner::Skipping audit for non-owning side of many-to-many collection");
155+
return;
156+
}
157+
158+
$owner = $subject->getOwner();
159+
if ($owner === null) {
160+
return;
161+
}
162+
163+
$payload = [
164+
'collection' => $subject,
165+
'uow' => $uow,
166+
'is_deletion' => $isDeletion,
167+
];
168+
$eventType = $isDeletion
169+
? IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE
170+
: IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE;
171+
172+
$strategy->audit($owner, $payload, $eventType, $ctx);
173+
}
130174
}

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\EntityManyToManyCollectionUpdateAuditLogFormatter;
22+
use App\Audit\ConcreteFormatters\EntityManyToManyCollectionDeleteAuditLogFormatter;
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 EntityManyToManyCollectionUpdateAuditLogFormatter();
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 EntityManyToManyCollectionDeleteAuditLogFormatter();
119+
}
120+
break;
110121
case IAuditStrategy::EVENT_ENTITY_CREATION:
111122
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
112123
if(is_null($formatter)) {

app/Audit/AuditLogOtlpStrategy.php

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public function audit($subject, array $change_set, string $event_type, AuditCon
7171
$auditData['audit.description'] = $description;
7272
}
7373
Log::debug("AuditLogOtlpStrategy::audit sending entry to OTEL", ["user_id" => $ctx->userId, "user_email" => $ctx->userEmail, 'payload' => $auditData]);
74-
EmitAuditLogJob::dispatch($this->getLogMessage($event_type), $auditData);
74+
EmitAuditLogJob::dispatch($this->getLogMessage($event_type), $auditData)->onQueue("audit_logs");
7575
Log::debug("AuditLogOtlpStrategy::audit entry sent to OTEL", ["user_id" => $ctx->userId, "user_email" => $ctx->userEmail]);
7676

7777
} catch (\Exception $ex) {
@@ -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;
@@ -184,19 +197,16 @@ private function buildAuditLogData($entity, $subject, array $change_set, string
184197
private function getCollectionType(PersistentCollection $collection): string
185198
{
186199
try {
187-
if (!method_exists($collection, 'getMapping')) {
188-
return 'unknown';
189-
}
190-
200+
191201
$mapping = $collection->getMapping();
202+
$targetEntity = $mapping->targetEntity ?? null;
192203

193-
if (!isset($mapping['targetEntity']) || empty($mapping['targetEntity'])) {
204+
if (!$targetEntity) {
194205
return 'unknown';
195206
}
196-
197-
return class_basename($mapping['targetEntity']);
207+
return class_basename($targetEntity);
198208
} catch (\Exception $ex) {
199-
return 'unknown';
209+
return 'AuditLogOtlpStrategy:: unknown targetEntity';
200210
}
201211
}
202212

@@ -216,6 +226,8 @@ private function mapEventTypeToAction(string $event_type): string
216226
IAuditStrategy::EVENT_ENTITY_UPDATE => IAuditStrategy::ACTION_UPDATE,
217227
IAuditStrategy::EVENT_ENTITY_DELETION => IAuditStrategy::ACTION_DELETE,
218228
IAuditStrategy::EVENT_COLLECTION_UPDATE => IAuditStrategy::ACTION_COLLECTION_UPDATE,
229+
IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE => IAuditStrategy::ACTION_COLLECTION_MANYTOMANY_UPDATE,
230+
IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE => IAuditStrategy::ACTION_COLLECTION_MANYTOMANY_DELETE,
219231
default => IAuditStrategy::ACTION_UNKNOWN
220232
};
221233
}
@@ -227,6 +239,8 @@ private function getLogMessage(string $event_type): string
227239
IAuditStrategy::EVENT_ENTITY_UPDATE => IAuditStrategy::LOG_MESSAGE_UPDATED,
228240
IAuditStrategy::EVENT_ENTITY_DELETION => IAuditStrategy::LOG_MESSAGE_DELETED,
229241
IAuditStrategy::EVENT_COLLECTION_UPDATE => IAuditStrategy::LOG_MESSAGE_COLLECTION_UPDATED,
242+
IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE => IAuditStrategy::LOG_MESSAGE_COLLECTION_UPDATED,
243+
IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE => IAuditStrategy::LOG_MESSAGE_DELETED,
230244
default => IAuditStrategy::LOG_MESSAGE_CHANGED
231245
};
232246
}

0 commit comments

Comments
 (0)