Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom dependency injection implementation #155

Merged
merged 11 commits into from
Aug 19, 2024

Conversation

inxilpro
Copy link
Contributor

@inxilpro inxilpro commented Aug 9, 2024

Right now, we rely on the Laravel container to handle DI, which means that there is some Verbs-specific logic that is hard to implement. This PR introduces a new DependencyResolver class that handles resolving all dependencies in your event hooks (validate, apply, etc). Owning the dependency resolution lets us handle events attached to multiple states considerably easier.

Breaking Change

The major consequence of this change is now all hooks can be called just once, and Verbs will figure out which dependency you want based on the following criteria:

  1. If you typehint something that Verbs isn't managing (ie. not a State or Event), we will resolve thru the Container normally
  2. If you typehint something that only has one "candidate" match, we will inject it regardless of variable name (eg. if you have an event that fires on a UserState and you typehint apply(UserState $foo) we will inject the UserState associated with the event as the $foo parameter)
  3. If you typehint something that has two+ candidates, we will match on name and throw an exception if that is ambiguous (eg. if you have an event that fires on two UsersStates, say $actor_id and $target_id, then you must use UserState $actor and UserState $target to tell Verbs which you mean)

Previously, if you had an event that fired on multiple states, hooks might be called multiple times:

Before

class BallotCast extends Event
{
  #[StateId(UserState::class)]
  public int $actor_id;

  #[StateId(UserState::class)]
  public int $target_id;

  public function apply(UserState $user) {
    // This gets called twice, once with the actor and once with 
    // the target, so we need to check before applying
    if ($user->id === $this->actor_id) {
      $user->has_cast_vote = true;
    }
    if ($user->id === $this->target_id) {
      $user->votes_received++;
    }
  }
}

After

class BallotCast extends Event
{
  #[StateId(UserState::class)]
  public int $actor_id;

  #[StateId(UserState::class)]
  public int $target_id;

  public function apply(UserState $actor, UserState $target) {
    $actor->has_cast_vote = true;
    $target->votes_received++;
  }
}

I think this change in API is significantly better and worth the BC break, but it's worth noting.

@@ -52,7 +51,7 @@ public function validate(): static
}

if (method_exists($this->event, 'failedValidation')) {
$this->event->failedValidation($this->state);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is technically a breaking change but I don't think anyone uses this functionality yet so it should be fine.

Comment on lines +63 to +64
$resolver = DependencyResolver::for($this->event->authorize(...), event: $this->event);
$result = call_user_func_array([$this->event, 'authorize'], $resolver());
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now authorize, fired, and handle do not support prefixes (ie. authorizeFoo will not be called by Verbs—only authorize). There are reasons for this, but I wonder if we want to make it use the same prefix logic everywhere to be consistent.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My first thought was that it could make the events hard to reason about if all the functions can have prefixed instances. validate and apply inherently make sense to me since the logic within is so specific to the states being passed - this may be a symptom of knowing the Verbs lifecycle already though.

Consistency is nice too though 🤷

Comment on lines -121 to -143
$parameters = [
'e' => $event,
'event' => $event,
$event::class => $event,
(string) Str::of($event::class)->classBasename()->snake() => $event,
(string) Str::of($event::class)->classBasename()->studly() => $event,
'meta' => $metadata,
'metadata' => $metadata,
'metaData' => $metadata,
'meta_data' => $metadata,
Metadata::class => $metadata,
];

if ($state) {
$parameters = [
...$parameters,
's' => $state,
'state' => $state,
$state::class => $state,
(string) Str::of($state::class)->classBasename()->snake() => $state,
(string) Str::of($state::class)->classBasename()->studly() => $state,
];
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's worth noting that Verbs will no longer inject parameters based on name. You need to provide a type hint. We could consider adding that functionality back in, but I think using types is significantly more common and then we don't have to worry about the multitude of naming conventions…

});

it('can inject states of same type into event handle methods by alias', function () {
Verbs::fake();
DependencyInjectionTestMultiHandleEvent::commit();
});

// TODO: How do we handle nullable state_ids
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I probably need to add some new tests :D

@hans-lts
Copy link
Contributor

Great PR 👍

@inxilpro
Copy link
Contributor Author

Re: state reconstitution bug — one solution would be to just recursively fetch all states and events related to the state that we're rebuilding and then re-apply them all to get new snapshots. For example, if we're dealing with state_id=10, we would do something like:

select `event_id` 
from `verb_state_events` 
where `state_id` = 10;

-- take event IDs from above, and use in following:
select `state_id` 
from `verb_state_events` 
where `event_id` in(/* known event IDs */) 
and `state_id` not in (/* known state IDs, which is just 10 right now */);

-- if we get any new state IDs from the above, use those in the following:
select `event_id`
from `verb_state_events`
where `state_id` in(/* state IDs from previous query */)
and `event_id` not in(/* known event IDs */);

We would just repeat a version of this until we get no new data for one loop. The vast majority of the time, this will only result in one loop. In the future, we can probably put together a neat CTE that would improve performance significantly in MySQL, and maybe someone can PR a similar solution for Postgres and SQLite.

@DanielCoulbourne DanielCoulbourne merged commit 22421d7 into main Aug 19, 2024
42 checks passed
@DanielCoulbourne DanielCoulbourne deleted the custom-dependency-injection branch August 19, 2024 20:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants