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

Implement an Event mangement system for Silverstripe CMS #1

Merged
merged 26 commits into from
Nov 17, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
30d6d46
Initial commit
maxime-rainville Nov 6, 2024
4911860
Update workfow
maxime-rainville Nov 6, 2024
5de5068
Add ability to scaffold Dataobject event
maxime-rainville Nov 7, 2024
3095867
Update CI workflow
maxime-rainville Nov 7, 2024
dcf32e8
Add PHPUnit config
maxime-rainville Nov 7, 2024
263401f
Move test around
maxime-rainville Nov 7, 2024
86c7bb9
Test PHP 8.3
maxime-rainville Nov 7, 2024
abcaf6d
Fix PHP linting error
maxime-rainville Nov 7, 2024
bdf4dfa
Finishing implementing test for DataObjectEvent flow
maxime-rainville Nov 7, 2024
397b00d
Fix linting issue
maxime-rainville Nov 7, 2024
0b8ed50
Add DataObjectListener test
maxime-rainville Nov 7, 2024
0269386
Update readme
maxime-rainville Nov 7, 2024
0fc305f
Remove repositories composer config
maxime-rainville Nov 7, 2024
9e945f9
Add CI badge to readme
maxime-rainville Nov 7, 2024
ed7ba72
Try to instanciate Loaders and listeners with Injector
maxime-rainville Nov 10, 2024
54afefd
Add more code sample to readme
maxime-rainville Nov 10, 2024
1db248b
Be more explicit abotut he return type of DataObjectEvent
maxime-rainville Nov 11, 2024
e857cc7
Fix linting issue
maxime-rainville Nov 11, 2024
8618512
Add option to suppress Event dispatch for testing
maxime-rainville Nov 11, 2024
e410533
Add more detail about testing to README
maxime-rainville Nov 12, 2024
aa2593f
Enable PHPStan CI
Nov 13, 2024
de9b9e9
Fix all PHPStan warning
Nov 13, 2024
017bd8b
Fix linting issue
Nov 13, 2024
fedec8b
Implement peer review feedback
Nov 13, 2024
5b7ead7
Fix some linting issue
Nov 13, 2024
da0f6d4
Require version 0.0.0 of archipro/revolt-event-dispatcher
maxime-rainville Nov 17, 2024
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
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml

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?

Copy link
Member Author

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 😅

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
12 changes: 12 additions & 0 deletions .gitignore
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
32 changes: 32 additions & 0 deletions .php-cs-fixer.dist.php
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);
303 changes: 303 additions & 0 deletions README.md
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.

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

Choose a reason for hiding this comment

The 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.

Choose a reason for hiding this comment

The 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

Choose a reason for hiding this comment

The 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

Copy link
Member Author

Choose a reason for hiding this comment

The 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
Loading
Loading