Skip to content

Commit

Permalink
Merge branch 'main' into hook-class-hierarchy
Browse files Browse the repository at this point in the history
  • Loading branch information
inxilpro authored Jan 6, 2025
2 parents 0374d75 + 5c2ed3c commit 9af33c0
Show file tree
Hide file tree
Showing 10 changed files with 203 additions and 9 deletions.
2 changes: 2 additions & 0 deletions src/Lifecycle/Broker.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ public function fire(Event $event): ?Event
// NOTE: Any changes to how the dispatcher is called here
// should also be applied to the `replay` method

$this->dispatcher->boot($event);

Guards::for($event)->check();

$this->dispatcher->apply($event);
Expand Down
19 changes: 19 additions & 0 deletions src/Lifecycle/Dispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ public function skipPhases(Phase ...$phases): void
$this->skipped_phases = $phases;
}

public function boot(Event $event): void
{
if ($this->shouldDispatchPhase(Phase::Boot)) {
$this->getBootHooks($event)->each(fn (Hook $hook) => $hook->boot($this->container, $event));
}
}

public function validate(Event $event): bool
{
if (! $this->shouldDispatchPhase(Phase::Validate)) {
Expand Down Expand Up @@ -82,6 +89,18 @@ public function replay(Event $event): void
}
}

/** @return Collection<int, Hook> */
protected function getBootHooks(Event $event): Collection
{
$hooks = $this->hooksFor($event, Phase::Boot);

if (method_exists($event, 'boot')) {
$hooks->prepend(Hook::fromClassMethod($event, 'boot')->forcePhases(Phase::Boot));
}

return $hooks;
}

/** @return Collection<int, Hook> */
protected function getFiredHooks(Event $event): Collection
{
Expand Down
1 change: 1 addition & 0 deletions src/Lifecycle/EventStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ protected function formatRelationshipsForWrite(array $event_objects): array
'created_at' => now(),
'updated_at' => now(),
]))
->values()
->all();
}
}
9 changes: 9 additions & 0 deletions src/Lifecycle/Hook.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ public function runsInPhase(Phase $phase): bool
return isset($this->phases[$phase]) && $this->phases[$phase] === true;
}

public function boot(Container $container, Event $event): bool
{
if ($this->runsInPhase(Phase::Boot)) {
return $this->execute($container, $event) ?? true;
}

throw new RuntimeException('Hook::boot called on a non-boot hook.');
}

public function validate(Container $container, Event $event): bool
{
if ($this->runsInPhase(Phase::Validate)) {
Expand Down
1 change: 1 addition & 0 deletions src/Lifecycle/Phase.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

enum Phase: string
{
case Boot = 'boot';
case Authorize = 'authorize';
case Validate = 'validate';
case Apply = 'apply';
Expand Down
38 changes: 31 additions & 7 deletions src/Support/EventStateRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
use InvalidArgumentException;
use ReflectionAttribute;
use ReflectionClass;
use ReflectionIntersectionType;
use ReflectionNamedType;
use ReflectionProperty;
use ReflectionUnionType;
use Thunk\Verbs\Attributes\Autodiscovery\StateDiscoveryAttribute;
use Thunk\Verbs\Event;
use Thunk\Verbs\Lifecycle\StateManager;
Expand All @@ -20,7 +23,7 @@ class EventStateRegistry
protected array $discovered_properties = [];

public function __construct(
protected StateManager $manager
protected StateManager $manager,
) {}

public function getStates(Event $event): StateCollection
Expand Down Expand Up @@ -54,7 +57,7 @@ protected function discoverAndPushState(StateDiscoveryAttribute $attribute, Even
$states = Arr::wrap(
$attribute
->setDiscoveredState($discovered)
->discoverState($target, $this->manager)
->discoverState($target, $this->manager),
);

$discovered->push(...$states);
Expand Down Expand Up @@ -120,17 +123,38 @@ protected function findAllProperties(Event $target): Collection

return collect($reflect->getProperties(ReflectionProperty::IS_PUBLIC))
->filter(function (ReflectionProperty $property) use ($target) {
$propertyType = $property->getType();
$propertyTypeName = $propertyType?->getName();
$property_type = $property->getType();

if ($propertyType->allowsNull() && $property->getValue($target) === null) {
if (
$property_type instanceof ReflectionNamedType
&& $property_type->allowsNull()
&& $property->getValue($target) === null
) {
return false;
}

return $propertyTypeName
&& (is_subclass_of($propertyTypeName, State::class) || $propertyTypeName === State::class || $propertyTypeName === StateCollection::class);
$all_property_types = match ($property_type::class) {
ReflectionUnionType::class, ReflectionIntersectionType::class => $property_type->getTypes(),
default => [$property_type],
};

foreach ($all_property_types as $type) {
$name = $type?->getName();
if ($name && $this->isStateClass($name)) {
return true;
}
}

return false;
})
->map(fn (ReflectionProperty $property) => $property->getValue($target))
->flatten();
}

protected function isStateClass(string $name): bool
{
return is_subclass_of($name, State::class)
|| $name === State::class
|| $name === StateCollection::class;
}
}
4 changes: 2 additions & 2 deletions src/Support/Normalization/StateNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class StateNormalizer implements DenormalizerInterface, NormalizerInterface
{
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return is_a($type, State::class, true) && (is_numeric($data) || is_a($data, State::class, true));
return is_a($type, State::class, true) && (is_numeric($data) || is_string($data) || is_a($data, State::class, true));
}

/** @param class-string<State> $type */
Expand All @@ -23,7 +23,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
return $data;
}

return app(StateManager::class)->load((int) $data, $type);
return app(StateManager::class)->load($data, $type);
}

public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
Expand Down
32 changes: 32 additions & 0 deletions tests/Feature/BootHookTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

use Thunk\Verbs\Attributes\Hooks\On;
use Thunk\Verbs\Event;
use Thunk\Verbs\Lifecycle\Dispatcher;
use Thunk\Verbs\Lifecycle\Phase;

it('can modify props on events in the Boot phase', function () {
app(Dispatcher::class)->register(new BootHookTestListener);

$e = BootHookTestEvent::fire(
album: 'Tha Carter 2'
);

expect($e)->name->toBe('Lil Wayne');
});

class BootHookTestEvent extends Event
{
public string $name;

public string $album;
}

class BootHookTestListener
{
#[On(Phase::Boot)]
public function setNameToLilWayne(BootHookTestEvent $event)
{
$event->name = 'Lil Wayne';
}
}
74 changes: 74 additions & 0 deletions tests/Unit/SupportUuidsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

use Illuminate\Support\Facades\Facade;
use Illuminate\Support\Str;
use Thunk\Verbs\Event;
use Thunk\Verbs\Lifecycle\StateManager;
use Thunk\Verbs\State;
use Thunk\Verbs\Support\IdManager;

use function Pest\Laravel\artisan;

beforeEach(function () {
// This is necessary because our Orchestra setup migrates the database
// before our tests run, so we need to re-run our migrations
config()->set('verbs.id_type', 'uuid');
app()->instance(IdManager::class, new IdManager('uuid'));
Facade::clearResolvedInstance(IdManager::class);
artisan('migrate:fresh');
});

afterAll(function () {
// This just resets the migrations back to how the were before this test suite
config()->set('verbs.id_type', 'snowflake');
app()->instance(IdManager::class, new IdManager('snowflake'));
Facade::clearResolvedInstance(IdManager::class);
artisan('migrate:fresh');
});

it('supports using uuids as state ids', function () {
$uuid = (string) Str::orderedUuid();

$state = UuidState::load($uuid);

UuidEvent::commit(
state: $state,
);

expect($state)
->id->toBe($uuid)
->event_was_applied->toBeTrue();
});

it('loads states correctly using uuids when the snapshots table has been removed', function () {
$uuid = (string) Str::orderedUuid();

$state = UuidState::load($uuid);

UuidEvent::commit(
state: $state,
);

app(StateManager::class)->reset(include_storage: true);

$state = UuidState::load($uuid);

expect($state)
->id->toBe($uuid)
->event_was_applied->toBeTrue();
});

class UuidState extends State
{
public bool $event_was_applied = false;
}

class UuidEvent extends Event
{
public UuidState $state;

public function apply()
{
$this->state->event_was_applied = true;
}
}
32 changes: 32 additions & 0 deletions tests/Unit/UseStatesDirectlyInEventsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,33 @@
$this->assertEquals($event2->id, $user_request2->last_event_id);
});

it('supports union typed properties in events', function() {
$user_request = UserRequestState::new();

UserRequestsWithUnionTypes::commit(
user_request: $user_request,
value: 'foo'
);

$this->assertEquals($user_request->unionTypedValue, 'foo');

UserRequestsWithUnionTypes::commit(
user_request: $user_request,
value: 12
);

$this->assertEquals($user_request->unionTypedValue, 12);
});

class UserRequestState extends State
{
public bool $acknowledged = false;

public bool $processed = false;

public bool $nullable = false;

public string|int $unionTypedValue = '';
}

class UserRequestAcknowledged extends Event
Expand Down Expand Up @@ -149,6 +169,18 @@ public function apply()
}
}

class UserRequestsWithUnionTypes extends Event
{
public function __construct(
public UserRequestState $user_request,
public string|int $value
) {}

public function apply() {
$this->user_request->unionTypedValue = $this->value;
}
}

class ParentState extends State
{
public ChildState $child;
Expand Down

0 comments on commit 9af33c0

Please sign in to comment.