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
47 changes: 47 additions & 0 deletions src/Attributes/Projection/EagerLoad.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace Thunk\Verbs\Attributes\Projection;

use Attribute;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
use ReflectionNamedType;
use ReflectionProperty;
use Str;
use Thunk\Verbs\Event;
use Thunk\Verbs\Exceptions\AttributeNotAllowed;

#[Attribute(Attribute::TARGET_PROPERTY)]
class EagerLoad
{
public function __construct(
protected ?string $id_attribute = null,
) {}

public function handle(ReflectionProperty $property, Event $event): array
{
if ($property->isPublic() || $property->isStatic()) {
throw new AttributeNotAllowed('You can only eager-load protected instance properties.');
}

$type = $property->getType();
if (! $type instanceof ReflectionNamedType) {
throw new AttributeNotAllowed('You can only apply #[EagerLoad] to properties with a type hint.');
}

$class_name = $type->getName();
if (! is_a($class_name, Model::class, true)) {
throw new AttributeNotAllowed('You can only eager load eloquent models.');
}

$name = $property->getName();
$this->id_attribute ??= Str::snake($name).'_id';

if (! property_exists($event, $this->id_attribute)) {
$event_class = class_basename($event);
throw new InvalidArgumentException("Unable to find property '{$this->id_attribute}' on '{$event_class}'.");
}

return [$class_name, $event, $this->id_attribute, $name];
}
}
7 changes: 7 additions & 0 deletions src/Exceptions/AttributeNotAllowed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace Thunk\Verbs\Exceptions;

use LogicException;

class AttributeNotAllowed extends LogicException {}
33 changes: 20 additions & 13 deletions src/Lifecycle/Broker.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

namespace Thunk\Verbs\Lifecycle;

use Illuminate\Support\Enumerable;
use Thunk\Verbs\CommitsImmediately;
use Thunk\Verbs\Contracts\BrokersEvents;
use Thunk\Verbs\Contracts\StoresEvents;
use Thunk\Verbs\Event;
use Thunk\Verbs\Exceptions\EventNotValid;
use Thunk\Verbs\Lifecycle\Queue as EventQueue;
use Thunk\Verbs\Support\EagerLoader;

class Broker implements BrokersEvents
{
Expand Down Expand Up @@ -70,6 +72,8 @@ public function commit(): bool
$this->states->writeSnapshots();
$this->states->prune();

EagerLoader::load(...$events);

foreach ($events as $event) {
$this->metadata->setLastResults($event, $this->dispatcher->handle($event));
}
Expand All @@ -84,24 +88,27 @@ public function replay(?callable $beforeEach = null, ?callable $afterEach = null
try {
$this->states->reset(include_storage: true);

$iteration = 0;

app(StoresEvents::class)->read()
->each(function (Event $event) use ($beforeEach, $afterEach, &$iteration) {
$this->states->setReplaying(true);
->chunk(500)
->each(function (Enumerable $events) use ($beforeEach, $afterEach) {
EagerLoader::load(...$events);

if ($beforeEach) {
$beforeEach($event);
}
$events->each(function (Event $event) use ($beforeEach, $afterEach) {
$this->states->setReplaying(true);

$this->dispatcher->apply($event);
$this->dispatcher->replay($event);
if ($beforeEach) {
$beforeEach($event);
}

if ($afterEach) {
$afterEach($event);
}
$this->dispatcher->apply($event);
$this->dispatcher->replay($event);

if ($afterEach) {
$afterEach($event);
}
});

if ($iteration++ % 500 === 0 && $this->states->willPrune()) {
if ($this->states->willPrune()) {
$this->states->writeSnapshots();
$this->states->prune();
}
Expand Down
62 changes: 62 additions & 0 deletions src/Support/EagerLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

namespace Thunk\Verbs\Support;

use Arr;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use ReflectionClass;
use ReflectionProperty;
use Thunk\Verbs\Attributes\Projection\EagerLoad;
use Thunk\Verbs\Event;

class EagerLoader
{
public static function load(Event ...$events)
{
return (new static($events))();
}

public function __construct(
/** @var Event[] */
protected array $events,
) {}

public function __invoke()
{
$discovered = collect($this->events)
->map($this->discover(...))
->reduce(function (array $map, Collection $discovered) {
foreach ($discovered as [$class_name, $event, $id_property, $target_property]) {
$map['load'][$class_name][] = $event->{$id_property};
$map['fill'][$class_name][$event->{$id_property}][] = [$event, $target_property];
}

return $map;
}, ['load' => [], 'fill' => []]);

/** @var class-string<Model> $class_name */
foreach ($discovered['load'] as $class_name => $keys) {
$class_name::query()
->whereIn((new $class_name)->getKeyName(), $keys)
->eachById(function (Model $model) use ($discovered) {
foreach ($discovered['fill'][$model::class][$model->getKey()] as [$event, $target_property]) {
// This lets us set the property even if it's protected
(fn () => $this->{$target_property} = $model)(...)->call($event);
}
});
}
}

protected function discover(Event $event): Collection
{
return collect((new ReflectionClass($event))->getProperties())
->map(function (ReflectionProperty $property) use ($event) {
$attribute = Arr::first($property->getAttributes(EagerLoad::class));

return $attribute?->newInstance()->handle($property, $event);
})
->filter()
->values();
}
}
70 changes: 70 additions & 0 deletions tests/Feature/EagerLoadingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Thunk\Verbs\Attributes\Projection\EagerLoad;
use Thunk\Verbs\Event;
use Thunk\Verbs\Support\EagerLoader;

it('identifies the correct data when calling the attribute', function () {
$event = new TestEagerLoadingEvent(1337);

$attrs = collect((new ReflectionClass($event))->getProperties())
->map(function (ReflectionProperty $property) use ($event) {
$attribute = Arr::first($property->getAttributes(EagerLoad::class));

return $attribute?->newInstance()->handle($property, $event);
})
->filter()
->values();

expect($attrs->all())->toBe([[TestEagerLoadingModel::class, $event, 'test_model_id', 'test_model']]);
});

it('eager-loads models for events', function () {
TestEagerLoadingModel::migrate();

$model1 = TestEagerLoadingModel::create(['id' => 1337, 'name' => 'test 1']);
$model2 = TestEagerLoadingModel::create(['id' => 9876, 'name' => 'test 2']);

$event1 = new TestEagerLoadingEvent(1337);
$event2 = new TestEagerLoadingEvent(9876);

EagerLoader::load($event1, $event2);

expect($model1->is($event1->getTestModel()))->toBeTrue()
->and($model2->is($event2->getTestModel()))->toBeTrue();
});

class TestEagerLoadingEvent extends Event
{
public function __construct(
public int $test_model_id,
) {}

#[EagerLoad]
protected ?TestEagerLoadingModel $test_model = null;

public function getTestModel(): ?TestEagerLoadingModel
{
return $this->test_model;
}
}

class TestEagerLoadingModel extends Model
{
public $incrementing = false;

public $timestamps = false;

protected $table = 'test_eager_loading';

public static function migrate()
{
Schema::create('test_eager_loading', function (Blueprint $table) {
$table->snowflakeId();
$table->string('name')->nullable();
});
}
}
7 changes: 4 additions & 3 deletions tests/Unit/UseStatesDirectlyInEventsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
$this->assertEquals($event2->id, $user_request2->last_event_id);
});

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

UserRequestsWithUnionTypes::commit(
Expand Down Expand Up @@ -169,14 +169,15 @@ public function apply()
}
}

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

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