Skip to content
Merged
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
7 changes: 5 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"license": "BSD-3-Clause",
"require": {
"php": ">=8.5",
"doctrine/dbal": "^4.0",
"doctrine/orm": "^3.6",
"psr/clock": "^1.0",
"psr/log": "^3.0"
},
Expand All @@ -18,7 +20,8 @@
"friendsofphp/php-cs-fixer": "^3.95",
"rector/rector": "^2.4",
"deptrac/deptrac": "^4.6",
"infection/infection": "^0.33"
"infection/infection": "^0.33",
"symfony/cache": "^7.2"
},
"autoload": {
"psr-4": { "WeDevelop\\AuditLog\\": "src/" }
Expand All @@ -36,7 +39,7 @@
"scripts": {
"cs-check": "php-cs-fixer fix --dry-run --diff",
"cs-fix": "php-cs-fixer fix",
"phpstan": "phpstan analyse",
"phpstan": "phpstan analyse --memory-limit=512M",
"rector-check": "rector process --dry-run",
"deptrac": "deptrac analyse --config-file=deptrac.yaml",
"test": "phpunit",
Expand Down
38 changes: 26 additions & 12 deletions deptrac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,26 @@ deptrac:
layers:
- name: Event
collectors:
- type: directory
value: src/Event/.*
- { type: directory, value: src/Event/.* }
- name: Recording
collectors:
- type: directory
value: src/Recording/.*
- { type: directory, value: src/Recording/.* }
- name: Record
collectors:
- type: directory
value: src/Record/.*
- { type: directory, value: src/Record/.* }
- name: Reading
collectors:
- type: directory
value: src/Reading/.*
- name: Bridge
- { type: directory, value: src/Reading/.* }
- name: InfraDoctrine
collectors:
- type: directory
value: src/Bridge/.*
- { type: directory, value: src/Infrastructure/Doctrine/.* }
- name: InfraSymfony
collectors:
- { type: directory, value: src/Infrastructure/Symfony/.* }
- name: Bundle
collectors:
- { type: directory, value: src/DependencyInjection/.* }
- { type: classNameRegex, value: /AuditLogBundle$/ }
ruleset:
Event: ~
Record:
Expand All @@ -31,8 +33,20 @@ deptrac:
Reading:
- Event
- Record
Bridge:
InfraDoctrine:
- Event
- Recording
- Record
- Reading
InfraSymfony:
- Event
- Recording
- Record
- Reading
Bundle:
- Event
- Recording
- Record
- Reading
- InfraDoctrine
- InfraSymfony
18 changes: 18 additions & 0 deletions src/Event/FieldChange.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

namespace WeDevelop\AuditLog\Event;

use InvalidArgumentException;

use function is_scalar;
use function sprintf;

final readonly class FieldChange
{
private function __construct(
Expand All @@ -16,9 +21,22 @@ private function __construct(

public static function of(string $field, mixed $old, mixed $new): self
{
// The changeset is frozen as JSON, so values must round-trip faithfully:
// an object silently reshapes (or loses its class) and a resource/closure
// throws mid-flush, inside the audited transaction. Curate to a scalar.
self::assertJsonSafe($field, 'old', $old);
self::assertJsonSafe($field, 'new', $new);

return new self($field, $old, $new, false);
}

private static function assertJsonSafe(string $field, string $side, mixed $value): void
{
if (null !== $value && !is_scalar($value)) {
throw new InvalidArgumentException(sprintf('FieldChange "%s" %s value must be a scalar or null; got %s.', $field, $side, get_debug_type($value)));
}
}

public static function redacted(string $field): self
{
return new self($field, null, null, true);
Expand Down
95 changes: 95 additions & 0 deletions src/Infrastructure/Doctrine/AuditRecordEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

declare(strict_types=1);

namespace WeDevelop\AuditLog\Infrastructure\Doctrine;

use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use WeDevelop\AuditLog\Event\AuditChannel;
use WeDevelop\AuditLog\Event\Changeset;
use WeDevelop\AuditLog\Event\RenderPayload;
use WeDevelop\AuditLog\Infrastructure\Doctrine\Type\ChangesetType;
use WeDevelop\AuditLog\Infrastructure\Doctrine\Type\RenderPayloadType;
use WeDevelop\AuditLog\Record\AuditRecord;
use WeDevelop\AuditLog\Recording\NewAuditRecord;

/**
* The default stored record. Append-only: a private constructor, a single named
* factory, no setters. Doctrine hydrates via reflection (asymmetric visibility
* is transparent to it).
*/
#[ORM\Entity]
#[ORM\Table(name: 'audit_record')]
#[ORM\Index(fields: ['code'])]
#[ORM\Index(fields: ['occurredAt'])]
#[ORM\Index(fields: ['actorId'])]
#[ORM\Index(fields: ['subjectClass', 'subjectId'])]
class AuditRecordEntity implements AuditRecord
{
#[ORM\Id]
#[ORM\Column(type: Types::GUID)]
public private(set) string $id;

#[ORM\Column(type: Types::STRING)]
public private(set) string $code;

#[ORM\Column(type: Types::STRING, length: 16, enumType: AuditChannel::class)]
public private(set) AuditChannel $channel;

#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
public private(set) DateTimeImmutable $occurredAt;

#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
public private(set) ?string $actorId;

#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
public private(set) ?string $actorLabel;

#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
public private(set) ?string $subjectClass;

#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
public private(set) ?string $subjectId;

#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
public private(set) ?string $subjectLabel;

#[ORM\Column(type: Types::STRING, length: 45, nullable: true)]
public private(set) ?string $ipAddress;

#[ORM\Column(type: ChangesetType::NAME, nullable: true)]
public private(set) ?Changeset $changes;

/** @var array<string, mixed>|null */
#[ORM\Column(type: Types::JSON, nullable: true)]
public private(set) ?array $data;

#[ORM\Column(type: RenderPayloadType::NAME)]
public private(set) RenderPayload $render;

private function __construct()
{
}

public static function fromNew(NewAuditRecord $record): self
{
$entity = new self();
$entity->id = $record->id;
$entity->code = $record->code;
$entity->channel = $record->channel;
$entity->occurredAt = $record->occurredAt;
$entity->actorId = $record->actorId;
$entity->actorLabel = $record->actorLabel;
$entity->subjectClass = $record->subjectClass;
$entity->subjectId = $record->subjectId;
$entity->subjectLabel = $record->subjectLabel;
$entity->ipAddress = $record->ipAddress;
$entity->changes = $record->changes;
$entity->data = $record->data;
$entity->render = $record->render;

return $entity;
}
}
118 changes: 118 additions & 0 deletions src/Infrastructure/Doctrine/DoctrineRecordReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

declare(strict_types=1);

namespace WeDevelop\AuditLog\Infrastructure\Doctrine;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Override;
use WeDevelop\AuditLog\Reading\AuditActor;
use WeDevelop\AuditLog\Reading\AuditEntry;
use WeDevelop\AuditLog\Reading\AuditPage;
use WeDevelop\AuditLog\Reading\AuditQuery;
use WeDevelop\AuditLog\Reading\RecordReader;

final readonly class DoctrineRecordReader implements RecordReader
{
public function __construct(private EntityManagerInterface $entityManager)
{
}

#[Override]
public function page(AuditQuery $query): AuditPage
{
$rows = $this->filtered($query)
->select('r')
->orderBy('r.occurredAt', 'DESC')
->addOrderBy('r.id', 'DESC')
->setFirstResult(($query->page - 1) * $query->perPage)
->setMaxResults($query->perPage)
->getQuery()
->getResult();

/** @var list<AuditRecordEntity> $rows */
return new AuditPage(
array_map($this->toEntry(...), $rows),
$query->page,
$query->perPage,
$this->count($query),
);
}

/** @return list<AuditActor> */
#[Override]
public function actors(): array
{
/** @var list<array{id: string, label: string|null}> $rows */
$rows = $this->entityManager->createQueryBuilder()
->from(AuditRecordEntity::class, 'r')
->select('r.actorId AS id', 'MAX(r.actorLabel) AS label')
->where('r.actorId IS NOT NULL')
->groupBy('r.actorId')
->orderBy('label', 'ASC')
->getQuery()
->getResult();

return array_map(
static fn (array $row): AuditActor => new AuditActor($row['id'], $row['label'] ?? $row['id']),
$rows,
);
}

private function count(AuditQuery $query): int
{
return (int) $this->filtered($query)
->select('COUNT(r.id)')
->getQuery()
->getSingleScalarResult();
}

private function filtered(AuditQuery $query): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(AuditRecordEntity::class, 'r');

if (null !== $query->code) {
$qb->andWhere('r.code = :code')->setParameter('code', $query->code);
}
if (null !== $query->actorId) {
$qb->andWhere('r.actorId = :actorId')->setParameter('actorId', $query->actorId);
}
if (null !== $query->subjectClass) {
$qb->andWhere('r.subjectClass = :subjectClass')->setParameter('subjectClass', $query->subjectClass);
}
if (null !== $query->subjectId) {
$qb->andWhere('r.subjectId = :subjectId')->setParameter('subjectId', $query->subjectId);
}
if (null !== $query->channel) {
$qb->andWhere('r.channel = :channel')->setParameter('channel', $query->channel->value);
}
if (null !== $query->from) {
$qb->andWhere('r.occurredAt >= :from')->setParameter('from', $query->from);
}
if (null !== $query->to) {
$qb->andWhere('r.occurredAt <= :to')->setParameter('to', $query->to);
}

return $qb;
}

private function toEntry(AuditRecordEntity $record): AuditEntry
{
return new AuditEntry(
$record->id,
$record->code,
$record->channel,
$record->occurredAt,
$record->actorId,
$record->actorLabel,
$record->subjectClass,
$record->subjectId,
$record->subjectLabel,
$record->ipAddress,
$record->changes,
$record->data,
$record->render,
);
}
}
28 changes: 28 additions & 0 deletions src/Infrastructure/Doctrine/DoctrineRecordStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace WeDevelop\AuditLog\Infrastructure\Doctrine;

use Doctrine\ORM\EntityManagerInterface;
use Override;
use WeDevelop\AuditLog\Recording\NewAuditRecord;
use WeDevelop\AuditLog\Recording\RecordStore;

/**
* Persists a record via Doctrine. Append-only: persist without flush — the
* record commits with the surrounding unit of work, atomically with the audited
* change. (ORM 3 flush() is global, so the store must never call it.).
*/
final readonly class DoctrineRecordStore implements RecordStore
{
public function __construct(private EntityManagerInterface $entityManager)
{
}

#[Override]
public function add(NewAuditRecord $record): void
{
$this->entityManager->persist(AuditRecordEntity::fromNew($record));
}
}
Loading