-
Notifications
You must be signed in to change notification settings - Fork 2
Feature: add a new event for many-to-many relationships #498
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a4ecfbe
3fdfd17
d7a2af6
14e01c5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,8 @@ | |
|
|
||
| use App\Audit\Interfaces\IAuditStrategy; | ||
| use Doctrine\ORM\Event\OnFlushEventArgs; | ||
| use Doctrine\ORM\EntityManagerInterface; | ||
| use Doctrine\ORM\PersistentCollection; | ||
| use Illuminate\Support\Facades\App; | ||
| use Illuminate\Support\Facades\Log; | ||
| use Illuminate\Support\Facades\Route; | ||
|
|
@@ -25,16 +27,16 @@ | |
| class AuditEventListener | ||
| { | ||
| private const ROUTE_METHOD_SEPARATOR = '|'; | ||
|
|
||
| private $em; | ||
| public function onFlush(OnFlushEventArgs $eventArgs): void | ||
| { | ||
| if (app()->environment('testing')) { | ||
| return; | ||
| } | ||
| $em = $eventArgs->getObjectManager(); | ||
| $uow = $em->getUnitOfWork(); | ||
| $this->em = $eventArgs->getObjectManager(); | ||
| $uow = $this->em->getUnitOfWork(); | ||
| // Strategy selection based on environment configuration | ||
| $strategy = $this->getAuditStrategy($em); | ||
| $strategy = $this->getAuditStrategy($this->em); | ||
| if (!$strategy) { | ||
| return; // No audit strategy enabled | ||
| } | ||
|
|
@@ -52,11 +54,23 @@ public function onFlush(OnFlushEventArgs $eventArgs): void | |
|
|
||
| foreach ($uow->getScheduledEntityDeletions() as $entity) { | ||
| $strategy->audit($entity, [], IAuditStrategy::EVENT_ENTITY_DELETION, $ctx); | ||
| } | ||
| foreach ($uow->getScheduledCollectionDeletions() as $col) { | ||
| [$subject, $payload, $eventType] = $this->auditCollection($col, IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); | ||
|
|
||
| if (!is_null($subject)) { | ||
| $strategy->audit($subject, $payload, $eventType, $ctx); | ||
| } | ||
| } | ||
|
|
||
| foreach ($uow->getScheduledCollectionUpdates() as $col) { | ||
| $strategy->audit($col, [], IAuditStrategy::EVENT_COLLECTION_UPDATE, $ctx); | ||
| [$subject, $payload, $eventType] = $this->auditCollection($col, IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); | ||
|
|
||
| if (!is_null($subject)) { | ||
| $strategy->audit($subject, $payload, $eventType, $ctx); | ||
| } | ||
| } | ||
|
|
||
| } catch (\Exception $e) { | ||
| Log::error('Audit event listener failed', [ | ||
| 'error' => $e->getMessage(), | ||
|
|
@@ -98,7 +112,7 @@ private function buildAuditContext(): AuditContext | |
| $member = $memberRepo->findOneBy(["user_external_id" => $userExternalId]); | ||
| } | ||
|
|
||
| //$ui = app()->bound('ui.context') ? app('ui.context') : []; | ||
| $ui = []; | ||
|
|
||
| $req = request(); | ||
| $rawRoute = null; | ||
|
|
@@ -127,4 +141,101 @@ private function buildAuditContext(): AuditContext | |
| rawRoute: $rawRoute | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Audit collection changes | ||
| * Returns triple: [$subject, $payload, $eventType] | ||
| * Subject will be null if collection should not be audited | ||
| * | ||
| * @param object $subject The collection | ||
| * @param string $eventType The event type constant (EVENT_COLLECTION_MANYTOMANY_DELETE or EVENT_COLLECTION_MANYTOMANY_UPDATE) | ||
| * @return array [$subject, $payload, $eventType] | ||
| */ | ||
| private function auditCollection($subject, string $eventType): array | ||
| { | ||
| if (!$subject instanceof PersistentCollection) { | ||
| return [null, null, null]; | ||
| } | ||
|
|
||
| $mapping = $subject->getMapping(); | ||
|
|
||
| if (!$mapping->isManyToMany()) { | ||
| return [$subject, [], IAuditStrategy::EVENT_COLLECTION_UPDATE]; | ||
| } | ||
|
|
||
| if (!$mapping->isOwningSide()) { | ||
| Log::debug("AuditEventListener::Skipping audit for non-owning side of many-to-many collection"); | ||
| return [null, null, null]; | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| $owner = $subject->getOwner(); | ||
| if ($owner === null) { | ||
| return [null, null, null]; | ||
| } | ||
|
|
||
| $payload = ['collection' => $subject]; | ||
|
|
||
| if ($eventType === IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE | ||
| && ( | ||
| !$subject->isInitialized() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @andrestejerina97
testAuditCollectionDeleteInitializedWithoutDiffUsesJoinTableQuery covers Branch B (it calls takeSnapshot(), making the collection initialized). But Branch A , the ADR's primary scenario, has no test. This matters because Branch A's !isInitialized() short-circuit is what prevents getDeleteDiff() from being called on an uninitialized collection, which would trigger Doctrine hydration and the memory blowup the ADR was written to prevent. A future refactor that removes the isInitialized() guard (e.g. simplifying to just count($subject->getDeleteDiff()) === 0) would reintroduce the production issue with no test to catch it. Can you add a test similar to testAuditCollectionDeleteInitializedWithoutDiffUsesJoinTableQuery but without the $collection->takeSnapshot() call, so isInitialized() returns false? That would cover the uninitialized path end-to-end. |
||
| || ($subject->isInitialized() && count($subject->getDeleteDiff()) === 0) | ||
| )) { | ||
| if ($this->em instanceof EntityManagerInterface) { | ||
| $payload['deleted_ids'] = $this->fetchManyToManyIds($subject, $this->em); | ||
| } | ||
| } | ||
|
|
||
| return [$owner, $payload, $eventType]; | ||
| } | ||
|
|
||
|
|
||
| private function fetchManyToManyIds(PersistentCollection $collection, EntityManagerInterface $em): array | ||
| { | ||
| try { | ||
| $mapping = $collection->getMapping(); | ||
| $joinTable = $mapping->joinTable; | ||
| $tableName = is_array($joinTable) ? ($joinTable['name'] ?? null) : ($joinTable->name ?? null); | ||
| $joinColumns = is_array($joinTable) ? ($joinTable['joinColumns'] ?? []) : ($joinTable->joinColumns ?? []); | ||
| $inverseJoinColumns = is_array($joinTable) ? ($joinTable['inverseJoinColumns'] ?? []) : ($joinTable->inverseJoinColumns ?? []); | ||
|
|
||
| $joinColumn = $joinColumns[0] ?? null; | ||
| $inverseJoinColumn = $inverseJoinColumns[0] ?? null; | ||
| $sourceColumn = is_array($joinColumn) ? ($joinColumn['name'] ?? null) : ($joinColumn->name ?? null); | ||
| $targetColumn = is_array($inverseJoinColumn) ? ($inverseJoinColumn['name'] ?? null) : ($inverseJoinColumn->name ?? null); | ||
|
|
||
| if (!$sourceColumn || !$targetColumn || !$tableName) { | ||
| return []; | ||
| } | ||
|
|
||
| $owner = $collection->getOwner(); | ||
| if ($owner === null) { | ||
| return []; | ||
| } | ||
|
|
||
| $ownerId = method_exists($owner, 'getId') ? $owner->getId() : null; | ||
| if ($ownerId === null) { | ||
| $ownerMeta = $em->getClassMetadata(get_class($owner)); | ||
| $ownerIds = $ownerMeta->getIdentifierValues($owner); | ||
| $ownerId = empty($ownerIds) ? null : reset($ownerIds); | ||
| } | ||
|
|
||
| if ($ownerId === null) { | ||
| return []; | ||
| } | ||
|
|
||
| $ids = $em->getConnection()->fetchFirstColumn( | ||
| "SELECT {$targetColumn} FROM {$tableName} WHERE {$sourceColumn} = ?", | ||
| [$ownerId] | ||
| ); | ||
|
|
||
| return array_values(array_map('intval', $ids)); | ||
|
|
||
| } catch (\Exception $e) { | ||
| Log::error("AuditEventListener::fetchManyToManyIds error: " . $e->getMessage(), [ | ||
| 'exception' => get_class($e), | ||
| 'trace' => $e->getTraceAsString() | ||
| ]); | ||
| return []; | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.