Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added adr/0002-flow1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added adr/0002-flow2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
584 changes: 584 additions & 0 deletions adr/0002-m2m-audit-logging-lightweight-join-table-query.md

Large diffs are not rendered by default.

105 changes: 104 additions & 1 deletion app/Audit/AbstractAuditLogFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace App\Audit;

use App\Audit\Utils\DateFormatter;
use Doctrine\ORM\PersistentCollection;
use Illuminate\Support\Facades\Log;

/**
* Copyright 2025 OpenStack Foundation
Expand Down Expand Up @@ -32,10 +34,59 @@ final public function setContext(AuditContext $ctx): void
$this->ctx = $ctx;
}

protected function processCollection(
object $col,
bool $isDeletion = false
): ?array
{
if (!($col instanceof PersistentCollection)) {
return null;
}

$mapping = $col->getMapping();

$addedEntities = $col->getInsertDiff();
$removedEntities = $col->getDeleteDiff();

$addedIds = $this->extractCollectionEntityIds($addedEntities);
$removedIds = $this->extractCollectionEntityIds($removedEntities);


return [
'field' => $mapping->fieldName ?? 'unknown',
'target_entity' => $mapping->targetEntity ?? 'unknown',
'is_deletion' => $isDeletion,
'added_ids' => $addedIds,
'removed_ids' => $removedIds,
];
}


/**
* Extract IDs from entity objects in collection
*/
protected function extractCollectionEntityIds(array $entities): array
{
$ids = [];
foreach ($entities as $entity) {
if (method_exists($entity, 'getId')) {
$id = $entity->getId();
if ($id !== null) {
$ids[] = $id;
}
}
}

$uniqueIds = array_unique($ids);
sort($uniqueIds);

return array_values($uniqueIds);
}

protected function getUserInfo(): string
{
if (app()->runningInConsole()) {
return 'Worker Job';
return 'Worker Job';
}
if (!$this->ctx) {
return 'Unknown (unknown)';
Expand Down Expand Up @@ -129,5 +180,57 @@ protected function formatFieldChange(string $prop_name, $old_value, $new_value):
return sprintf("Property \"%s\" has changed from \"%s\" to \"%s\"", $prop_name, $old_display, $new_display);
}

/**
* Build detailed message for many-to-many collection changes
*/
protected function buildManyToManyDetailedMessage(PersistentCollection $collection, array $insertDiff, array $deleteDiff): array
{
$fieldName = 'unknown';
$targetEntity = 'unknown';

try {
$mapping = $collection->getMapping();
$fieldName = $mapping->fieldName ?? 'unknown';
$targetEntity = $mapping->targetEntity ?? 'unknown';
if ($targetEntity) {
$targetEntity = class_basename($targetEntity);
}
} catch (\Exception $e) {
Log::debug("AbstractAuditLogFormatter::Could not extract collection metadata: " . $e->getMessage());
}

$addedIds = $this->extractCollectionEntityIds($insertDiff);
$removedIds = $this->extractCollectionEntityIds($deleteDiff);

return [
'field' => $fieldName,
'target_entity' => $targetEntity,
'added_ids' => $addedIds,
'removed_ids' => $removedIds,
];
}

/**
* Format detailed message for many-to-many collection changes
*/
protected static function formatManyToManyDetailedMessage(array $details, int $addCount, int $removeCount, string $action): string
{
$field = $details['field'] ?? 'unknown';
$target = $details['target_entity'] ?? 'unknown';
$addedIds = $details['added_ids'] ?? [];
$removedIds = $details['removed_ids'] ?? [];

$parts = [];
if (!empty($addedIds)) {
$parts[] = sprintf("Added %d %s(s): %s", $addCount, $target, implode(', ', $addedIds));
}
if (!empty($removedIds)) {
$parts[] = sprintf("Removed %d %s(s): %s", $removeCount, $target, implode(', ', $removedIds));
}

$detailStr = implode(' | ', $parts);
return sprintf("Many-to-Many collection '%s' %s: %s", $field, $action, $detailStr);
}

abstract public function format(mixed $subject, array $change_set): ?string;
}
50 changes: 47 additions & 3 deletions app/Audit/AuditEventListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

use App\Audit\Interfaces\IAuditStrategy;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\PersistentCollection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
Expand Down Expand Up @@ -53,10 +55,13 @@ public function onFlush(OnFlushEventArgs $eventArgs): void
foreach ($uow->getScheduledEntityDeletions() as $entity) {
$strategy->audit($entity, [], IAuditStrategy::EVENT_ENTITY_DELETION, $ctx);
}

foreach ($uow->getScheduledCollectionDeletions() as $col) {
$this->auditCollection($col, $strategy, $ctx, $uow, true);
}
foreach ($uow->getScheduledCollectionUpdates() as $col) {
$strategy->audit($col, [], IAuditStrategy::EVENT_COLLECTION_UPDATE, $ctx);
$this->auditCollection($col, $strategy, $ctx, $uow, false);
}

} catch (\Exception $e) {
Log::error('Audit event listener failed', [
'error' => $e->getMessage(),
Expand Down Expand Up @@ -98,7 +103,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;
Expand Down Expand Up @@ -127,4 +132,43 @@ private function buildAuditContext(): AuditContext
rawRoute: $rawRoute
);
}

/**
* Audit collection changes
* Only determines if it's ManyToMany and emits appropriate event
*/
private function auditCollection($subject, IAuditStrategy $strategy, AuditContext $ctx, $uow, bool $isDeletion = false): void
{
if (!$subject instanceof PersistentCollection) {
return;
}

$mapping = $subject->getMapping();
if (!$mapping->isManyToMany()) {
$strategy->audit($subject, [], IAuditStrategy::EVENT_COLLECTION_UPDATE, $ctx);
return;
}

$isOwningSide = $mapping->isOwningSide();
if (!$isOwningSide) {
Log::debug("AuditEventListerner::Skipping audit for non-owning side of many-to-many collection");
return;
}

$owner = $subject->getOwner();
if ($owner === null) {
return;
}

$payload = [
'collection' => $subject,
'uow' => $uow,
'is_deletion' => $isDeletion,
];
$eventType = $isDeletion
? IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE
: IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE;

$strategy->audit($owner, $payload, $eventType, $ctx);
}
}
17 changes: 14 additions & 3 deletions app/Audit/AuditLogFormatterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
use App\Audit\ConcreteFormatters\EntityCollectionUpdateAuditLogFormatter;
use App\Audit\ConcreteFormatters\EntityCreationAuditLogFormatter;
use App\Audit\ConcreteFormatters\EntityDeletionAuditLogFormatter;
use App\Audit\ConcreteFormatters\EntityManyToManyCollectionUpdateAuditLogFormatter;
use App\Audit\ConcreteFormatters\EntityManyToManyCollectionDeleteAuditLogFormatter;
use App\Audit\ConcreteFormatters\EntityUpdateAuditLogFormatter;
use App\Audit\Interfaces\IAuditStrategy;
use Doctrine\ORM\PersistentCollection;
Expand Down Expand Up @@ -57,9 +59,7 @@ public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLo
);
if (method_exists($subject, 'getTypeClass')) {
$type = $subject->getTypeClass();
// Your log shows this is ClassMetadata
if ($type instanceof ClassMetadata) {
// Doctrine supports either getName() or public $name
$targetEntity = method_exists($type, 'getName') ? $type->getName() : ($type->name ?? null);
} elseif (is_string($type)) {
$targetEntity = $type;
Expand All @@ -71,7 +71,6 @@ public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLo
$targetEntity = $mapping['targetEntity'] ?? null;
Log::debug("AuditLogFormatterFactory::make getMapping targetEntity {$targetEntity}");
} else {
// last-resort: read private association metadata (still no hydration)
$ref = new \ReflectionObject($subject);
foreach (['association', 'mapping', 'associationMapping'] as $propName) {
if ($ref->hasProperty($propName)) {
Expand Down Expand Up @@ -107,6 +106,18 @@ public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLo

$formatter = new EntityCollectionUpdateAuditLogFormatter($child_entity_formatter);
break;
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE:
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
if (is_null($formatter)) {
$formatter = new EntityManyToManyCollectionUpdateAuditLogFormatter();
}
break;
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE:
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
if (is_null($formatter)) {
$formatter = new EntityManyToManyCollectionDeleteAuditLogFormatter();
}
break;
case IAuditStrategy::EVENT_ENTITY_CREATION:
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
if(is_null($formatter)) {
Expand Down
30 changes: 22 additions & 8 deletions app/Audit/AuditLogOtlpStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,19 @@ private function buildAuditLogData($entity, $subject, array $change_set, string
$data['audit.collection_is_dirty'] = $changes['is_dirty'] ? 'true' : 'false';
}
break;
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE:
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE:
if (isset($change_set['collection']) && $change_set['collection'] instanceof PersistentCollection) {
$collection = $change_set['collection'];
$data['audit.collection_type'] = $this->getCollectionType($collection);
$data['audit.collection_count'] = count($collection);

$changes = $this->getCollectionChanges($collection, $change_set);
$data['audit.collection_current_count'] = $changes['current_count'];
$data['audit.collection_snapshot_count'] = $changes['snapshot_count'];
$data['audit.collection_is_dirty'] = $changes['is_dirty'] ? 'true' : 'false';
}
break;
}

return $data;
Expand All @@ -184,19 +197,16 @@ private function buildAuditLogData($entity, $subject, array $change_set, string
private function getCollectionType(PersistentCollection $collection): string
{
try {
if (!method_exists($collection, 'getMapping')) {
return 'unknown';
}


$mapping = $collection->getMapping();
$targetEntity = $mapping->targetEntity ?? null;

if (!isset($mapping['targetEntity']) || empty($mapping['targetEntity'])) {
if (!$targetEntity) {
return 'unknown';
}

return class_basename($mapping['targetEntity']);
return class_basename($targetEntity);
} catch (\Exception $ex) {
return 'unknown';
return 'AuditLogOtlpStrategy:: unknown targetEntity';
}
}

Expand All @@ -216,6 +226,8 @@ private function mapEventTypeToAction(string $event_type): string
IAuditStrategy::EVENT_ENTITY_UPDATE => IAuditStrategy::ACTION_UPDATE,
IAuditStrategy::EVENT_ENTITY_DELETION => IAuditStrategy::ACTION_DELETE,
IAuditStrategy::EVENT_COLLECTION_UPDATE => IAuditStrategy::ACTION_COLLECTION_UPDATE,
IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE => IAuditStrategy::ACTION_COLLECTION_MANYTOMANY_UPDATE,
IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE => IAuditStrategy::ACTION_COLLECTION_MANYTOMANY_DELETE,
default => IAuditStrategy::ACTION_UNKNOWN
};
}
Expand All @@ -227,6 +239,8 @@ private function getLogMessage(string $event_type): string
IAuditStrategy::EVENT_ENTITY_UPDATE => IAuditStrategy::LOG_MESSAGE_UPDATED,
IAuditStrategy::EVENT_ENTITY_DELETION => IAuditStrategy::LOG_MESSAGE_DELETED,
IAuditStrategy::EVENT_COLLECTION_UPDATE => IAuditStrategy::LOG_MESSAGE_COLLECTION_UPDATED,
IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE => IAuditStrategy::LOG_MESSAGE_COLLECTION_UPDATED,
IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE => IAuditStrategy::LOG_MESSAGE_DELETED,
default => IAuditStrategy::LOG_MESSAGE_CHANGED
};
}
Expand Down
Loading