-
Notifications
You must be signed in to change notification settings - Fork 0
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
Implement an Event mangement system for Silverstripe CMS #1
Changes from 20 commits
30d6d46
4911860
5de5068
3095867
dcf32e8
263401f
86c7bb9
abcaf6d
bdf4dfa
397b00d
0b8ed50
0269386
0fc305f
9e945f9
ed7ba72
54afefd
1db248b
e857cc7
8618512
e410533
aa2593f
de9b9e9
017bd8b
fedec8b
5b7ead7
da0f6d4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
name: CI | ||
|
||
on: | ||
push: | ||
pull_request: | ||
workflow_dispatch: | ||
|
||
jobs: | ||
ci: | ||
name: CI | ||
uses: silverstripe/gha-ci/.github/workflows/ci.yml@v1 | ||
with: | ||
dynamic_matrix: false | ||
extra_jobs: | | ||
- php: '8.1' | ||
db: mysql80 | ||
phpunit: true | ||
installer_version: ^4 | ||
- php: '8.2' | ||
db: mysql80 | ||
phpunit: true | ||
installer_version: ^5 | ||
- php: '8.3' | ||
db: mariadb | ||
phpunit: true | ||
installer_version: ^5 | ||
|
||
coding-standards: | ||
name: Coding Standards | ||
runs-on: ubuntu-latest | ||
|
||
steps: | ||
- uses: actions/checkout@v3 | ||
|
||
- name: Setup PHP | ||
uses: shivammathur/setup-php@v2 | ||
with: | ||
php-version: '8.1' | ||
coverage: none | ||
tools: composer:v2, php-cs-fixer | ||
|
||
- name: Install dependencies | ||
run: composer install --prefer-dist --no-progress | ||
|
||
- name: Check coding standards | ||
run: php-cs-fixer fix --dry-run --diff |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
/vendor/ | ||
.phpunit.result.cache | ||
.php-cs-cache | ||
.env | ||
.idea/ | ||
.vscode/ | ||
*.swp | ||
*.swo | ||
.DS_Store | ||
composer.lock | ||
/public/ | ||
.php-cs-fixer.cache |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
<?php | ||
|
||
$finder = PhpCsFixer\Finder::create() | ||
->in([ | ||
__DIR__ . '/src', | ||
__DIR__ . '/tests', | ||
]) | ||
->name('*.php') | ||
->ignoreDotFiles(true) | ||
->ignoreVCS(true); | ||
|
||
$config = new PhpCsFixer\Config(); | ||
|
||
return $config | ||
->setRules([ | ||
'@PSR12' => true, | ||
'array_syntax' => ['syntax' => 'short'], | ||
'ordered_imports' => ['sort_algorithm' => 'alpha'], | ||
'no_unused_imports' => true, | ||
'trailing_comma_in_multiline' => true, | ||
'phpdoc_align' => true, | ||
'phpdoc_order' => true, | ||
'phpdoc_separation' => true, | ||
'phpdoc_single_line_var_spacing' => true, | ||
'phpdoc_trim' => true, | ||
'phpdoc_var_without_name' => true, | ||
'return_type_declaration' => ['space_before' => 'none'], | ||
'single_quote' => true, | ||
'ternary_operator_spaces' => true, | ||
'unary_operator_spaces' => true, | ||
]) | ||
->setFinder($finder); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,303 @@ | ||
# Silverstripe CMS RevoltEvent Dispatcher Module (experimental) | ||
[![CI](https://github.com/archiprocode/silverstripe-revolt-event-dispatcher/actions/workflows/ci.yml/badge.svg)](https://github.com/archiprocode/silverstripe-revolt-event-dispatcher/actions/workflows/ci.yml) | ||
|
||
This module adds the ability to dispatch and listen for events in Silverstripe CMS. It's built around Revolt PHP | ||
and AMPHP. It aims to process events asynchronously. It also provides some abstraction to help managing event around | ||
common DataObject operations. | ||
|
||
## Installation | ||
|
||
```bash | ||
composer require archipro/silverstripe-revolt-event-dispatcher | ||
``` | ||
|
||
## Features | ||
- Automatic event dispatching for DataObject operations (create, update, delete) | ||
- Support for versioned operations (publish, unpublish, archive, restore) | ||
- Asynchronous event handling using Revolt Event Loop | ||
|
||
## Setting up the Event Loop | ||
|
||
Because we are using Revolt PHP, you need to run the event loop to process the events. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not completely true if we are using amphp. We can await futures, the EventLoop doesn't need to be explicitly run. This is just very specifically tailored to our use case. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess people could manually wait for all the futures. But it seems like a bit of a weird use case. The main value of this module is that will do things asynchronously. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wouldn't be surprised if we come up with a use case where we actually need to just wait for the event, instead of it being completely deferred. Unless your intention for this library is to be always deferred, with no way to one day decide "I need to use this event.. now". There's also being able chain reactions to events.. like: Event::queueEvent()->map(function ($result) {
// You can now do something else with the event.. and it's still deferred
}); There'a also Event::queueEvent()->catch(function ($exception) {
// Maybe I expect this event to fail at times and want to run something else in case
}); and then of course, finally... for use cases that require an event to be acted on immediately: $result = Event::queueEvent()->await(); for whatever reason. I guess the argument is to just use whatever internal service is being called directly, but, I'd be too lazy to do that and just want to deal with it then and there with the code i have. |
||
|
||
Somewhere in your code you need to start the event loop by running `\Revolt\EventLoop::run()`. This will process all the events up to that point. | ||
|
||
A simple approach is to put it at the end of your `public/index.php` file in a `try-finally` block. You can also add a `fastcgi_finish_request()` call to ensure all output is sent before processing the events. | ||
|
||
```php | ||
try { | ||
$kernel = new CoreKernel(BASE_PATH); | ||
$app = new HTTPApplication($kernel); | ||
$response = $app->handle($request); | ||
$response->output(); | ||
} finally { | ||
// This call will complete the request without closing the PHP worker. A nice side effect of this is that your | ||
// event listeners won't block your request from being sent to the client. So you can use them to run slow | ||
// operations like sending emails or doing API calls without delaying the response. | ||
fastcgi_finish_request(); | ||
|
||
// Many methods in Silverstripe CMS rely on having a current controller with a request. | ||
$controller = new Controller(); | ||
$controller->setRequest($request); | ||
$controller->pushCurrent(); | ||
|
||
// Now we can process the events in the event loop | ||
\Revolt\EventLoop::run(); | ||
} | ||
marwan38 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
``` | ||
|
||
### TODO | ||
|
||
- Need to find a an elegant way to run the event loop on `sake` commands. This won't hit `public/index.php`. | ||
|
||
## Basic Usage | ||
|
||
### Firing a Custom Event | ||
|
||
```php | ||
use SilverStripe\Core\Injector\Injector; | ||
use ArchiPro\Silverstripe\EventDispatcher\Service\EventService; | ||
|
||
// Create your event class | ||
class MyCustomEvent | ||
{ | ||
public function __construct( | ||
private readonly string $message | ||
) {} | ||
|
||
public function getMessage(): string | ||
{ | ||
return $this->message; | ||
} | ||
} | ||
|
||
// Dispatch the event | ||
$event = new MyCustomEvent('Hello World'); | ||
$service = Injector::inst()->get(EventService::class); | ||
$service->dispatch($event); | ||
marwan38 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
``` | ||
|
||
### Adding a Simple Event Listener | ||
|
||
```php | ||
use SilverStripe\Core\Injector\Injector; | ||
use ArchiPro\Silverstripe\EventDispatcher\Service\EventService; | ||
|
||
// Add a listener | ||
$service = Injector::inst()->get(EventService::class); | ||
$service->addListener(MyCustomEvent::class, function(MyCustomEvent $event) { | ||
error_log('MyCustomEventListener::handleEvent was called'); | ||
}); | ||
``` | ||
|
||
### Configuration-based Listeners | ||
|
||
You can register listeners via YAML configuration: | ||
|
||
```yaml | ||
ArchiPro\Silverstripe\EventDispatcher\Service\EventService: | ||
listeners: | ||
MyCustomEvent: | ||
- ['MyApp\EventListener', 'handleEvent'] | ||
``` | ||
|
||
## Registering many listeners at once with loaders | ||
|
||
You can use listeners loaders to register many listeners at once. | ||
|
||
```php | ||
<?php | ||
|
||
use ArchiPro\EventDispatcher\ListenerProvider; | ||
use ArchiPro\Silverstripe\EventDispatcher\Contract\ListenerLoaderInterface; | ||
use ArchiPro\Silverstripe\EventDispatcher\Event\DataObjectEvent; | ||
use ArchiPro\Silverstripe\EventDispatcher\Event\Operation; | ||
use ArchiPro\Silverstripe\EventDispatcher\Listener\DataObjectEventListener; | ||
use SilverStripe\Control\Email\Email; | ||
use SilverStripe\Security\Member; | ||
|
||
class MemberListenerLoader implements ListenerLoaderInterface | ||
{ | ||
public function loadListeners(ListenerProvider $provider): void | ||
{ | ||
DataObjectEventListener::create( | ||
Closure::fromCallable([self::class, 'onMemberCreated']), | ||
[Member::class], | ||
[Operation::CREATE] | ||
)->selfRegister($provider); | ||
} | ||
|
||
public static function onMemberCreated(DataObjectEvent $event): void | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does it have to be a static method? Harder to test these things There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No it doesn't. Just swap to instance method. |
||
{ | ||
$member = $event->getObject(); | ||
error_log('Member created: ' . $member->ID); | ||
marwan38 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Email::create() | ||
->setTo($member->Email) | ||
->setSubject('Welcome to our site') | ||
->setFrom('[email protected]') | ||
->setBody('Welcome to our site') | ||
->send(); | ||
} | ||
} | ||
``` | ||
|
||
Loaders can be registered in your YAML configuration file: | ||
```yaml | ||
ArchiPro\Silverstripe\EventDispatcher\Service\EventService: | ||
loaders: | ||
- MemberListenerLoader | ||
``` | ||
|
||
## DataObject Event Handling | ||
|
||
This module automatically dispatches events for DataObject operations. You can listen for these events using the | ||
`DataObjectEventListener` class. | ||
|
||
### Firing DataObject Events | ||
|
||
Applying the `EventDispatchExtension` to a DataObject will automatically fire events when changes are made to an | ||
instance of that DataObject. | ||
|
||
```yaml | ||
|
||
## This will fire events for SiteTree instances only | ||
SilverStripe\SiteTree\SiteTree: | ||
extensions: | ||
- ArchiPro\Silverstripe\EventDispatcher\Extension\EventDispatchExtension | ||
|
||
## This will fire events for all DataObjects | ||
SilverStripe\ORM\DataObject: | ||
extensions: | ||
- ArchiPro\Silverstripe\EventDispatcher\Extension\EventDispatchExtension | ||
``` | ||
|
||
### Listening for DataObject Events | ||
|
||
```php | ||
use ArchiPro\Silverstripe\EventDispatcher\Event\DataObjectEvent; | ||
use ArchiPro\Silverstripe\EventDispatcher\Event\Operation; | ||
use ArchiPro\Silverstripe\EventDispatcher\Listener\DataObjectEventListener; | ||
use SilverStripe\Core\Injector\Injector; | ||
use SilverStripe\Security\Member; | ||
|
||
// Create a listener for all Member operations | ||
DataObjectEventListener::create( | ||
function (DataObjectEvent $event) { | ||
echo "Operation {$event->getOperation()->value} performed on Member {$event->getObjectID()}"; | ||
}, | ||
[Member::class] | ||
)->selfRegister(); | ||
|
||
// Listen for specific operations on multiple classes | ||
DataObjectEventListener::create( | ||
function (DataObjectEvent $event) { | ||
// Handle create/update operations | ||
}, | ||
[Member::class, Group::class], | ||
[Operation::CREATE, Operation::UPDATE] | ||
)->selfRegister(); | ||
``` | ||
|
||
### Available Operations | ||
|
||
The following operations are automatically tracked: | ||
|
||
- `Operation::CREATE` - When a DataObject is first written | ||
- `Operation::UPDATE` - When an existing DataObject is modified | ||
- `Operation::DELETE` - When a DataObject is deleted | ||
- `Operation::PUBLISH` - When a versioned DataObject is published | ||
- `Operation::UNPUBLISH` - When a versioned DataObject is unpublished | ||
- `Operation::ARCHIVE` - When a versioned DataObject is archived | ||
- `Operation::RESTORE` - When a versioned DataObject is restored from archive | ||
|
||
### Accessing Event Data | ||
|
||
The `DataObjectEvent` class provides several methods to access information about the event: | ||
|
||
```php | ||
DataObjectEventListener::create( | ||
function (DataObjectEvent $event) { | ||
$object = $event->getObject(); // Get the affected DataObject | ||
$class = $event->getObjectClass(); // Get the class name | ||
$operation = $event->getOperation(); // Get the operation type | ||
$version = $event->getVersion(); // Get version number (if versioned) | ||
$member = $event->getMember(); // Get the Member who performed the action | ||
$time = $event->getTimestamp(); // Get when the event occurred | ||
}, | ||
[DataObject::class] | ||
)->selfRegister(); | ||
``` | ||
|
||
`DataObjectEvent` is configured to be serializable so it can easily be stored for later use. | ||
|
||
Note that `DataObjectEvent` doesn't store the actual DataObject instance that caused the event to be fired. | ||
`DataObjectEvent::getObject()` will refetch the latest version of the DataObject from the database ... which will | ||
return `null` if the DataObject has been deleted. | ||
|
||
`DataObjectEvent::getObject(true) will attempt to retrieve the exact version of the DataObject that fired the event, | ||
assuming it was versioned. | ||
|
||
## Testing Your Events | ||
|
||
### Writing Event Tests | ||
|
||
When testing your event listeners, you'll need to: | ||
1. Dispatch your events | ||
2. Run the event loop | ||
3. Assert the expected outcomes | ||
|
||
Here's an example test: | ||
|
||
```php | ||
use Revolt\EventLoop; | ||
use SilverStripe\Dev\SapphireTest; | ||
use SilverStripe\Core\Injector\Injector; | ||
use ArchiPro\Silverstripe\EventDispatcher\Service\EventService; | ||
|
||
class MyEventTest extends SapphireTest | ||
{ | ||
public function testMyCustomEvent(): void | ||
{ | ||
// Create your test event | ||
$event = new MyCustomEvent('test message'); | ||
|
||
// Get the event service | ||
$service = Injector::inst()->get(EventService::class); | ||
|
||
// Add your test listener ... or if you have already | ||
$wasCalled = false; | ||
$service->addListener( | ||
MyCustomEvent::class, | ||
[MyCustomEventListener::class, 'handleEvent'] | ||
marwan38 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
); | ||
|
||
// Dispatch the event | ||
$service->dispatch($event); | ||
|
||
// Run the event loop to process events | ||
EventLoop::run(); | ||
|
||
// Assert your listener was called | ||
$this->assertTrue( | ||
MyCustomEventListener::wasCalled(), | ||
'Assert some side effect of the event being handled' | ||
); | ||
} | ||
} | ||
``` | ||
|
||
### Disabling event dispatching | ||
|
||
You can disable event dispatching for test to avoid side affects from irrelevant events that might be fired while | ||
scaffolding fixtures. | ||
|
||
Call `EventService::singleton()->disableDispatch()` to disable event dispatching while setting up your test. | ||
|
||
When you are ready to start running your test, call `EventService::singleton()->enableDispatch()` to start listening for | ||
events again. | ||
|
||
### Important Testing Notes | ||
|
||
- Always remember to run `EventLoop::run()` after dispatching events | ||
- Events are processed asynchronously, so assertions should happen after running the event loop | ||
marwan38 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
- For DataObject events, make sure your test class has the `EventDispatchExtension` applied |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not adding in static checks using phpstan?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just added it back and fix a boat load of warning. Honestly, the only reason why it was on the other module was because Claude put it there without telling me 😅