Skip to content

Commit

Permalink
Add Doctrine ORM 3 support (#44)
Browse files Browse the repository at this point in the history
* Add Doctrine ORM 3 support

* Add integration tests and enhance CI workflow for multiple platforms

Add integration tests with SQLite, MySQL, PostgreSQL.
Run SQLite tests on Windows and Linux.
Run MySQL/PostgreSQL tests on Linux.
  • Loading branch information
wazum authored Jan 4, 2025
1 parent 7128cab commit 40ad17d
Show file tree
Hide file tree
Showing 7 changed files with 403 additions and 22 deletions.
126 changes: 107 additions & 19 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,62 +2,150 @@ name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:

phpunit:
name: PHPUnit with PHP ${{ matrix.php }} and Symfony ${{ matrix.symfony }}
phpunit-sqlite:
name: PHPUnit (PHP ${{ matrix.php }} - Symfony ${{ matrix.symfony }} - Doctrine ${{ matrix.doctrine }} - SQLite - ${{ matrix.operating-system }})
runs-on: ${{ matrix.operating-system }}

strategy:
fail-fast: false
matrix:
operating-system: [ ubuntu-latest, windows-latest ]
php: [ '8.1', '8.2', '8.3', '8.4' ]
symfony: [ '5.4.*', '6.4.*', '7.2.*' ]
php: ['8.1', '8.2', '8.3', '8.4']
symfony: ['5.4.*', '6.4.*', '7.2.*']
doctrine: ['^2.5', '^3.0']
exclude:
- { php: '8.1', symfony: '7.2.*' }
- { php: '8.1', doctrine: '^3.0' }
- { symfony: '5.4.*', doctrine: '^3.0' }

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
tools: flex
extensions: pdo_sqlite
coverage: none
- uses: ramsey/composer-install@v3
env:
SYMFONY_REQUIRE: ${{ matrix.symfony }}
with:
composer-options: --with=doctrine/orm:${{ matrix.doctrine }}
- run: vendor/bin/phpunit tests
env:
DATABASE_URL: 'sqlite:///:memory:'

phpunit-mysql:
name: PHPUnit (PHP ${{ matrix.php }} - Symfony ${{ matrix.symfony }} - Doctrine ${{ matrix.doctrine }} - MySQL)
runs-on: ubuntu-latest

- name: Install composer dependencies
uses: ramsey/composer-install@v3
services:
mysql:
image: mysql:8.0
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: test_db
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
symfony: ['5.4.*', '6.4.*', '7.2.*']
doctrine: ['^2.5', '^3.0']
exclude:
- { php: '8.1', symfony: '7.2.*' }
- { php: '8.1', doctrine: '^3.0' }
- { symfony: '5.4.*', doctrine: '^3.0' }

steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
tools: flex
extensions: pdo_mysql
coverage: none
- uses: ramsey/composer-install@v3
env:
SYMFONY_REQUIRE: ${{ matrix.symfony }}
with:
composer-options: --with=doctrine/orm:${{ matrix.doctrine }}
- run: vendor/bin/phpunit tests
env:
DATABASE_URL: 'mysql://root:@127.0.0.1:3306/test_db'

phpunit-postgres:
name: PHPUnit (PHP ${{ matrix.php }} - Symfony ${{ matrix.symfony }} - Doctrine ${{ matrix.doctrine }} - PostgreSQL)
runs-on: ubuntu-latest

services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval=10s
--health-timeout=5s
--health-retries=3
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
symfony: ['5.4.*', '6.4.*', '7.2.*']
doctrine: ['^2.5', '^3.0']
exclude:
- { php: '8.1', symfony: '7.2.*' }
- { php: '8.1', doctrine: '^3.0' }
- { symfony: '5.4.*', doctrine: '^3.0' }

- name: Run test suite on PHP ${{ matrix.php }} and Symfony ${{ matrix.symfony }}
run: vendor/bin/phpunit tests
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
tools: flex
extensions: pdo_pgsql
coverage: none
- uses: ramsey/composer-install@v3
env:
SYMFONY_REQUIRE: ${{ matrix.symfony }}
with:
composer-options: --with=doctrine/orm:${{ matrix.doctrine }}
- run: vendor/bin/phpunit tests
env:
DATABASE_URL: 'postgresql://postgres:[email protected]:5432/test_db'

ecs:
name: Easy Coding Standard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: latest
php-version: latest
coverage: none
- uses: ramsey/composer-install@v3
- run: vendor/bin/ecs

phpstan:
name: PHPStan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: latest
coverage: none
- uses: ramsey/composer-install@v3
- run: vendor/bin/phpstan
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
},
"require": {
"php": "^8.1",
"doctrine/orm": "^2.5",
"doctrine/orm": "^2.5 || ^3.0",
"doctrine/doctrine-bundle": "^2.0",
"ramsey/uuid-doctrine": "^1.5",
"ramsey/uuid-doctrine": "^1.5 || ^2.0",
"symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0",
"symfony/framework-bundle": "^5.4 || ^6.4 || ^7.0",
"symfony/lock": "^5.4 || ^6.4 || ^7.0",
Expand Down
1 change: 0 additions & 1 deletion tests/Functional/config.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

doctrine:

dbal:
Expand Down
196 changes: 196 additions & 0 deletions tests/Integration/DomainEventPersistenceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?php

declare(strict_types=1);

namespace Headsnet\DomainEventsBundle\Integration;

use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\DoctrineBundle\Registry;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Headsnet\DomainEventsBundle\Domain\Model\DomainEvent;
use Headsnet\DomainEventsBundle\Domain\Model\EventStore;
use Headsnet\DomainEventsBundle\Domain\Model\StoredEvent;
use Headsnet\DomainEventsBundle\HeadsnetDomainEventsBundle;
use Headsnet\DomainEventsBundle\Integration\Fixtures\TestEntity;
use Headsnet\DomainEventsBundle\Integration\Fixtures\TestEvent;
use Nyholm\BundleTest\TestKernel;
use Ramsey\Uuid\Uuid;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpKernel\KernelInterface;

/**
* @group integration
*/
class DomainEventPersistenceTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
private EventStore $eventStore;

protected static function getKernelClass(): string
{
return TestKernel::class;
}

/**
* @param array<string, mixed> $options
*/
protected static function createKernel(array $options = []): KernelInterface
{
/** @var TestKernel $kernel */
$kernel = parent::createKernel($options);
$kernel->addTestConfig(__DIR__ . '/config.yml');
$kernel->addTestBundle(FrameworkBundle::class);
$kernel->addTestBundle(DoctrineBundle::class);
$kernel->addTestBundle(HeadsnetDomainEventsBundle::class);
$kernel->handleOptions($options);

return $kernel;
}

protected function setUp(): void
{
self::bootKernel();

$container = self::getContainer();

/** @var Registry $doctrine */
$doctrine = $container->get('doctrine');
/** @var EntityManagerInterface $entityManager */
$entityManager = $doctrine->getManager();
$this->entityManager = $entityManager;

/** @var EventStore $eventStore */
$eventStore = $container->get(EventStore::class);
$this->eventStore = $eventStore;

$schemaTool = new SchemaTool($this->entityManager);
$metadata = $this->entityManager->getMetadataFactory()->getAllMetadata();
$schemaTool->dropSchema($metadata);
$schemaTool->createSchema($metadata);
}

public function testDomainEventIsPersistedWithCorrectData(): void
{
$entity = new TestEntity();
$event = new TestEvent($entity->getId());

$entity->record($event);
$this->entityManager->persist($entity);
$this->entityManager->flush();
$this->entityManager->clear();

/** @var StoredEvent[] $storedEvents */
$storedEvents = $this->entityManager->getRepository(StoredEvent::class)->findAll();
self::assertCount(1, $storedEvents);
self::assertEquals($event->getAggregateRootId(), $storedEvents[0]->getAggregateRoot());
/** @var \DateTimeImmutable $expectedDate */
$expectedDate = \DateTimeImmutable::createFromFormat(DomainEvent::MICROSECOND_DATE_FORMAT, $event->getOccurredOn());
/** @var \DateTimeImmutable $actualDate */
$actualDate = $storedEvents[0]->getOccurredOn();
$platform = $this->entityManager->getConnection()->getDatabasePlatform();

if ($this->isMicrosecondPlatform($platform)) {
// MySQL and PostgreSQL support microseconds
self::assertEquals(
$expectedDate->format('Y-m-d\TH:i:s.u'),
$actualDate->format('Y-m-d\TH:i:s.u'),
'Dates should match with microsecond precision'
);
} else {
// SQLite and others: compare only up to seconds
self::assertEquals(
$expectedDate->format('Y-m-d H:i:s'),
$actualDate->format('Y-m-d H:i:s'),
'Dates should match up to seconds precision'
);
}
self::assertNull($storedEvents[0]->getPublishedOn());
}

public function testReplaceableDomainEventReplacesUnpublishedEvent(): void
{
$aggregateId = Uuid::uuid4()->toString();
$firstEvent = new TestEvent($aggregateId);
$secondEvent = new TestEvent($aggregateId);

// Store event
$this->eventStore->append($firstEvent);
$this->entityManager->flush();
$this->entityManager->clear();

$events = $this->entityManager->getRepository(StoredEvent::class)->findAll();
self::assertCount(1, $events);
$firstStoredEventId = $events[0]->getEventId()->asString();

// Replace event
$this->eventStore->replace($secondEvent);
$this->entityManager->flush();
$this->entityManager->clear();

$events = $this->entityManager->getRepository(StoredEvent::class)->findAll();
self::assertCount(1, $events, 'Should still have only one event');
self::assertNotEquals($firstStoredEventId, $events[0]->getEventId()->asString(), 'Should have a different event ID');
self::assertEquals($aggregateId, $events[0]->getAggregateRoot());
}

public function testPublishedDomainEventIsNotReplaced(): void
{
$aggregateId = Uuid::uuid4()->toString();
$firstEvent = new TestEvent($aggregateId);

$this->eventStore->append($firstEvent);
$this->entityManager->flush();
$this->entityManager->clear();
$storedEvent = $this->entityManager->getRepository(StoredEvent::class)->findAll()[0];
$this->eventStore->publish($storedEvent);
$this->entityManager->flush();
$this->entityManager->clear();

// Attempt to replace published event
$secondEvent = new TestEvent($aggregateId);
$this->eventStore->replace($secondEvent);
$this->entityManager->flush();
$this->entityManager->clear();

$events = $this->entityManager->getRepository(StoredEvent::class)->findAll();
self::assertCount(2, $events, 'Should have two events');
self::assertEquals($aggregateId, $events[0]->getAggregateRoot());
self::assertEquals($aggregateId, $events[1]->getAggregateRoot());
self::assertNotNull($events[0]->getPublishedOn(), 'First event should be published');
self::assertNull($events[1]->getPublishedOn(), 'Second event should not be published');
}

public function testUnpublishedEventsCanBeRetrieved(): void
{
$aggregateId = Uuid::uuid4()->toString();
$event = new TestEvent($aggregateId);

$this->eventStore->append($event);
$this->entityManager->flush();
$this->entityManager->clear();

$unpublishedEvents = $this->eventStore->allUnpublished();
self::assertCount(1, $unpublishedEvents);
self::assertEquals($aggregateId, $unpublishedEvents[0]->getAggregateRoot());
}

private function isMicrosecondPlatform(AbstractPlatform $platform): bool
{
$platformClass = get_class($platform);
$microsecondPlatformClasses = [
'Doctrine\\DBAL\\Platforms\\MySQLPlatform',
'Doctrine\\DBAL\\Platforms\\PostgreSQLPlatform'
];

return in_array($platformClass, $microsecondPlatformClasses);
}

protected function tearDown(): void
{
parent::tearDown();
$this->entityManager->close();
}
}
Loading

0 comments on commit 40ad17d

Please sign in to comment.