diff --git a/composer.json b/composer.json index 6f1c14ca..45df19c3 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,9 @@ "autoload-dev": { "psr-4": { "Thunk\\Verbs\\Tests\\": "tests/", + "Thunk\\Verbs\\Examples\\Cart\\": "examples/Cart/src/", + "Thunk\\Verbs\\Examples\\Cart\\Tests\\": "examples/Cart/tests/", + "Thunk\\Verbs\\Examples\\Cart\\Database\\Factories\\": "examples/Cart/database/factories/", "Thunk\\Verbs\\Examples\\Counter\\": "examples/Counter/src/", "Thunk\\Verbs\\Examples\\Counter\\Tests\\": "examples/Counter/tests/", "Thunk\\Verbs\\Examples\\Counter\\Database\\Factories\\": "examples/Counter/database/factories/", diff --git a/docs/articles.md b/docs/articles.md new file mode 100644 index 00000000..314eb4f4 --- /dev/null +++ b/docs/articles.md @@ -0,0 +1,6 @@ +This is a collection of articles and blog posts that have been published about Verbs. If you have written something +about Verbs, feel free to [submit a PR](https://github.com/hirethunk/verbs/blob/main/docs/articles.md) to add it +to the list. + +- [Models in Verbs](https://cmorrell.com/models-in-verbs) (Chris Morrell - 2024) +- [Verbs Validation Errors](https://cmorrell.com/verbs-errors) (Chris Morrell - 2024) diff --git a/docs/navigation.json b/docs/navigation.json index bf09b309..85d0ed4a 100644 --- a/docs/navigation.json +++ b/docs/navigation.json @@ -41,10 +41,32 @@ } ] }, + { + "title": "Resources", + "slug": "resources", + "items": [ + { + "title": "Videos", + "slug": "videos", + "icon": "tv" + }, + { + "title": "Articles", + "slug": "articles", + "icon": "newspaper" + } + ] + }, { "title": "Examples", "slug": "examples", "items": [ + { + "title": "Shopping Cart", + "slug": "cart", + "icon": "shopping-cart", + "url": "/examples/cart" + }, { "title": "Board Game", "slug": "monopoly", @@ -105,26 +127,5 @@ "icon": "circle-stack" } ] - }, - { - "title": "Packages", - "slug": "verbs-packages", - "items": [ - { - "title": "Verbs Commands", - "slug": "verbs-commands", - "icon": "window" - }, - { - "title": "Verbs History", - "slug": "verbs-history", - "icon": "book-open" - }, - { - "title": "Verbs Livewire", - "slug": "verbs-livewire", - "icon": "bolt" - } - ] } ] diff --git a/docs/videos.md b/docs/videos.md new file mode 100644 index 00000000..6a960790 --- /dev/null +++ b/docs/videos.md @@ -0,0 +1,12 @@ +> [!note] +> These videos are relatively off-the-cuff and are using Verbs pre-releases. Some features +> may change, or best practices may emerge prior to version 1.0 that will make these videos +> obsolete. Once Verbs 1.0 is released, we plan on publishing a full, produced video series +> on Verbs. + +------- + +Chris has posted a couple of introductory videos to YouTube. You can watch the playlist +below, or [view it on YouTube](https://www.youtube.com/playlist?list=PLfp8k2-5wAK5Knn7HT43k1MJca08q_1nP). + +https://youtube.com/playlist?list=PLfp8k2-5wAK5Knn7HT43k1MJca08q_1nP&si=8wbEtU4Sz6Qp3A5_ diff --git a/examples/Cart/README.md b/examples/Cart/README.md new file mode 100644 index 00000000..75155b28 --- /dev/null +++ b/examples/Cart/README.md @@ -0,0 +1,4 @@ +# Shopping Cart Example + +This example is the code written in Chris's [Using state in Verbs](https://www.youtube.com/watch?v=d6_ggotpb_8&t=594s) +YouTube video. Tests and a few comments have been added. diff --git a/examples/Cart/navigation.json b/examples/Cart/navigation.json new file mode 100644 index 00000000..e3b61eeb --- /dev/null +++ b/examples/Cart/navigation.json @@ -0,0 +1,55 @@ +[ + { + "title": "Intro", + "slug": "intro", + "items": [ + { + "title": "About", + "slug": "readme", + "path": "README.md" + } + ] + }, + { + "title": "Events", + "slug": "events", + "items": [ + { + "title": "ItemRestocked", + "slug": "restock", + "path": "src/Events/ItemRestocked.php" + }, + { + "title": "ItemAddedToCart", + "slug": "add-to-cart", + "path": "src/Events/ItemAddedToCart.php" + }, + { + "title": "ItemRemovedFromCart", + "slug": "remove-from-cart", + "path": "src/Events/ItemRemovedFromCart.php" + }, + { + "title": "CheckedOut", + "slug": "checkout", + "path": "src/Events/CheckedOut.php" + } + ] + }, + { + "title": "State", + "slug": "state", + "items": [ + { + "title": "CartState", + "slug": "cart", + "path": "src/States/CartState.php" + }, + { + "title": "ItemState", + "slug": "item", + "path": "src/States/ItemState.php" + } + ] + } +] diff --git a/examples/Cart/src/Console/Commands/ShopCommand.php b/examples/Cart/src/Console/Commands/ShopCommand.php new file mode 100644 index 00000000..c42984d8 --- /dev/null +++ b/examples/Cart/src/Console/Commands/ShopCommand.php @@ -0,0 +1,96 @@ +setup(); + + do { + try { + $action = $this->action(); + $this->getLaravel()->forgetScopedInstances(); // Emulate a new request + $action(); + } catch (Exception $exception) { + error("Error: {$exception->getMessage()}"); + } + } while (true); + } + + protected function action(): Closure + { + $selection = select( + label: 'What would you like to do?', + options: [ + 'Add item to cart', + 'Remove item from cart', + 'Check out', + 'Restock items', + ] + ); + + return match ($selection) { + 'Add item to cart' => function () { + [$item, $quantity] = $this->selectSticker(); + ItemAddedToCart::commit(cart: $this->cart_id, item: $item, quantity: $quantity); + }, + 'Remove item from cart' => function () { + [$item, $quantity] = $this->selectSticker(); + ItemRemovedFromCart::commit(cart: $this->cart_id, item: $item, quantity: $quantity); + }, + 'Check out' => function () { + CheckedOut::commit(cart: $this->cart_id); + }, + 'Restock items' => function () { + [$sticker, $quantity] = $this->selectSticker(4); + ItemRestocked::commit(item: $sticker, quantity: (int) $quantity); + }, + }; + } + + protected function selectSticker($default_quantity = 1): array + { + $sticker = select('Which sticker?', $this->stickers); + $quantity = (int) text('Quantity', default: $default_quantity, required: true, validate: ['quantity' => 'required|int|min:1']); + + return [$sticker, $quantity]; + } + + protected function setup() + { + // Each time we load our app we'll create a new shopping cart by assigning + // it a unique ID. + + $this->cart_id = snowflake_id(); + + // These are entirely arbitrary IDs. They're just hard-coded so that they're + // consistent across separate runs of the app. + + $this->stickers = [ + 1000 => 'PHPĂ—Philly', + 1001 => 'Ignore Prev. Instructions', + 1002 => 'Verbs', + 1003 => 'Over Engineered', + ]; + } +} diff --git a/examples/Cart/src/Events/CheckedOut.php b/examples/Cart/src/Events/CheckedOut.php new file mode 100644 index 00000000..41b8f0ec --- /dev/null +++ b/examples/Cart/src/Events/CheckedOut.php @@ -0,0 +1,34 @@ +assert(! $this->cart->checked_out, 'Already checked out'); + + foreach ($this->cart->items as $item_id => $quantity) { + $item = ItemState::load($item_id); + $held = $item->activeHolds()[$this->cart->id]['quantity'] ?? 0; + $this->assert($held + $item->available() >= $quantity, 'Some items in your cart are out of stock'); + } + } + + public function apply() + { + foreach ($this->cart->items as $item_id => $quantity) { + $item = ItemState::load($item_id); + $item->quantity -= $quantity; + unset($item->holds[$this->cart->id]); + } + + $this->cart->checked_out = true; + } +} diff --git a/examples/Cart/src/Events/ItemAddedToCart.php b/examples/Cart/src/Events/ItemAddedToCart.php new file mode 100644 index 00000000..2a96b9dc --- /dev/null +++ b/examples/Cart/src/Events/ItemAddedToCart.php @@ -0,0 +1,50 @@ +assert(! $this->cart->checked_out, 'Already checked out'); + + $this->assert( + $this->item->available() >= $this->quantity, + 'Out of stock', + ); + + $this->assert( + $this->cart->count($this->item->id) + $this->quantity <= self::$item_limit, + 'Reached limit' + ); + } + + public function apply() + { + // Add (additional?) quantity to cart + $this->cart->items[$this->item->id] = $this->cart->count($this->item->id) + $this->quantity; + + // Initialize hold to 0 if it doesn't already exist + $this->item->holds[$this->cart->id] ??= [ + 'quantity' => 0, + 'expires' => now()->unix() + self::$hold_seconds, + ]; + + // Add quantity to hold + $this->item->holds[$this->cart->id]['quantity'] += $this->quantity; + } +} diff --git a/examples/Cart/src/Events/ItemRemovedFromCart.php b/examples/Cart/src/Events/ItemRemovedFromCart.php new file mode 100644 index 00000000..0f46a17a --- /dev/null +++ b/examples/Cart/src/Events/ItemRemovedFromCart.php @@ -0,0 +1,35 @@ +assert(! $this->cart->checked_out, 'Already checked out'); + + $this->assert( + $this->cart->count($this->item->id) >= $this->quantity, + "There aren't {$this->quantity} items in the cart to remove." + ); + } + + public function apply() + { + $this->cart->items[$this->item->id] -= $this->quantity; + + if (isset($this->item->holds[$this->cart->id])) { + $this->item->holds[$this->cart->id]['quantity'] -= $this->quantity; + } + } +} diff --git a/examples/Cart/src/Events/ItemRestocked.php b/examples/Cart/src/Events/ItemRestocked.php new file mode 100644 index 00000000..84ac49bc --- /dev/null +++ b/examples/Cart/src/Events/ItemRestocked.php @@ -0,0 +1,18 @@ +item->quantity += $this->quantity; + } +} diff --git a/examples/Cart/src/States/CartState.php b/examples/Cart/src/States/CartState.php new file mode 100644 index 00000000..83f8cc2d --- /dev/null +++ b/examples/Cart/src/States/CartState.php @@ -0,0 +1,17 @@ +items[$item_id] ?? 0; + } +} diff --git a/examples/Cart/src/States/ItemState.php b/examples/Cart/src/States/ItemState.php new file mode 100644 index 00000000..def379b0 --- /dev/null +++ b/examples/Cart/src/States/ItemState.php @@ -0,0 +1,27 @@ +quantity - $this->activeHoldCount(); + } + + public function activeHolds(): array + { + return $this->holds = array_filter($this->holds, fn ($hold) => $hold['expires'] > now()->unix()); + } + + protected function activeHoldCount(): mixed + { + return collect($this->activeHolds())->sum('quantity'); + } +} diff --git a/examples/Cart/tests/CartTest.php b/examples/Cart/tests/CartTest.php new file mode 100644 index 00000000..c36f3fa3 --- /dev/null +++ b/examples/Cart/tests/CartTest.php @@ -0,0 +1,179 @@ +assertEquals(0, $item->quantity); + + ItemRestocked::fire(item: $item, quantity: 4); + + $this->assertEquals(4, $item->quantity); + + ItemRestocked::fire(item: $item, quantity: 2); + + $this->assertEquals(6, $item->quantity); +}); + +it('can have items added to it', function () { + $item = ItemState::load(snowflake_id()); + $cart = CartState::load(snowflake_id()); + + $this->assertEquals(0, $item->available()); + $this->assertEquals(0, $cart->count($item->id)); + + $this->assertThrows(fn () => ItemAddedToCart::fire(cart: $cart, item: $item, quantity: 4)); + + ItemRestocked::fire(item: $item, quantity: 4); + + $this->assertEquals(4, $item->available()); + $this->assertEquals(0, $cart->count($item->id)); + + ItemAddedToCart::fire(cart: $cart, item: $item, quantity: 2); + + $this->assertEquals(2, $item->available()); + $this->assertEquals(2, $cart->count($item->id)); +}); + +it('enforces item limits', function () { + ItemAddedToCart::$item_limit = 2; + + $item1 = ItemState::load(snowflake_id()); + $item2 = ItemState::load(snowflake_id()); + $cart = CartState::load(snowflake_id()); + + ItemRestocked::fire(item: $item1, quantity: 100); + ItemRestocked::fire(item: $item2, quantity: 100); + + ItemAddedToCart::fire(cart: $cart, item: $item1, quantity: 2); + ItemAddedToCart::fire(cart: $cart, item: $item2, quantity: 2); + + $this->assertThrows(fn () => ItemAddedToCart::fire(cart: $cart, item: $item1, quantity: 1)); + $this->assertThrows(fn () => ItemAddedToCart::fire(cart: $cart, item: $item2, quantity: 1)); +}); + +it('reserves items for a configured number of seconds', function () { + ItemAddedToCart::$hold_seconds = 10; + Date::setTestNow(); + + $item = ItemState::load(snowflake_id()); + $cart1 = CartState::load(snowflake_id()); + $cart2 = CartState::load(snowflake_id()); + + ItemRestocked::fire(item: $item, quantity: 2); + + ItemAddedToCart::fire(cart: $cart1, item: $item, quantity: 2); + + $this->assertThrows(fn () => ItemAddedToCart::fire(cart: $cart2, item: $item, quantity: 2)); + + Date::setTestNow(now()->addSeconds(11)); + + ItemAddedToCart::fire(cart: $cart2, item: $item, quantity: 2); + + $this->assertEquals(2, $cart2->count($item->id)); + $this->assertNotTrue(isset($item->activeHolds()[$cart1->id])); + $this->assertEquals(2, $item->activeHolds()[$cart2->id]['quantity']); +}); + +it('can have items removed from it', function () { + $item = ItemState::load(snowflake_id()); + $cart = CartState::load(snowflake_id()); + + ItemRestocked::fire(item: $item, quantity: 2); + + $this->assertThrows(fn () => ItemRemovedFromCart::fire(cart: $cart, item: $item, quantity: 1)); + + ItemAddedToCart::fire(cart: $cart, item: $item, quantity: 2); + ItemRemovedFromCart::fire(cart: $cart, item: $item, quantity: 1); + + $this->assertEquals(1, $item->available()); + $this->assertEquals(1, $cart->count($item->id)); + + ItemRemovedFromCart::fire(cart: $cart, item: $item, quantity: 1); + + $this->assertThrows(fn () => ItemRemovedFromCart::fire(cart: $cart, item: $item, quantity: 1)); +}); + +it('allows checking out', function () { + $cart = CartState::load(snowflake_id()); + $item1 = ItemState::load(snowflake_id()); + $item2 = ItemState::load(snowflake_id()); + + ItemRestocked::fire(item: $item1, quantity: 2); + ItemRestocked::fire(item: $item2, quantity: 2); + + ItemAddedToCart::fire(cart: $cart, item: $item1, quantity: 2); + ItemAddedToCart::fire(cart: $cart, item: $item2, quantity: 2); + + CheckedOut::fire(cart: $cart); + + $this->assertEquals(0, $item1->available()); + $this->assertEquals(0, collect($item1->activeHolds())->sum('quantity')); + $this->assertEquals(0, $item2->available()); + $this->assertEquals(0, collect($item2->activeHolds())->sum('quantity')); +}); + +it('allows checking out after a hold expires if there is enough stock', function () { + ItemAddedToCart::$hold_seconds = 10; + Date::setTestNow(); + + $cart = CartState::load(snowflake_id()); + $item1 = ItemState::load(snowflake_id()); + $item2 = ItemState::load(snowflake_id()); + + ItemRestocked::fire(item: $item1, quantity: 2); + ItemRestocked::fire(item: $item2, quantity: 2); + + ItemAddedToCart::fire(cart: $cart, item: $item1, quantity: 2); + ItemAddedToCart::fire(cart: $cart, item: $item2, quantity: 2); + + Date::setTestNow(now()->addSeconds(11)); + + $this->assertNotTrue(isset($item1->activeHolds()[$cart->id])); + $this->assertNotTrue(isset($item2->activeHolds()[$cart->id])); + + CheckedOut::fire(cart: $cart); + + $this->assertEquals(0, $item1->available()); + $this->assertEquals(0, collect($item1->activeHolds())->sum('quantity')); + $this->assertEquals(0, $item2->available()); + $this->assertEquals(0, collect($item2->activeHolds())->sum('quantity')); +}); + +it('does not allow checking out if there is no stock', function () { + ItemAddedToCart::$hold_seconds = 10; + Date::setTestNow(); + + $cart1 = CartState::load(snowflake_id()); + $cart2 = CartState::load(snowflake_id()); + $item1 = ItemState::load(snowflake_id()); + $item2 = ItemState::load(snowflake_id()); + + ItemRestocked::fire(item: $item1, quantity: 2); + ItemRestocked::fire(item: $item2, quantity: 2); + + ItemAddedToCart::fire(cart: $cart1, item: $item1, quantity: 2); + ItemAddedToCart::fire(cart: $cart1, item: $item2, quantity: 2); + + Date::setTestNow(now()->addSeconds(11)); + + ItemAddedToCart::fire(cart: $cart2, item: $item2, quantity: 2); + + $this->assertThrows(fn () => CheckedOut::fire(cart: $cart1)); + + $this->assertEquals(2, $item1->available()); + $this->assertEquals(0, collect($item1->activeHolds())->sum('quantity')); + $this->assertEquals(0, $item2->available()); + $this->assertEquals(2, collect($item2->activeHolds())->sum('quantity')); +}); diff --git a/src/Attributes/Hooks/Apply.php b/src/Attributes/Hooks/Apply.php index 0b5e6b31..6e3b9fa7 100644 --- a/src/Attributes/Hooks/Apply.php +++ b/src/Attributes/Hooks/Apply.php @@ -20,6 +20,6 @@ public function __construct( public function applyToHook(Hook $hook): void { - $hook->states[] = $this->state_type; + $hook->targets[] = $this->state_type; } } diff --git a/src/Attributes/Hooks/Listen.php b/src/Attributes/Hooks/Listen.php index 270a751a..ce6fb30d 100644 --- a/src/Attributes/Hooks/Listen.php +++ b/src/Attributes/Hooks/Listen.php @@ -20,6 +20,6 @@ public function __construct( public function applyToHook(Hook $hook): void { - $hook->events[] = $this->event_type; + $hook->targets[] = $this->event_type; } } diff --git a/src/Lifecycle/Dispatcher.php b/src/Lifecycle/Dispatcher.php index 8a36cb71..e54736a8 100644 --- a/src/Lifecycle/Dispatcher.php +++ b/src/Lifecycle/Dispatcher.php @@ -24,11 +24,8 @@ public function __construct( public function register(object $target): void { foreach (Reflector::getHooks($target) as $hook) { - foreach ($hook->events as $event_type) { - $this->hooks[$event_type][] = $hook; - } - foreach ($hook->states as $state_type) { - $this->hooks[$state_type][] = $hook; + foreach ($hook->targets as $fqcn) { + $this->hooks[$fqcn][] = $hook; } } } @@ -185,7 +182,9 @@ protected function hooksWithPrefix(Event|State $target, Phase $phase, string $pr /** @return Collection */ protected function hooksFor(Event|State $target, ?Phase $phase = null): Collection { - return Collection::make($this->hooks[$target::class] ?? []) + return Collection::make($this->hooks) + ->only(Reflector::getClassInstanceOf($target)) + ->flatten() ->when($phase, fn (Collection $hooks) => $hooks->filter(fn (Hook $hook) => $hook->runsInPhase($phase))); } diff --git a/src/Lifecycle/Hook.php b/src/Lifecycle/Hook.php index 9d757588..5ebbdb85 100644 --- a/src/Lifecycle/Hook.php +++ b/src/Lifecycle/Hook.php @@ -22,8 +22,7 @@ public static function fromClassMethod(object $target, ReflectionMethod|string $ $hook = new static( callback: Closure::fromCallable([$target, $method->getName()]), - events: Reflector::getEventParameters($method), - states: Reflector::getStateParameters($method), + targets: Reflector::getParameterTypes($method), name: $method->getName(), ); @@ -34,8 +33,7 @@ public static function fromClosure(Closure $callback): static { $hook = new static( callback: $callback, - events: Reflector::getEventParameters($callback), - states: Reflector::getStateParameters($callback), + targets: Reflector::getParameterTypes($callback), ); return Reflector::applyHookAttributes($callback, $hook); @@ -43,8 +41,7 @@ public static function fromClosure(Closure $callback): static public function __construct( public Closure $callback, - public array $events = [], - public array $states = [], + public array $targets = [], public SplObjectStorage $phases = new SplObjectStorage, public ?string $name = null, ) {} diff --git a/src/Support/Reflector.php b/src/Support/Reflector.php index 8f65e097..7560062e 100644 --- a/src/Support/Reflector.php +++ b/src/Support/Reflector.php @@ -42,6 +42,23 @@ public static function getStateParameters(ReflectionFunctionAbstract|Closure $me return static::getParametersOfType(State::class, $method)->values()->all(); } + public static function getParameterTypes(ReflectionFunctionAbstract|Closure $method): array + { + $method = static::reflectFunction($method); + + if (empty($parameters = $method->getParameters())) { + return []; + } + + return Collection::make($parameters) + ->map(fn (ReflectionParameter $parameter) => static::getParameterClassNames($parameter)) + ->flatten() + ->filter() + ->unique() + ->values() + ->toArray(); + } + public static function applyHookAttributes(ReflectionFunctionAbstract|Closure $method, Hook $hook): Hook { $method = static::reflectFunction($method); @@ -80,6 +97,20 @@ public static function getParametersOfType(string $type, ReflectionFunctionAbstr ->map(fn (array $names) => Arr::first($names)); } + /** @return class-string[] */ + public static function getClassInstanceOf(string|object $class): array + { + $reflection = new ReflectionClass($class); + + $class_and_interface_names = array_unique($reflection->getInterfaceNames()); + + do { + $class_and_interface_names[] = $reflection->getName(); + } while ($reflection = $reflection->getParentClass()); + + return $class_and_interface_names; + } + protected static function reflectFunction(ReflectionFunctionAbstract|Closure $function): ReflectionFunctionAbstract { if ($function instanceof Closure) { diff --git a/tests/Feature/HooksClassHierarchyTest.php b/tests/Feature/HooksClassHierarchyTest.php new file mode 100644 index 00000000..e52f83eb --- /dev/null +++ b/tests/Feature/HooksClassHierarchyTest.php @@ -0,0 +1,60 @@ +register(new HooksClassHierarchyTestOneListener); + + expect(fn () => HooksClassHierarchyTestEvent1::fire())->toThrow(RuntimeException::class, 'one') + ->and(fn () => HooksClassHierarchyTestEvent2::fire())->not->toThrow(RuntimeException::class); +}); + +it('can match events by type-hinting an interface', function () { + app(Dispatcher::class)->register(new HooksClassHierarchyTestInterfaceListener); + + expect(fn () => HooksClassHierarchyTestEvent1::fire())->not->toThrow(RuntimeException::class) + ->and(fn () => HooksClassHierarchyTestEvent2::fire())->toThrow(RuntimeException::class, 'interface'); +}); + +it('can match all events by type-hinting the base class', function () { + app(Dispatcher::class)->register(new HooksClassHierarchyTestEveryListener); + + expect(fn () => HooksClassHierarchyTestEvent1::fire())->toThrow(RuntimeException::class, 'every') + ->and(fn () => HooksClassHierarchyTestEvent2::fire())->toThrow(RuntimeException::class, 'every'); +}); + +interface HooksClassHierarchyTestInterface {} + +class HooksClassHierarchyTestEvent1 extends Event {} + +class HooksClassHierarchyTestEvent2 extends Event implements HooksClassHierarchyTestInterface {} + +class HooksClassHierarchyTestOneListener +{ + #[On(Phase::Validate)] + public static function one(HooksClassHierarchyTestEvent1 $event) + { + throw new RuntimeException('one'); + } +} + +class HooksClassHierarchyTestInterfaceListener +{ + #[On(Phase::Validate)] + public static function interface(HooksClassHierarchyTestInterface $event) + { + throw new RuntimeException('interface'); + } +} + +class HooksClassHierarchyTestEveryListener +{ + #[On(Phase::Validate)] + public static function every(Event $event) + { + throw new RuntimeException('every'); + } +}