diff --git a/src/Attributes/Projection/EagerLoad.php b/src/Attributes/Projection/EagerLoad.php new file mode 100644 index 00000000..4f51fc09 --- /dev/null +++ b/src/Attributes/Projection/EagerLoad.php @@ -0,0 +1,47 @@ +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]; + } +} diff --git a/src/Exceptions/AttributeNotAllowed.php b/src/Exceptions/AttributeNotAllowed.php new file mode 100644 index 00000000..11fb370b --- /dev/null +++ b/src/Exceptions/AttributeNotAllowed.php @@ -0,0 +1,7 @@ +states->writeSnapshots(); $this->states->prune(); + EagerLoader::load(...$events); + foreach ($events as $event) { $this->metadata->setLastResults($event, $this->dispatcher->handle($event)); } @@ -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(); } diff --git a/src/Support/EagerLoader.php b/src/Support/EagerLoader.php new file mode 100644 index 00000000..335e56d5 --- /dev/null +++ b/src/Support/EagerLoader.php @@ -0,0 +1,62 @@ +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 $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(); + } +} diff --git a/tests/Feature/EagerLoadingTest.php b/tests/Feature/EagerLoadingTest.php new file mode 100644 index 00000000..59dc78ca --- /dev/null +++ b/tests/Feature/EagerLoadingTest.php @@ -0,0 +1,70 @@ +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(); + }); + } +} diff --git a/tests/Unit/UseStatesDirectlyInEventsTest.php b/tests/Unit/UseStatesDirectlyInEventsTest.php index b1c6a5ed..dc127e43 100644 --- a/tests/Unit/UseStatesDirectlyInEventsTest.php +++ b/tests/Unit/UseStatesDirectlyInEventsTest.php @@ -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( @@ -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; } }