diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ed8bce1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + tests: + name: PHP ${{ matrix.php }} - Silverstripe ${{ matrix.silverstripe }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1'] + silverstripe: ['4.13', '5.0'] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, intl, pdo, mysql + coverage: xdebug + + - name: Install dependencies + run: | + composer require --no-update silverstripe/framework:^${{ matrix.silverstripe }} + composer install --no-interaction --no-progress + + - name: Run PHP CS Fixer + run: vendor/bin/php-cs-fixer fix --dry-run --diff + + - name: Run PHPStan + run: vendor/bin/phpstan analyse + + - name: Run PHPUnit + run: vendor/bin/phpunit --coverage-clover=coverage.xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ffc7fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/vendor/ +.phpunit.result.cache +.php-cs-cache +.env +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..a9aeee2 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,32 @@ +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); \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..da6e031 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Silverstripe Events Module + +This module provides PSR-14 Event Dispatcher integration for Silverstripe CMS with automatic event dispatching for DataObject CRUD operations and versioning actions. + +## Installation \ No newline at end of file diff --git a/_config/events.yml b/_config/events.yml new file mode 100644 index 0000000..8a64c6e --- /dev/null +++ b/_config/events.yml @@ -0,0 +1,10 @@ +--- +Name: events +After: + - '#coreservices' +--- +SilverStripe\Core\Injector\Injector: + ArchiPro\Silverstripe\EventDispatcher\Service\EventService: + constructor: + dispatcher: '%$Psr\EventDispatcher\EventDispatcherInterface' + listenerProvider: '%$Psr\EventDispatcher\ListenerProviderInterface' \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..446aad3 --- /dev/null +++ b/composer.json @@ -0,0 +1,40 @@ +{ + "name": "archipro/silverstripe-event-dispatcher", + "description": "PSR-14 Event Dispatcher integration for Silverstripe CMS", + "type": "silverstripe-vendormodule", + "license": "MIT", + "require": { + "php": "^8.1", + "silverstripe/framework": "^4.13 || ^5.0", + "silverstripe/versioned": "^1.13 || ^2.0", + "psr/event-dispatcher": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.0", + "friendsofphp/php-cs-fixer": "^3.0", + "phpstan/phpstan": "^1.10", + "symbiote/silverstripe-phpstan": "^1.0" + }, + "autoload": { + "psr-4": { + "ArchiPro\\Silverstripe\\EventDispatcher\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "ArchiPro\\Silverstripe\\EventDispatcher\\Tests\\": "tests/" + } + }, + "scripts": { + "lint": "php-cs-fixer fix --dry-run --diff", + "lint-fix": "php-cs-fixer fix", + "analyse": "phpstan analyse", + "test": "phpunit" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "expose": [] + } +} \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..652fa71 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,14 @@ +parameters: + level: 8 + paths: + - src + - tests + excludePaths: + - vendor/* + silverstripe: + checkUnusedViewVariables: false + ignoreErrors: + - '#Access to an undefined property .+::\$owner#' + - '#Call to an undefined method .+::hasExtension\(\)#' +includes: + - vendor/symbiote/silverstripe-phpstan/phpstan.neon \ No newline at end of file diff --git a/src/Event/AbstractDataObjectEvent.php b/src/Event/AbstractDataObjectEvent.php new file mode 100644 index 0000000..0e989e6 --- /dev/null +++ b/src/Event/AbstractDataObjectEvent.php @@ -0,0 +1,52 @@ +objectID; + } + + public function getObjectClass(): string + { + return $this->objectClass; + } + + public function getAction(): string + { + return $this->action; + } + + public function getChanges(): array + { + return $this->changes; + } + + public function jsonSerialize(): array + { + return [ + 'id' => $this->objectID, + 'class' => $this->objectClass, + 'action' => $this->action, + 'changes' => $this->changes, + 'timestamp' => time(), + ]; + } +} \ No newline at end of file diff --git a/src/Event/DataObjectDeleteEvent.php b/src/Event/DataObjectDeleteEvent.php new file mode 100644 index 0000000..258267f --- /dev/null +++ b/src/Event/DataObjectDeleteEvent.php @@ -0,0 +1,13 @@ +dataObject = $dataObject; + $this->action = $action; + } + + public function getDataObject(): DataObject + { + return $this->dataObject; + } + + public function getAction(): string + { + return $this->action; + } +} \ No newline at end of file diff --git a/src/Event/DataObjectVersionEvent.php b/src/Event/DataObjectVersionEvent.php new file mode 100644 index 0000000..211a2e1 --- /dev/null +++ b/src/Event/DataObjectVersionEvent.php @@ -0,0 +1,37 @@ +version; + } + + public function jsonSerialize(): array + { + return array_merge(parent::jsonSerialize(), [ + 'version' => $this->version, + ]); + } +} \ No newline at end of file diff --git a/src/Event/DataObjectWriteEvent.php b/src/Event/DataObjectWriteEvent.php new file mode 100644 index 0000000..1609a5f --- /dev/null +++ b/src/Event/DataObjectWriteEvent.php @@ -0,0 +1,16 @@ +originalData = $this->owner->exists() ? $this->owner->getQueriedDatabaseFields() : []; + } + + /** + * Fires an event after the object is written (created or updated) + */ + public function onAfterWrite(): void + { + // Don't fire write events during deletion process + if ($this->isSoftDelete) { + return; + } + + $event = new DataObjectWriteEvent( + $this->owner->ID, + get_class($this->owner), + $this->owner->isInDB() ? 'update' : 'create', + $this->getChanges() + ); + + $this->dispatchEvent($event); + } + + /** + * Fires before a DataObject is deleted from the database + * For versioned objects, this is called during both soft and hard deletes + */ + public function onBeforeDelete(): void + { + $isVersioned = $this->owner->hasExtension(Versioned::class); + $this->isSoftDelete = $isVersioned && !$this->owner->getIsDeleteFromStage(); + + $event = new DataObjectDeleteEvent( + $this->owner->ID, + get_class($this->owner), + $this->isSoftDelete ? 'soft_delete' : 'hard_delete', + [ + 'is_versioned' => $isVersioned, + 'deleted_from_stage' => $this->owner->getIsDeleteFromStage(), + 'version' => $isVersioned ? $this->owner->Version : null, + ] + ); + + $this->dispatchEvent($event); + } + + /** + * Fires after a DataObject is deleted from the database + */ + public function onAfterDelete(): void + { + // Reset the soft delete flag + $this->isSoftDelete = false; + } + + /** + * Fires when a versioned DataObject is published + */ + public function onAfterPublish(): void + { + if (!$this->owner->hasExtension(Versioned::class)) { + return; + } + + $event = new DataObjectVersionEvent( + $this->owner->ID, + get_class($this->owner), + 'publish', + $this->getChanges(), + $this->owner->Version + ); + + $this->dispatchEvent($event); + } + + /** + * Fires when a versioned DataObject is unpublished + */ + public function onAfterUnpublish(): void + { + if (!$this->owner->hasExtension(Versioned::class)) { + return; + } + + $event = new DataObjectVersionEvent( + $this->owner->ID, + get_class($this->owner), + 'unpublish', + [], + $this->owner->Version + ); + + $this->dispatchEvent($event); + } + + /** + * Fires when a versioned DataObject is archived + */ + public function onAfterArchive(): void + { + if (!$this->owner->hasExtension(Versioned::class)) { + return; + } + + $event = new DataObjectVersionEvent( + $this->owner->ID, + get_class($this->owner), + 'archive', + [], + $this->owner->Version + ); + + $this->dispatchEvent($event); + } + + /** + * Fires when a versioned DataObject is restored from archive + */ + public function onAfterRestore(): void + { + if (!$this->owner->hasExtension(Versioned::class)) { + return; + } + + $event = new DataObjectVersionEvent( + $this->owner->ID, + get_class($this->owner), + 'restore', + [], + $this->owner->Version + ); + + $this->dispatchEvent($event); + } + + /** + * Calculates the changes made to the object by comparing original and new state + * + * @return array Array of changes with 'old' and 'new' values for each changed field + */ + protected function getChanges(): array + { + if (empty($this->originalData)) { + return $this->owner->toMap(); + } + + $changes = []; + $newData = $this->owner->toMap(); + + foreach ($newData as $field => $value) { + if (!isset($this->originalData[$field]) || $this->originalData[$field] !== $value) { + $changes[$field] = [ + 'old' => $this->originalData[$field] ?? null, + 'new' => $value + ]; + } + } + + return $changes; + } + + /** + * Dispatches an event using the EventService + * + * @param object $event The event to dispatch + * @return object The processed event + */ + protected function dispatchEvent(object $event): object + { + return Injector::inst()->get(EventService::class)->dispatch($event); + } +} \ No newline at end of file diff --git a/src/Service/EventService.php b/src/Service/EventService.php new file mode 100644 index 0000000..9dae391 --- /dev/null +++ b/src/Service/EventService.php @@ -0,0 +1,60 @@ +dispatcher = $dispatcher; + $this->listenerProvider = $listenerProvider; + } + + /** + * Dispatches an event to all registered listeners + * + * @param object $event The event to dispatch + * @return object The event after it has been processed by all listeners + */ + public function dispatch(object $event): object + { + return $this->dispatcher->dispatch($event); + } + + /** + * Gets the listener provider instance + * + * @return ListenerProviderInterface + */ + public function getListenerProvider(): ListenerProviderInterface + { + return $this->listenerProvider; + } +} \ No newline at end of file diff --git a/tests/Event/AbstractDataObjectEventTest.php b/tests/Event/AbstractDataObjectEventTest.php new file mode 100644 index 0000000..7b297de --- /dev/null +++ b/tests/Event/AbstractDataObjectEventTest.php @@ -0,0 +1,43 @@ + ['old' => null, 'new' => 'New Page']] + ); + + $this->assertEquals(1, $event->getObjectID()); + $this->assertEquals('Page', $event->getObjectClass()); + $this->assertEquals('create', $event->getAction()); + $this->assertArrayHasKey('Title', $event->getChanges()); + } + + public function testJsonSerialization(): void + { + $event = new DataObjectWriteEvent( + 1, + 'Page', + 'create', + ['Title' => ['old' => null, 'new' => 'New Page']] + ); + + $json = json_encode($event); + $data = json_decode($json, true); + + $this->assertArrayHasKey('id', $data); + $this->assertArrayHasKey('class', $data); + $this->assertArrayHasKey('action', $data); + $this->assertArrayHasKey('changes', $data); + $this->assertArrayHasKey('timestamp', $data); + } +} \ No newline at end of file diff --git a/tests/Event/DataObjectVersionEventTest.php b/tests/Event/DataObjectVersionEventTest.php new file mode 100644 index 0000000..c50f971 --- /dev/null +++ b/tests/Event/DataObjectVersionEventTest.php @@ -0,0 +1,42 @@ + ['old' => 'Old Title', 'new' => 'New Title']] + ); + + $this->assertEquals(1, $event->getObjectID()); + $this->assertEquals('Page', $event->getObjectClass()); + $this->assertEquals('publish', $event->getAction()); + $this->assertEquals(2, $event->getVersion()); + } + + public function testVersionJsonSerialization(): void + { + $event = new DataObjectVersionEvent( + 1, + 'Page', + 'publish', + 2, + [] + ); + + $json = json_encode($event); + $data = json_decode($json, true); + + $this->assertArrayHasKey('version', $data); + $this->assertEquals(2, $data['version']); + } +} \ No newline at end of file