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
50 changes: 50 additions & 0 deletions src/Altair/Happen/Psr14EventDispatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

/*
* This file is part of the univeros/framework
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Altair\Happen;

use Override;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\ListenerProviderInterface;
use Psr\EventDispatcher\StoppableEventInterface;

/**
* PSR-14 object-based event dispatcher.
*
* Pairs with any PSR-14 {@see ListenerProviderInterface} (the framework ships
* {@see Psr14ListenerProvider}). Listeners receive the event object and may
* mutate it; the same instance is returned. When the event is a
* {@see StoppableEventInterface}, propagation is checked before each listener
* so a stopped event halts the remaining listeners.
*
* This is the object-based counterpart to the name-keyed {@see EventDispatcher};
* the two are independent and a host binds whichever it needs.
*/
final readonly class Psr14EventDispatcher implements EventDispatcherInterface
{
public function __construct(private ListenerProviderInterface $provider) {}

#[Override]
public function dispatch(object $event): object
{
$stoppable = $event instanceof StoppableEventInterface;

foreach ($this->provider->getListenersForEvent($event) as $listener) {
if ($stoppable && $event->isPropagationStopped()) {
break;
}

$listener($event);
}

return $event;
}
}
87 changes: 87 additions & 0 deletions src/Altair/Happen/Psr14ListenerProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

/*
* This file is part of the univeros/framework
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Altair\Happen;

use Override;
use Psr\EventDispatcher\ListenerProviderInterface;

/**
* Type-keyed, priority-ordered PSR-14 listener provider.
*
* Listeners are registered against an event class or interface name. An event
* matches a registration when it is an instance of that type, so listeners bound
* to a parent class or interface also fire for subclasses. Matching listeners
* are returned highest-priority first; ties keep registration order.
*
* This is the object-based counterpart to the name-keyed {@see EventDispatcher};
* the two are independent and a host binds whichever it needs.
*/
final class Psr14ListenerProvider implements ListenerProviderInterface
{
public const int HIGH_PRIORITY = 100;

public const int NORMAL_PRIORITY = 0;

public const int LOW_PRIORITY = -100;

/**
* @var array<class-string, list<array{priority: int, sequence: int, listener: callable}>>
*/
private array $listeners = [];

private int $sequence = 0;

/**
* Register a listener for an event type (class or interface name).
*
* @param class-string $eventType
*/
public function listen(
string $eventType,
callable $listener,
int $priority = self::NORMAL_PRIORITY
): self {
$this->listeners[$eventType][] = [
'priority' => $priority,
'sequence' => $this->sequence++,
'listener' => $listener,
];

return $this;
}

/**
* @return iterable<int, callable>
*/
#[Override]
public function getListenersForEvent(object $event): iterable
{
$matched = [];
foreach ($this->listeners as $type => $entries) {
if ($event instanceof $type) {
foreach ($entries as $entry) {
$matched[] = $entry;
}
}
}

usort(
$matched,
static fn(array $a, array $b): int => $b['priority'] <=> $a['priority']
?: $a['sequence'] <=> $b['sequence'],
);

foreach ($matched as $entry) {
yield $entry['listener'];
}
}
}
9 changes: 9 additions & 0 deletions tests/Happen/Fixtures/BaseEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Altair\Tests\Happen\Fixtures;

class BaseEvent implements MarkerInterface
{
}
9 changes: 9 additions & 0 deletions tests/Happen/Fixtures/DerivedEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Altair\Tests\Happen\Fixtures;

final class DerivedEvent extends BaseEvent
{
}
9 changes: 9 additions & 0 deletions tests/Happen/Fixtures/MarkerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Altair\Tests\Happen\Fixtures;

interface MarkerInterface
{
}
24 changes: 24 additions & 0 deletions tests/Happen/Fixtures/StoppableEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Altair\Tests\Happen\Fixtures;

use Psr\EventDispatcher\StoppableEventInterface;

final class StoppableEvent implements StoppableEventInterface
{
public function __construct(private bool $stopped = false)
{
}

public function stop(): void
{
$this->stopped = true;
}

public function isPropagationStopped(): bool
{
return $this->stopped;
}
}
9 changes: 9 additions & 0 deletions tests/Happen/Fixtures/UnrelatedEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Altair\Tests\Happen\Fixtures;

final class UnrelatedEvent
{
}
148 changes: 148 additions & 0 deletions tests/Happen/Psr14EventDispatcherTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php

declare(strict_types=1);

namespace Altair\Tests\Happen;

use Altair\Happen\Psr14EventDispatcher;
use Altair\Happen\Psr14ListenerProvider;
use Altair\Tests\Happen\Fixtures\BaseEvent;
use Altair\Tests\Happen\Fixtures\DerivedEvent;
use Altair\Tests\Happen\Fixtures\StoppableEvent;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(Psr14EventDispatcher::class)]
final class Psr14EventDispatcherTest extends TestCase
{
public function testDispatchReturnsTheSameEventInstance(): void
{
$dispatcher = new Psr14EventDispatcher(new Psr14ListenerProvider());
$event = new BaseEvent();

$this->assertSame($event, $dispatcher->dispatch($event));
}

public function testDispatchInvokesMatchingListenerWithTheEvent(): void
{
$received = null;
$provider = new Psr14ListenerProvider();
$provider->listen(BaseEvent::class, static function (BaseEvent $e) use (&$received): void {
$received = $e;
});
$dispatcher = new Psr14EventDispatcher($provider);

$event = new BaseEvent();
$dispatcher->dispatch($event);

$this->assertSame($event, $received);
}

public function testDispatchInvokesNothingWhenNoListenersMatch(): void
{
$dispatcher = new Psr14EventDispatcher(new Psr14ListenerProvider());
$event = new BaseEvent();

$this->assertSame($event, $dispatcher->dispatch($event));
}

public function testDispatchInvokesListenersInPriorityOrder(): void
{
$calls = [];
$provider = new Psr14ListenerProvider();
$provider->listen(BaseEvent::class, static function () use (&$calls): void {
$calls[] = 'normal';
});
$provider->listen(BaseEvent::class, static function () use (&$calls): void {
$calls[] = 'high';
}, Psr14ListenerProvider::HIGH_PRIORITY);
$provider->listen(BaseEvent::class, static function () use (&$calls): void {
$calls[] = 'low';
}, Psr14ListenerProvider::LOW_PRIORITY);
$dispatcher = new Psr14EventDispatcher($provider);

$dispatcher->dispatch(new BaseEvent());

$this->assertSame(['high', 'normal', 'low'], $calls);
}

public function testStoppableEventHaltsRemainingListeners(): void
{
$calls = [];
$provider = new Psr14ListenerProvider();
$provider->listen(StoppableEvent::class, static function (StoppableEvent $e) use (&$calls): void {
$calls[] = 'first';
$e->stop();
});
$provider->listen(StoppableEvent::class, static function () use (&$calls): void {
$calls[] = 'second';
});
$dispatcher = new Psr14EventDispatcher($provider);

$dispatcher->dispatch(new StoppableEvent());

$this->assertSame(['first'], $calls);
}

public function testNonStoppableEventInvokesAllListeners(): void
{
$calls = [];
$provider = new Psr14ListenerProvider();
$provider->listen(BaseEvent::class, static function () use (&$calls): void {
$calls[] = 'first';
});
$provider->listen(BaseEvent::class, static function () use (&$calls): void {
$calls[] = 'second';
});
$dispatcher = new Psr14EventDispatcher($provider);

$dispatcher->dispatch(new BaseEvent());

$this->assertSame(['first', 'second'], $calls);
}

public function testAlreadyStoppedEventInvokesNoListeners(): void
{
$calls = [];
$provider = new Psr14ListenerProvider();
$provider->listen(StoppableEvent::class, static function () use (&$calls): void {
$calls[] = 'listener';
});
$dispatcher = new Psr14EventDispatcher($provider);

$dispatcher->dispatch(new StoppableEvent(stopped: true));

$this->assertSame([], $calls);
}

public function testInheritanceMatchingDispatchesParentListenersToSubclass(): void
{
$calls = [];
$provider = new Psr14ListenerProvider();
$provider->listen(BaseEvent::class, static function () use (&$calls): void {
$calls[] = 'base';
});
$dispatcher = new Psr14EventDispatcher($provider);

$dispatcher->dispatch(new DerivedEvent());

$this->assertSame(['base'], $calls);
}

public function testListenerRegisteredDuringDispatchDoesNotFireInTheSameCycle(): void
{
$calls = [];
$provider = new Psr14ListenerProvider();
$provider->listen(BaseEvent::class, static function () use ($provider, &$calls): void {
$calls[] = 'first';
$provider->listen(BaseEvent::class, static function () use (&$calls): void {
$calls[] = 'registered-during-dispatch';
});
});
$dispatcher = new Psr14EventDispatcher($provider);

$dispatcher->dispatch(new BaseEvent());

$this->assertSame(['first'], $calls);
}
}
Loading
Loading