├── .styleci.yml ├── .github └── FUNDING.yml ├── Classes ├── EventStore │ ├── StreamAwareEventListenerInterface.php │ ├── Exception │ │ ├── ConcurrencyException.php │ │ └── StorageConfigurationException.php │ ├── EventStreamIteratorInterface.php │ ├── Normalizer │ │ ├── ProxyAwareObjectNormalizer.php │ │ └── ValueObjectNormalizer.php │ ├── ExpectedVersion.php │ ├── WritableEvent.php │ ├── EventEnvelope.php │ ├── WritableEvents.php │ ├── Storage │ │ ├── EventStorageInterface.php │ │ ├── Doctrine │ │ │ ├── Factory │ │ │ │ └── ConnectionFactory.php │ │ │ └── DoctrineStreamIterator.php │ │ └── InMemory │ │ │ └── InMemoryStreamIterator.php │ ├── EventStream.php │ ├── RawEvent.php │ ├── EventNormalizer.php │ ├── StreamName.php │ ├── EventStore.php │ └── EventStoreFactory.php ├── EventRecordingInterface.php ├── EventListener │ ├── EventListenerInterface.php │ ├── Exception │ │ ├── InvalidConfigurationException.php │ │ ├── InvalidEventListenerException.php │ │ ├── HighestAppliedSequenceNumberCantBeReservedException.php │ │ └── EventCouldNotBeAppliedException.php │ ├── AfterInvokeInterface.php │ ├── BeforeInvokeInterface.php │ ├── ProvidesAppliedEventsStorageInterface.php │ ├── AppliedEventsStorage │ │ ├── AppliedEventsLog.php │ │ ├── AppliedEventsStorageInterface.php │ │ ├── DefaultAppliedEventsStorage.php │ │ └── DoctrineAppliedEventsStorage.php │ ├── AfterCatchUpInterface.php │ └── Mapping │ │ ├── EventToListenerMapping.php │ │ └── EventToListenerMappings.php ├── Event │ ├── DomainEventInterface.php │ ├── ProvidesEventTypeInterface.php │ ├── EventTypeResolverInterface.php │ ├── DomainEvents.php │ ├── DecoratedEvent.php │ └── EventTypeResolver.php ├── EventPublisher │ ├── NoopEventPublisher.php │ ├── EventPublisherFactoryInterface.php │ ├── EventPublisherInterface.php │ ├── DefaultEventPublisherFactory.php │ ├── DeferEventPublisher.php │ ├── JobQueue │ │ └── CatchUpEventListenerJob.php │ └── JobQueueEventPublisher.php ├── Projection │ ├── ProjectorInterface.php │ └── Projection.php ├── AbstractEventSourcedAggregateRoot.php └── Command │ └── EventStoreCommandController.php ├── Tests ├── Unit │ ├── EventStore │ │ ├── Fixture │ │ │ ├── DummyEvent1.php │ │ │ ├── DummyEvent2.php │ │ │ ├── DummyEvent3.php │ │ │ ├── BackedEnum.php │ │ │ ├── SomethingHasHappened.php │ │ │ ├── ArrayBasedEvent.php │ │ │ ├── EventWithBackedEnum.php │ │ │ ├── ArrayValueObject.php │ │ │ ├── FloatValueObject.php │ │ │ ├── IntegerValueObject.php │ │ │ ├── BooleanValueObject.php │ │ │ ├── StringValueObject.php │ │ │ ├── EventWithDateTime.php │ │ │ └── EventWithValueObjects.php │ │ ├── Normalizer │ │ │ ├── Fixture │ │ │ │ ├── ArrayBasedValueObject.php │ │ │ │ ├── FloatBasedValueObject.php │ │ │ │ ├── IntegerBasedValueObject.php │ │ │ │ ├── StringBasedValueObject.php │ │ │ │ ├── BooleanBasedValueObject.php │ │ │ │ └── InvalidArrayBasedValueObject.php │ │ │ └── ValueObjectNormalizerTest.php │ │ ├── Storage │ │ │ └── InMemory │ │ │ │ └── InMemoryStreamIteratorTest.php │ │ ├── EventStreamTest.php │ │ └── EventNormalizerTest.php │ ├── EventListener │ │ ├── Fixture │ │ │ └── AppliedEventsStorageEventListener.php │ │ └── EventListenerInvokerTest.php │ ├── EventPublisher │ │ ├── DeferEventPublisherTest.php │ │ └── JobQueueEventPublisherTest.php │ ├── Event │ │ └── DecoratedEventTest.php │ └── Projection │ │ └── ProjectionManagerTest.php └── Functional │ ├── EventStore │ ├── Fixture │ │ ├── MockDomainEvent.php │ │ ├── MockValueObject.php │ │ ├── MockDomainEvent2.php │ │ └── MockDomainEvent3.php │ └── EventNormalizerTest.php │ └── EventListener │ └── AppliedEventsStorage │ └── DoctrineAdapterTest.php ├── Configuration ├── Objects.yaml └── Settings.yaml ├── Resources └── Private │ └── Schema │ └── Settings │ └── Neos.EventSourcing.schema.yaml ├── composer.json ├── LICENSE └── Migrations └── Mysql ├── Version20161118131506.php ├── Version20161114151203.php ├── Version20161116120502.php ├── Version20161216132312.php ├── Version20190830130314.php └── Version20161117145045.php /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: psr2 2 | 3 | finder: 4 | path: 5 | - "Classes" 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [bwaidelich,robertlemke,skurfuerst] 4 | -------------------------------------------------------------------------------- /Classes/EventStore/StreamAwareEventListenerInterface.php: -------------------------------------------------------------------------------- 1 | data = $data; 18 | } 19 | 20 | public function getData(): array 21 | { 22 | return $this->data; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Classes/EventListener/EventListenerInterface.php: -------------------------------------------------------------------------------- 1 | enum = $enum; 18 | } 19 | 20 | public function getEnum(): BackedEnum 21 | { 22 | return $this->enum; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Tests/Functional/EventStore/Fixture/MockDomainEvent.php: -------------------------------------------------------------------------------- 1 | string = $string; 18 | } 19 | 20 | public function getString(): string 21 | { 22 | return $this->string; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Tests/Functional/EventStore/Fixture/MockValueObject.php: -------------------------------------------------------------------------------- 1 | string = $string; 16 | } 17 | 18 | public function getString(): string 19 | { 20 | return $this->string; 21 | } 22 | 23 | public function jsonSerialize(): string 24 | { 25 | return $this->string; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Tests/Functional/EventStore/Fixture/MockDomainEvent2.php: -------------------------------------------------------------------------------- 1 | string = $string; 22 | } 23 | 24 | public function getString(): string 25 | { 26 | return $this->string; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Tests/Functional/EventStore/Fixture/MockDomainEvent3.php: -------------------------------------------------------------------------------- 1 | value = $value; 22 | } 23 | 24 | public function getValue(): MockValueObject 25 | { 26 | return $this->value; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Classes/Event/DomainEventInterface.php: -------------------------------------------------------------------------------- 1 | value = $value; 13 | } 14 | 15 | public static function fromArray(array $value): self 16 | { 17 | return new self($value); 18 | } 19 | 20 | public function equals(self $other): bool 21 | { 22 | return $other->value === $this->value; 23 | } 24 | 25 | public function jsonSerialize(): array 26 | { 27 | return $this->value; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/Unit/EventStore/Fixture/FloatValueObject.php: -------------------------------------------------------------------------------- 1 | value = $value; 13 | } 14 | 15 | public static function fromFloat(float $value): self 16 | { 17 | return new self($value); 18 | } 19 | 20 | public function equals(self $other): bool 21 | { 22 | return $other->value === $this->value; 23 | } 24 | 25 | public function jsonSerialize(): float 26 | { 27 | return $this->value; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/Unit/EventStore/Fixture/IntegerValueObject.php: -------------------------------------------------------------------------------- 1 | value = $value; 13 | } 14 | 15 | public static function fromInteger(int $value): self 16 | { 17 | return new self($value); 18 | } 19 | 20 | public function equals(self $other): bool 21 | { 22 | return $other->value === $this->value; 23 | } 24 | 25 | public function jsonSerialize(): int 26 | { 27 | return $this->value; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/Unit/EventStore/Fixture/BooleanValueObject.php: -------------------------------------------------------------------------------- 1 | value = $value; 13 | } 14 | 15 | public static function fromBoolean(bool $value): self 16 | { 17 | return new self($value); 18 | } 19 | 20 | public function equals(self $other): bool 21 | { 22 | return $other->value === $this->value; 23 | } 24 | 25 | public function jsonSerialize(): bool 26 | { 27 | return $this->value; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/Unit/EventStore/Fixture/StringValueObject.php: -------------------------------------------------------------------------------- 1 | value = $value; 13 | } 14 | 15 | public static function fromString(string $value): self 16 | { 17 | return new self($value); 18 | } 19 | 20 | public function equals(self $other): bool 21 | { 22 | return $other->value === $this->value; 23 | } 24 | 25 | public function jsonSerialize(): string 26 | { 27 | return $this->value; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Classes/EventListener/Exception/HighestAppliedSequenceNumberCantBeReservedException.php: -------------------------------------------------------------------------------- 1 | date = $date; 18 | } 19 | 20 | /** 21 | * @return \DateTimeInterface 22 | */ 23 | public function getDate(): \DateTimeInterface 24 | { 25 | return $this->date; 26 | } 27 | 28 | public function equals(self $other): bool 29 | { 30 | return $other->date->getTimestamp() === $this->date->getTimestamp(); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /Classes/Projection/ProjectorInterface.php: -------------------------------------------------------------------------------- 1 | () was invoked. 20 | */ 21 | interface AfterInvokeInterface 22 | { 23 | /** 24 | * Called after a listener method is invoked 25 | * 26 | * @param EventEnvelope $eventEnvelope 27 | * @return void 28 | */ 29 | public function afterInvoke(EventEnvelope $eventEnvelope): void; 30 | } 31 | -------------------------------------------------------------------------------- /Tests/Unit/EventListener/Fixture/AppliedEventsStorageEventListener.php: -------------------------------------------------------------------------------- 1 | appliedEventsStorage = $appliedEventsStorage; 21 | } 22 | 23 | public function getAppliedEventsStorage(): AppliedEventsStorageInterface 24 | { 25 | return $this->appliedEventsStorage; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Classes/EventListener/BeforeInvokeInterface.php: -------------------------------------------------------------------------------- 1 | () was invoked. 20 | */ 21 | interface BeforeInvokeInterface 22 | { 23 | /** 24 | * Called before a listener method is invoked 25 | * 26 | * @param EventEnvelope $eventEnvelope 27 | * @return void 28 | */ 29 | public function beforeInvoke(EventEnvelope $eventEnvelope): void; 30 | } 31 | -------------------------------------------------------------------------------- /Classes/EventPublisher/EventPublisherFactoryInterface.php: -------------------------------------------------------------------------------- 1 | =8.0", 8 | "neos/flow": "^7.0 || ^8.0 || ^9.0 || dev-master", 9 | "flowpack/jobqueue-common": "^3.0 || dev-master", 10 | "symfony/serializer": "^5.1", 11 | "symfony/property-access": "^5.1", 12 | "ramsey/uuid": "^3.9 || ^4.0" 13 | }, 14 | "require-dev": { 15 | "roave/security-advisories": "dev-master" 16 | }, 17 | "autoload": { 18 | "psr-4": { 19 | "Neos\\EventSourcing\\": "Classes" 20 | } 21 | }, 22 | "autoload-dev": { 23 | "psr-4": { 24 | "Neos\\EventSourcing\\Tests\\": "Tests" 25 | } 26 | }, 27 | "extra": { 28 | "neos": { 29 | "package-key": "Neos.EventSourcing" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Classes/EventPublisher/EventPublisherInterface.php: -------------------------------------------------------------------------------- 1 | implementsInterface(ProxyInterface::class)) { 16 | return $reflectionClass->getParentClass()->getConstructor(); 17 | } 18 | return $reflectionClass->getConstructor(); 19 | } 20 | 21 | public function hasCacheableSupportsMethod(): bool 22 | { 23 | return true; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Classes/Event/EventTypeResolverInterface.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on "mysql".'); 29 | $this->addSql('RENAME TABLE neos_cqrs_processmanager_state_state TO neos_cqrs_processmanager_state_processstate'); 30 | } 31 | 32 | /** 33 | * @param Schema $schema 34 | * @return void 35 | */ 36 | public function down(Schema $schema): void 37 | { 38 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on "mysql".'); 39 | $this->addSql('RENAME TABLE neos_cqrs_processmanager_state_processstate TO neos_cqrs_processmanager_state_state'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Classes/Projection/Projection.php: -------------------------------------------------------------------------------- 1 | :" 22 | * 23 | * @var string 24 | */ 25 | private $fullIdentifier; 26 | 27 | /** 28 | * @var string 29 | */ 30 | private $projectorClassName; 31 | 32 | /** 33 | * @param string $identifier 34 | * @param string $projectorClassName 35 | */ 36 | public function __construct(string $identifier, string $projectorClassName) 37 | { 38 | $this->fullIdentifier = $identifier; 39 | $this->projectorClassName = $projectorClassName; 40 | } 41 | 42 | /** 43 | * @return string 44 | */ 45 | public function getIdentifier(): string 46 | { 47 | return $this->fullIdentifier; 48 | } 49 | 50 | /** 51 | * @return string 52 | */ 53 | public function getProjectorClassName(): string 54 | { 55 | return $this->projectorClassName; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Classes/EventStore/WritableEvent.php: -------------------------------------------------------------------------------- 1 | identifier = $identifier; 40 | $this->type = $type; 41 | $this->data = $data; 42 | $this->metadata = $metadata; 43 | } 44 | 45 | public function getIdentifier(): string 46 | { 47 | return $this->identifier; 48 | } 49 | 50 | public function getType(): string 51 | { 52 | return $this->type; 53 | } 54 | 55 | public function getData(): array 56 | { 57 | return $this->data; 58 | } 59 | 60 | public function getMetadata(): array 61 | { 62 | return $this->metadata; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20161114151203.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on "mysql".'); 29 | $this->addSql('CREATE TABLE neos_cqrs_projection_doctrine_projectionstate (projectoridentifier VARCHAR(255) NOT NULL, highestappliedsequencenumber INT NOT NULL, PRIMARY KEY(projectoridentifier)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); 30 | } 31 | 32 | /** 33 | * @param Schema $schema 34 | * @return void 35 | */ 36 | public function down(Schema $schema): void 37 | { 38 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on "mysql".'); 39 | $this->addSql('DROP TABLE neos_cqrs_projection_doctrine_projectionstate'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20161116120502.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on "mysql".'); 29 | $this->addSql('CREATE TABLE neos_cqrs_processmanager_state_state (identifier VARCHAR(40) NOT NULL, processmanagerclassname VARCHAR(255) NOT NULL, properties LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', checklist LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', PRIMARY KEY(identifier, processmanagerclassname)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); 30 | } 31 | 32 | /** 33 | * @param Schema $schema 34 | * @return void 35 | */ 36 | public function down(Schema $schema): void 37 | { 38 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on "mysql".'); 39 | $this->addSql('DROP TABLE neos_cqrs_processmanager_state_state'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Classes/EventListener/Exception/EventCouldNotBeAppliedException.php: -------------------------------------------------------------------------------- 1 | eventEnvelope = $eventEnvelope; 38 | $this->eventListener = $eventListener; 39 | } 40 | 41 | public function getEventEnvelope(): EventEnvelope 42 | { 43 | return $this->eventEnvelope; 44 | } 45 | 46 | public function getEventListener(): EventListenerInterface 47 | { 48 | return $this->eventListener; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Classes/EventStore/EventEnvelope.php: -------------------------------------------------------------------------------- 1 | event = $event; 37 | $this->rawEvent = $rawEvent; 38 | } 39 | 40 | /** 41 | * The converted event instance 42 | * 43 | * @return DomainEventInterface 44 | */ 45 | public function getDomainEvent(): DomainEventInterface 46 | { 47 | return $this->event; 48 | } 49 | 50 | /** 51 | * The raw event including version and metadata, as stored in the Event Store 52 | * 53 | * @return RawEvent 54 | */ 55 | public function getRawEvent(): RawEvent 56 | { 57 | return $this->rawEvent; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20161216132312.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on "mysql".'); 29 | $this->addSql('RENAME TABLE neos_cqrs_processmanager_state_processstate TO neos_eventsourcing_processmanager_state_processstate'); 30 | $this->addSql('RENAME TABLE neos_cqrs_eventlistener_appliedeventslog TO neos_eventsourcing_eventlistener_appliedeventslog'); 31 | } 32 | 33 | /** 34 | * @param Schema $schema 35 | * @return void 36 | */ 37 | public function down(Schema $schema): void 38 | { 39 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on "mysql".'); 40 | $this->addSql('RENAME TABLE neos_eventsourcing_processmanager_state_processstate TO neos_cqrs_processmanager_state_processstate'); 41 | $this->addSql('RENAME TABLE neos_eventsourcing_eventlistener_appliedeventslog TO neos_cqrs_eventlistener_appliedeventslog'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Classes/EventStore/WritableEvents.php: -------------------------------------------------------------------------------- 1 | events = $events; 31 | } 32 | 33 | public static function fromArray(array $events): self 34 | { 35 | foreach ($events as $event) { 36 | if (!$event instanceof WritableEvent) { 37 | throw new \InvalidArgumentException(sprintf('Only instances of WritableEvent are allowed, given: %s', \is_object($event) ? \get_class($event) : \gettype($event)), 1540316594); 38 | } 39 | } 40 | return new self(array_values($events)); 41 | } 42 | 43 | public function append(WritableEvent $event): self 44 | { 45 | $events = $this->events; 46 | $events[] = $event; 47 | return new self($events); 48 | } 49 | 50 | /** 51 | * @return WritableEvent[]|\ArrayIterator 52 | */ 53 | public function getIterator(): \ArrayIterator 54 | { 55 | return new \ArrayIterator($this->events); 56 | } 57 | 58 | public function count(): int 59 | { 60 | return \count($this->events); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20190830130314.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on "mysql".'); 28 | 29 | $this->addSql('DROP TABLE neos_eventsourcing_processmanager_state_processstate'); 30 | } 31 | 32 | /** 33 | * @param Schema $schema 34 | * @return void 35 | * @throws DBALException | AbortMigrationException 36 | */ 37 | public function down(Schema $schema): void 38 | { 39 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on "mysql".'); 40 | 41 | $this->addSql('CREATE TABLE neos_eventsourcing_processmanager_state_processstate (identifier VARCHAR(40) NOT NULL COLLATE utf8_unicode_ci, processmanagerclassname VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, properties LONGTEXT NOT NULL COLLATE utf8_unicode_ci COMMENT \'(DC2Type:array)\', checklist LONGTEXT NOT NULL COLLATE utf8_unicode_ci COMMENT \'(DC2Type:array)\', PRIMARY KEY(identifier, processmanagerclassname)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB COMMENT = \'\' '); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20161117145045.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on "mysql".'); 28 | 29 | $this->addSql('CREATE TABLE neos_cqrs_eventlistener_appliedeventslog (eventlisteneridentifier VARCHAR(255) NOT NULL, highestappliedsequencenumber INT NOT NULL, PRIMARY KEY(eventlisteneridentifier)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); 30 | $this->addSql('DROP TABLE neos_cqrs_projection_doctrine_projectionstate'); 31 | } 32 | 33 | /** 34 | * @param Schema $schema 35 | * @return void 36 | */ 37 | public function down(Schema $schema): void 38 | { 39 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on "mysql".'); 40 | 41 | $this->addSql('CREATE TABLE neos_cqrs_projection_doctrine_projectionstate (projectoridentifier VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, highestappliedsequencenumber INT NOT NULL, PRIMARY KEY(projectoridentifier)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); 42 | $this->addSql('DROP TABLE neos_cqrs_eventlistener_appliedeventslog'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Classes/EventListener/AfterCatchUpInterface.php: -------------------------------------------------------------------------------- 1 | mappingProvider = $mappingProvider; 41 | } 42 | 43 | public function create(string $eventStoreIdentifier): EventPublisherInterface 44 | { 45 | if (!isset($this->eventPublisherInstances[$eventStoreIdentifier])) { 46 | $mappings = $this->mappingProvider->getMappingsForEventStore($eventStoreIdentifier); 47 | $this->eventPublisherInstances[$eventStoreIdentifier] = DeferEventPublisher::forPublisher(new JobQueueEventPublisher($eventStoreIdentifier, $mappings)); 48 | } 49 | return $this->eventPublisherInstances[$eventStoreIdentifier]; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/Unit/EventPublisher/DeferEventPublisherTest.php: -------------------------------------------------------------------------------- 1 | getMock(); 20 | 21 | $publisher = DeferEventPublisher::forPublisher( 22 | $mockPublisher 23 | ); 24 | 25 | $mockPublisher 26 | ->expects(self::never()) 27 | ->method('publish'); 28 | 29 | $publisher->publish(DomainEvents::createEmpty()); 30 | $publisher->invoke(); 31 | } 32 | 33 | /** 34 | * @test 35 | */ 36 | public function pendingEventsAreClearedAfterInvoke(): void 37 | { 38 | $mockPublisher = self::getMockBuilder(EventPublisherInterface::class)->getMock(); 39 | $eventA = self::getMockBuilder(DomainEventInterface::class)->getMock(); 40 | $eventB = self::getMockBuilder(DomainEventInterface::class)->getMock(); 41 | 42 | $publisher = DeferEventPublisher::forPublisher( 43 | $mockPublisher 44 | ); 45 | 46 | $mockPublisher 47 | ->expects(self::exactly(2)) 48 | ->method('publish') 49 | ->withConsecutive( 50 | [DomainEvents::withSingleEvent($eventA)], 51 | [DomainEvents::withSingleEvent($eventB)] 52 | ); 53 | 54 | $publisher->publish(DomainEvents::withSingleEvent($eventA)); 55 | $publisher->invoke(); 56 | 57 | $publisher->publish(DomainEvents::withSingleEvent($eventB)); 58 | $publisher->invoke(); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /Classes/EventStore/Storage/EventStorageInterface.php: -------------------------------------------------------------------------------- 1 | eventClassName = $eventClassName; 42 | $this->listenerClassName = $listenerClassName; 43 | $this->options = $options; 44 | } 45 | 46 | public static function create(string $eventClassName, string $listenerClassName, array $options): self 47 | { 48 | return new self($eventClassName, $listenerClassName, $options); 49 | } 50 | 51 | public function getEventClassName(): string 52 | { 53 | return $this->eventClassName; 54 | } 55 | 56 | public function getListenerClassName(): string 57 | { 58 | return $this->listenerClassName; 59 | } 60 | 61 | public function getOptions(): array 62 | { 63 | return $this->options; 64 | } 65 | 66 | public function getOption(string $optionName, $defaultValue) 67 | { 68 | return $this->options[$optionName] ?? $defaultValue; 69 | } 70 | 71 | /** 72 | * @inheritDoc 73 | */ 74 | public function jsonSerialize(): array 75 | { 76 | return [ 77 | 'eventClassName' => $this->eventClassName, 78 | 'listenerClassName' => $this->listenerClassName, 79 | 'options' => $this->options, 80 | ]; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Tests/Unit/EventStore/Fixture/EventWithValueObjects.php: -------------------------------------------------------------------------------- 1 | array = $array; 38 | $this->string = $string; 39 | $this->integer = $integer; 40 | $this->float = $float; 41 | $this->boolean = $boolean; 42 | } 43 | 44 | /** 45 | * @return ArrayValueObject 46 | */ 47 | public function getArray(): ArrayValueObject 48 | { 49 | return $this->array; 50 | } 51 | 52 | /** 53 | * @return StringValueObject 54 | */ 55 | public function getString(): StringValueObject 56 | { 57 | return $this->string; 58 | } 59 | 60 | /** 61 | * @return IntegerValueObject 62 | */ 63 | public function getInteger(): IntegerValueObject 64 | { 65 | return $this->integer; 66 | } 67 | 68 | /** 69 | * @return FloatValueObject 70 | */ 71 | public function getFloat(): FloatValueObject 72 | { 73 | return $this->float; 74 | } 75 | 76 | /** 77 | * @return BooleanValueObject 78 | */ 79 | public function getBoolean(): BooleanValueObject 80 | { 81 | return $this->boolean; 82 | } 83 | 84 | public function equals(self $other): bool 85 | { 86 | return $other->array->equals($this->array) 87 | && $other->string->equals($this->string) 88 | && $other->integer->equals($this->integer) 89 | && $other->float->equals($this->float) 90 | && $other->boolean->equals($this->boolean); 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /Classes/EventStore/EventStream.php: -------------------------------------------------------------------------------- 1 | streamName = $streamName; 46 | $this->streamIterator = $streamIterator; 47 | $this->eventNormalizer = $eventNormalizer; 48 | } 49 | 50 | public function getName(): StreamName 51 | { 52 | return $this->streamName; 53 | } 54 | 55 | /** 56 | * @return EventEnvelope 57 | * @throws SerializerException 58 | */ 59 | public function current(): EventEnvelope 60 | { 61 | /** @var RawEvent $rawEvent */ 62 | $rawEvent = $this->streamIterator->current(); 63 | return new EventEnvelope( 64 | $this->eventNormalizer->denormalize($rawEvent->getPayload(), $rawEvent->getType()), 65 | $rawEvent 66 | ); 67 | } 68 | 69 | public function next(): void 70 | { 71 | $this->streamIterator->next(); 72 | } 73 | 74 | public function key(): float|bool|int|string|null 75 | { 76 | return $this->streamIterator->key(); 77 | } 78 | 79 | public function valid(): bool 80 | { 81 | return $this->streamIterator->valid(); 82 | } 83 | 84 | public function rewind(): void 85 | { 86 | $this->streamIterator->rewind(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Tests/Unit/EventStore/Normalizer/ValueObjectNormalizerTest.php: -------------------------------------------------------------------------------- 1 | valueObjectNormalizer = new ValueObjectNormalizer(); 19 | } 20 | 21 | /** 22 | * @test 23 | * @dataProvider provideSourceDataAndClassNames 24 | */ 25 | public function supportsDenormalizationTests($sourceData, string $className): void 26 | { 27 | $this->assertTrue( 28 | $this->valueObjectNormalizer->supportsDenormalization($sourceData, $className) 29 | ); 30 | } 31 | 32 | /** 33 | * @test 34 | * @dataProvider provideSourceDataAndClassNames 35 | */ 36 | public function denormalizeTests($sourceData, string $className): void 37 | { 38 | $this->assertInstanceOf( 39 | $className, 40 | $this->valueObjectNormalizer->denormalize($sourceData, $className) 41 | ); 42 | } 43 | 44 | public function provideSourceDataAndClassNames() 45 | { 46 | yield 'integer' => [0, Fixture\IntegerBasedValueObject::class]; 47 | yield 'string' => ['', Fixture\StringBasedValueObject::class]; 48 | yield 'boolean' => [true, Fixture\BooleanBasedValueObject::class]; 49 | yield 'float' => [0.0, Fixture\FloatBasedValueObject::class]; 50 | yield 'array' => [[], Fixture\ArrayBasedValueObject::class]; 51 | } 52 | 53 | /** 54 | * @test 55 | */ 56 | public function supportsDenormalizationReturnsFalseForArrayValueObjectsWithoutNamedConstructor(): void 57 | { 58 | self::assertFalse($this->valueObjectNormalizer->supportsDenormalization(['some' => 'array'], Fixture\InvalidArrayBasedValueObject::class)); 59 | } 60 | 61 | /** 62 | * @test 63 | */ 64 | public function denormalizeFailsForArrayValueObjectsWithoutNamedConstructor(): void 65 | { 66 | $this->expectException(\InvalidArgumentException::class); 67 | $this->valueObjectNormalizer->denormalize(['some' => 'array'], Fixture\InvalidArrayBasedValueObject::class); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Classes/EventPublisher/DeferEventPublisher.php: -------------------------------------------------------------------------------- 1 | wrappedEventPublisher = $wrappedEventPublisher; 37 | $this->pendingEvents = DomainEvents::createEmpty(); 38 | } 39 | 40 | public static function forPublisher(EventPublisherInterface $eventPublisher): self 41 | { 42 | return new DeferEventPublisher($eventPublisher); 43 | } 44 | 45 | /** 46 | * @param DomainEvents $events 47 | */ 48 | public function publish(DomainEvents $events): void 49 | { 50 | $this->pendingEvents = $this->pendingEvents->appendEvents($events); 51 | } 52 | 53 | public function getWrappedEventPublisher(): EventPublisherInterface 54 | { 55 | return $this->wrappedEventPublisher; 56 | } 57 | 58 | /** 59 | * Invoke the wrapped Event Publisher with all the pending events (if not empty) 60 | * 61 | * This is usually called when this instance is destructed (during shutdown of Flow for example) 62 | */ 63 | public function invoke(): void 64 | { 65 | if (!$this->pendingEvents->isEmpty()) { 66 | $this->wrappedEventPublisher->publish($this->pendingEvents); 67 | $this->pendingEvents = DomainEvents::createEmpty(); 68 | } 69 | } 70 | 71 | public function shutdownObject(): void 72 | { 73 | $this->invoke(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Classes/EventStore/Storage/Doctrine/Factory/ConnectionFactory.php: -------------------------------------------------------------------------------- 1 | defaultFlowDatabaseConfiguration, $connectionParams); 56 | 57 | $connection = DriverManager::getConnection($connectionParams, $config); 58 | 59 | if (isset($options['mappingTypes']) && \is_array($options['mappingTypes'])) { 60 | foreach ($options['mappingTypes'] as $typeName => $typeConfiguration) { 61 | if (!Type::hasType($typeName)) { 62 | Type::addType($typeName, $typeConfiguration['className']); 63 | } 64 | $connection->getDatabasePlatform()->registerDoctrineTypeMapping($typeConfiguration['dbType'], $typeName); 65 | } 66 | } 67 | 68 | return $connection; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Classes/EventListener/AppliedEventsStorage/AppliedEventsStorageInterface.php: -------------------------------------------------------------------------------- 1 | apply($event); 30 | if ($this->recordedEvents === null) { 31 | $this->recordedEvents = DomainEvents::withSingleEvent($event); 32 | } else { 33 | $this->recordedEvents = $this->recordedEvents->appendEvent($event); 34 | } 35 | } 36 | 37 | final public function pullUncommittedEvents(): DomainEvents 38 | { 39 | if ($this->recordedEvents === null) { 40 | return DomainEvents::createEmpty(); 41 | } 42 | $events = $this->recordedEvents; 43 | $this->recordedEvents = null; 44 | return $events; 45 | } 46 | 47 | final public function getReconstitutionVersion(): int 48 | { 49 | return $this->reconstitutionVersion; 50 | } 51 | 52 | final public static function reconstituteFromEventStream(EventStream $stream): static 53 | { 54 | $instance = new static(); 55 | $lastAppliedEventVersion = -1; 56 | foreach ($stream as $eventEnvelope) { 57 | $instance->apply($eventEnvelope->getDomainEvent()); 58 | $lastAppliedEventVersion = $eventEnvelope->getRawEvent()->getVersion(); 59 | } 60 | $instance->reconstitutionVersion = $lastAppliedEventVersion; 61 | return $instance; 62 | } 63 | 64 | final public function apply(DomainEventInterface $event): void 65 | { 66 | if ($event instanceof DecoratedEvent) { 67 | $event = $event->getWrappedEvent(); 68 | } 69 | try { 70 | $methodName = sprintf('when%s', (new \ReflectionClass($event))->getShortName()); 71 | } catch (\ReflectionException $exception) { 72 | throw new \RuntimeException(sprintf('Could not determine event handler method name for event %s in class %s', \get_class($event), \get_class($this)), 1540745153, $exception); 73 | } 74 | if (method_exists($this, $methodName)) { 75 | $this->$methodName($event); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Classes/EventListener/Mapping/EventToListenerMappings.php: -------------------------------------------------------------------------------- 1 | mappings = $mappings; 33 | } 34 | 35 | /** 36 | * @return static 37 | */ 38 | public static function createEmpty(): self 39 | { 40 | return new static([]); 41 | } 42 | 43 | public function withMapping(EventToListenerMapping $mapping): self 44 | { 45 | $mappings = $this->mappings; 46 | $mappings[] = $mapping; 47 | return new static($mappings); 48 | } 49 | 50 | /** 51 | * @param EventToListenerMapping[] $mappings 52 | * @return static 53 | */ 54 | public static function fromArray(array $mappings): self 55 | { 56 | foreach ($mappings as $mapping) { 57 | if (!$mapping instanceof EventToListenerMapping) { 58 | throw new \InvalidArgumentException(sprintf('Expected array of %s instances, got: %s', EventToListenerMapping::class, \is_object($mapping) ? \get_class($mapping) : \gettype($mapping)), 1578319100); 59 | } 60 | } 61 | return new static(array_values($mappings)); 62 | } 63 | 64 | public function filter(\closure $callback): EventToListenerMappings 65 | { 66 | return new static(array_filter($this->mappings, $callback)); 67 | } 68 | 69 | public function hasMappingForListenerClassName(string $listenerClassName): bool 70 | { 71 | foreach ($this->mappings as $mapping) { 72 | if ($mapping->getListenerClassName() === $listenerClassName) { 73 | return true; 74 | } 75 | } 76 | return false; 77 | } 78 | 79 | /** 80 | * @return EventToListenerMapping[]|\Iterator 81 | */ 82 | public function getIterator(): \Iterator 83 | { 84 | return new \ArrayIterator($this->mappings); 85 | } 86 | 87 | public function jsonSerialize(): array 88 | { 89 | return $this->mappings; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Classes/EventStore/RawEvent.php: -------------------------------------------------------------------------------- 1 | sequenceNumber = $sequenceNumber; 72 | $this->type = $type; 73 | $this->payload = $payload; 74 | $this->metadata = $metadata; 75 | $this->streamName = $streamName; 76 | $this->version = $version; 77 | $this->identifier = $identifier; 78 | $this->recordedAt = $recordedAt; 79 | } 80 | 81 | public function getSequenceNumber(): int 82 | { 83 | return $this->sequenceNumber; 84 | } 85 | 86 | 87 | public function getType(): string 88 | { 89 | return $this->type; 90 | } 91 | 92 | public function getPayload(): array 93 | { 94 | return $this->payload; 95 | } 96 | 97 | public function getMetadata(): array 98 | { 99 | return $this->metadata; 100 | } 101 | 102 | public function getStreamName(): StreamName 103 | { 104 | return $this->streamName; 105 | } 106 | 107 | public function getVersion(): int 108 | { 109 | return $this->version; 110 | } 111 | 112 | public function getIdentifier(): string 113 | { 114 | return $this->identifier; 115 | } 116 | 117 | public function getRecordedAt(): \DateTimeInterface 118 | { 119 | return $this->recordedAt; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Classes/EventListener/AppliedEventsStorage/DefaultAppliedEventsStorage.php: -------------------------------------------------------------------------------- 1 | eventListenerIdentifier = $eventListenerIdentifier; 48 | } 49 | 50 | /** 51 | * Creates an instance for the given EventListenerInterface 52 | * 53 | * @param EventListenerInterface $listener 54 | * @return self 55 | */ 56 | public static function forEventListener(EventListenerInterface $listener): self 57 | { 58 | return new DefaultAppliedEventsStorage(\get_class($listener)); 59 | } 60 | 61 | /** 62 | * @inheritDoc 63 | */ 64 | public function reserveHighestAppliedEventSequenceNumber(): int 65 | { 66 | return $this->doctrineAdapter()->reserveHighestAppliedEventSequenceNumber(); 67 | } 68 | 69 | /** 70 | * @inheritDoc 71 | */ 72 | public function releaseHighestAppliedSequenceNumber(): void 73 | { 74 | $this->doctrineAdapter()->releaseHighestAppliedSequenceNumber(); 75 | } 76 | 77 | /** 78 | * @inheritDoc 79 | */ 80 | public function saveHighestAppliedSequenceNumber(int $sequenceNumber): void 81 | { 82 | $this->doctrineAdapter()->saveHighestAppliedSequenceNumber($sequenceNumber); 83 | } 84 | 85 | /** 86 | * Obtains an instance of the DoctrineAdapter for the bound Event listener 87 | * and initializes it upon first usage 88 | * 89 | * @return DoctrineAppliedEventsStorage 90 | */ 91 | private function doctrineAdapter(): DoctrineAppliedEventsStorage 92 | { 93 | if ($this->_doctrineAdapter === null) { 94 | $this->_doctrineAdapter = new DoctrineAppliedEventsStorage($this->entityManager->getConnection(), $this->eventListenerIdentifier); 95 | } 96 | return $this->_doctrineAdapter; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Tests/Functional/EventStore/EventNormalizerTest.php: -------------------------------------------------------------------------------- 1 | mockEventTypeResolver = $this->getMockBuilder(EventTypeResolverInterface::class)->getMock(); 32 | $this->eventNormalizer = new EventNormalizer($this->mockEventTypeResolver); 33 | } 34 | 35 | /** 36 | * @param string $eventClassName 37 | * @param array $data 38 | * @param object $expectedResult 39 | * @test 40 | * @dataProvider denormalizeDataProvider 41 | * @throws SerializerException 42 | */ 43 | public function denormalizeTests(string $eventClassName, array $data, $expectedResult): void 44 | { 45 | $this->mockEventTypeResolver->method('getEventClassNameByType')->with('someEventType')->willReturn($eventClassName); 46 | $actualResult = $this->eventNormalizer->denormalize($data, 'someEventType'); 47 | 48 | self::assertEquals($expectedResult, $actualResult); 49 | } 50 | 51 | public function denormalizeDataProvider(): \generator 52 | { 53 | yield [MockDomainEvent::class, ['string' => 'Some String'], new MockDomainEvent('Some String')]; 54 | yield [MockDomainEvent2::class, ['string' => 'Some Other String'], new MockDomainEvent2('Some Other String')]; 55 | yield [MockDomainEvent3::class, ['value' => 'Yet another String'], new MockDomainEvent3(new MockValueObject('Yet another String'))]; 56 | } 57 | 58 | /** 59 | * @param DomainEventInterface $event 60 | * @param array $expectedResult 61 | * @test 62 | * @dataProvider normalizeDataProvider 63 | * @throws SerializerException 64 | */ 65 | public function normalizeTests(DomainEventInterface $event, array $expectedResult): void 66 | { 67 | $actualResult = $this->eventNormalizer->normalize($event); 68 | self::assertSame($expectedResult, $actualResult); 69 | } 70 | 71 | public function normalizeDataProvider(): \generator 72 | { 73 | yield [new MockDomainEvent('foo'), ['string' => 'foo']]; 74 | yield [new MockDomainEvent2('bar'), ['string' => 'bar']]; 75 | yield [new MockDomainEvent3(new MockValueObject('baz')), ['value' => 'baz']]; 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /Configuration/Settings.yaml: -------------------------------------------------------------------------------- 1 | Neos: 2 | EventSourcing: 3 | EventStore: 4 | stores: [] 5 | # 'Some.Package:SomeEventStore': 6 | # 7 | # # REQUIRED: Storage implementation that persists events (has to implement EventStorageInterface) 8 | # storage: 'Neos\EventSourcing\EventStore\Storage\Doctrine\DoctrineEventStorage' 9 | # 10 | # # OPTIONAL: options that will be passed to the storage instance 11 | # storageOptions: 12 | # 13 | # When using the DoctrineEventStorage adapter events are stored in a table called "neos_eventsourcing_eventstore_events" by default. This can be changed per Event Store: 14 | # eventTableName: 'some_package_custom_events' 15 | # 16 | # By default the Flow database connection is reused for the EventEvent store backend, but this can be changed per Event Store. Note: BackendOptions will be merged with the Flow default backend options 17 | # backendOptions: 18 | # driver: pdo_mysql 19 | # host: 127.0.0.1 20 | # dbname: null 21 | # user: null 22 | # password: null 23 | # charset: utf8 24 | # 25 | # Custom mapping types can be configured (only useful when using a _different_ database connection for the Event Store) 26 | # mappingTypes: 27 | # 'some_custom_type': 28 | # dbType: 'json_array' 29 | # className: 'Some\Type\Implementation' 30 | # 31 | # # OPTIONAL: implementation for the EventPublisherFactory (needs to implement EventPublisherFactoryInterface). If not set, the DefaultEventPublisherFactory is used 32 | # eventPublisherFactory: 'Some\Class\Implementing\EventPublisherFactoryInterface' 33 | # 34 | # # REQUIRED: Event Listener classes that belong to this Event Store 35 | # # Each key is a class name (supports regular expressions to match multiple). 36 | # # If the value is an array this will be passed as options to the Event Publisher when a corresponding event is being published 37 | # listeners: 38 | # 'Some\Specific\EventListener': true 39 | # 'Some\Package\.*': true 40 | # 'Some\Other\.*': 41 | # # Example: use a custom job queue name (other than "neos-eventsourcing") for these listeners 42 | # queueName: 'custom-queue' 43 | 44 | 45 | # Ignore the default Event Store table ("neos_eventsourcing_eventstore_events") when creating Doctrine migrations 46 | Flow: 47 | persistence: 48 | doctrine: 49 | migrations: 50 | ignoredTables: 51 | 'neos_eventsourcing_eventstore_events': true 52 | 53 | Flowpack: 54 | JobQueue: 55 | Common: 56 | queues: 57 | # By default we use the Fake Queue that doesn't need a background process 58 | # For better performance/scale you should consider using a proper job queue backend 59 | # See https://github.com/Flowpack/jobqueue-common for more details 60 | 'neos-eventsourcing': 61 | className: 'Flowpack\JobQueue\Common\Queue\FakeQueue' 62 | options: 63 | # NOTE: Setting async to FALSE does NOT lead to synchronous projections by default, 64 | # because (by default), the JobQueueEventPublisher is wrapped with DeferEventPublisher. 65 | # See DefaultEventPublisherFactory. 66 | async: true 67 | -------------------------------------------------------------------------------- /Classes/EventStore/Storage/InMemory/InMemoryStreamIterator.php: -------------------------------------------------------------------------------- 1 | innerIterator = new ArrayIterator($eventRecords); 41 | } 42 | 43 | /** 44 | * @return RawEvent 45 | * @throws \JsonException 46 | */ 47 | public function current(): RawEvent 48 | { 49 | $currentEventData = $this->innerIterator->current(); 50 | $payload = json_decode($currentEventData['payload'], true, 512, JSON_THROW_ON_ERROR); 51 | $metadata = json_decode($currentEventData['metadata'], true, 512, JSON_THROW_ON_ERROR); 52 | try { 53 | $recordedAt = new \DateTimeImmutable($currentEventData['recordedat']); 54 | } catch (\Exception $exception) { 55 | throw new \RuntimeException(sprintf('Could not parse recordedat timestamp "%s" as date.', $currentEventData['recordedat']), 1597843669, $exception); 56 | } 57 | return new RawEvent( 58 | (int)$currentEventData['sequencenumber'], 59 | $currentEventData['type'], 60 | $payload, 61 | $metadata, 62 | StreamName::fromString($currentEventData['stream']), 63 | (int)$currentEventData['version'], 64 | $currentEventData['id'], 65 | $recordedAt 66 | ); 67 | } 68 | 69 | /** 70 | * @return void 71 | */ 72 | public function next(): void 73 | { 74 | $this->currentOffset = $this->innerIterator->current()['sequencenumber']; 75 | $this->innerIterator->next(); 76 | } 77 | 78 | /** 79 | * @return bool|int|null 80 | */ 81 | public function key(): bool|int|null 82 | { 83 | return $this->innerIterator->valid() ? $this->innerIterator->current()['sequencenumber'] : null; 84 | } 85 | 86 | /** 87 | * @return bool 88 | */ 89 | public function valid(): bool 90 | { 91 | return $this->innerIterator->valid(); 92 | } 93 | 94 | /** 95 | * @return void 96 | */ 97 | public function rewind(): void 98 | { 99 | if ($this->currentOffset === 0) { 100 | return; 101 | } 102 | $this->innerIterator->rewind(); 103 | $this->currentOffset = 0; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Classes/EventPublisher/JobQueue/CatchUpEventListenerJob.php: -------------------------------------------------------------------------------- 1 | listenerClassName = $listenerClassName; 54 | $this->eventStoreIdentifier = $eventStoreIdentifier; 55 | } 56 | 57 | public function getListenerClassName(): string 58 | { 59 | return $this->listenerClassName; 60 | } 61 | 62 | public function getEventStoreIdentifier(): string 63 | { 64 | return $this->eventStoreIdentifier; 65 | } 66 | 67 | /** 68 | * This is the Asynchronicity Boundary of the EventSourcing package. This means: 69 | * 70 | * - This object is created in the main process (usually a web request calling $eventStore->commit($events) 71 | * - The execute method (this method) is called ASYNCHRONOUSLY in an extra PHP process (via the Flowpack.JobQueue abstraction) 72 | * 73 | * @param QueueInterface $queue 74 | * @param Message $message 75 | * @return bool 76 | * @throws EventCouldNotBeAppliedException 77 | */ 78 | public function execute(QueueInterface $queue, Message $message): bool 79 | { 80 | /** @var EventListenerInterface $listener */ 81 | $listener = $this->objectManager->get($this->listenerClassName); 82 | 83 | /** @var EventStoreFactory $eventStoreFactory */ 84 | $eventStoreFactory = $this->objectManager->get(EventStoreFactory::class); 85 | $eventStore = $eventStoreFactory->create($this->eventStoreIdentifier); 86 | 87 | $eventListenerInvoker = new EventListenerInvoker($eventStore, $listener, $this->entityManager->getConnection()); 88 | $eventListenerInvoker->catchUp(); 89 | return true; 90 | } 91 | 92 | public function getLabel(): string 93 | { 94 | return sprintf('Catch up event listener "%s" from store "%s"', $this->listenerClassName, $this->eventStoreIdentifier); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Classes/EventStore/EventNormalizer.php: -------------------------------------------------------------------------------- 1 | eventTypeResolver = $eventTypeResolver; 50 | 51 | // TODO: make normalizers configurable 52 | $normalizers = [ 53 | new BackedEnumNormalizer(), 54 | new DateTimeNormalizer(), 55 | new JsonSerializableNormalizer(), 56 | new ValueObjectNormalizer(), 57 | new ProxyAwareObjectNormalizer() 58 | ]; 59 | $this->serializer = new Serializer($normalizers); 60 | } 61 | 62 | /** 63 | * @param DomainEventInterface $event 64 | * @return array 65 | * @throws SerializerException 66 | */ 67 | public function normalize(DomainEventInterface $event): array 68 | { 69 | return $this->serializer->normalize($event); 70 | } 71 | 72 | /** 73 | * @param array $eventData 74 | * @param string $eventType 75 | * @return DomainEventInterface 76 | * @throws SerializerException 77 | */ 78 | public function denormalize(array $eventData, string $eventType): DomainEventInterface 79 | { 80 | // TODO allow to hook into event type => class conversion in order to enable upcasting, ... 81 | $eventClassName = $this->eventTypeResolver->getEventClassNameByType($eventType); 82 | /** @var DomainEventInterface $event */ 83 | $event = $this->serializer->denormalize($eventData, $eventClassName); 84 | return $event; 85 | } 86 | 87 | /** 88 | * Return the event type for the given Event object 89 | * 90 | * @param DomainEventInterface $event An Domain Event instance 91 | * @return string The corresponding Event Type, for example "Some.Package:SomeEvent" 92 | */ 93 | public function getEventType(DomainEventInterface $event): string 94 | { 95 | return $this->eventTypeResolver->getEventType($event); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Classes/Event/DomainEvents.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | final class DomainEvents implements \IteratorAggregate, \Countable 24 | { 25 | /** 26 | * @var array 27 | */ 28 | private array $events; 29 | 30 | /** 31 | * @var \ArrayIterator 32 | */ 33 | private \ArrayIterator $iterator; 34 | 35 | /** 36 | * @param array $events 37 | */ 38 | private function __construct(array $events) 39 | { 40 | $this->events = $events; 41 | $this->iterator = new \ArrayIterator($events); 42 | } 43 | 44 | public static function createEmpty(): self 45 | { 46 | return new self([]); 47 | } 48 | 49 | /** 50 | * @param array $events 51 | */ 52 | public static function fromArray(array $events): self 53 | { 54 | foreach ($events as $event) { 55 | if (!$event instanceof DomainEventInterface) { 56 | throw new \InvalidArgumentException(sprintf('Only instances of EventInterface are allowed, given: %s', \is_object($event) ? \get_class($event) : \gettype($event)), 1540311882); 57 | } 58 | } 59 | return new self(array_values($events)); 60 | } 61 | 62 | public static function withSingleEvent(DomainEventInterface $event): self 63 | { 64 | return new self([$event]); 65 | } 66 | 67 | public function appendEvent(DomainEventInterface $event): self 68 | { 69 | $events = $this->events; 70 | $events[] = $event; 71 | 72 | return new self($events); 73 | } 74 | 75 | public function appendEvents(self $other): self 76 | { 77 | $events = array_merge($this->events, $other->events); 78 | 79 | return new self($events); 80 | } 81 | 82 | public function getFirst(): DomainEventInterface 83 | { 84 | if ($this->isEmpty()) { 85 | throw new \RuntimeException('Cant\'t return first event of an empty DomainEvents', 1540909869); 86 | } 87 | 88 | return $this->events[0]; 89 | } 90 | 91 | /** 92 | * @return DomainEventInterface[]|\ArrayIterator 93 | */ 94 | public function getIterator(): \ArrayIterator 95 | { 96 | return $this->iterator; 97 | } 98 | 99 | public function map(\Closure $processor): self 100 | { 101 | $convertedEvents = array_map($processor, $this->events); 102 | return self::fromArray($convertedEvents); 103 | } 104 | 105 | public function filter(\Closure $expression): self 106 | { 107 | $filteredEvents = array_filter($this->events, $expression); 108 | return self::fromArray($filteredEvents); 109 | } 110 | 111 | public function isEmpty(): bool 112 | { 113 | return $this->events === []; 114 | } 115 | 116 | public function count(): int 117 | { 118 | return \count($this->events); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Tests/Functional/EventListener/AppliedEventsStorage/DoctrineAdapterTest.php: -------------------------------------------------------------------------------- 1 | objectManager->get(EntityManagerInterface::class); 32 | $dbal1 = $entityManager->getConnection(); 33 | 34 | $platform = $dbal1->getDatabasePlatform()->getName(); 35 | if ($platform !== 'mysql' && $platform !== 'postgresql') { 36 | self::markTestSkipped(sprintf('DB platform "%s" is not supported', $platform)); 37 | } 38 | 39 | $this->adapter1 = new DoctrineAppliedEventsStorage($dbal1, 'someEventListenerIdentifier'); 40 | 41 | $dbal2 = DriverManager::getConnection($dbal1->getParams(), new Configuration()); 42 | $this->adapter2 = new DoctrineAppliedEventsStorage($dbal2, 'someEventListenerIdentifier'); 43 | } 44 | 45 | public function tearDown(): void 46 | { 47 | $this->adapter1->releaseHighestAppliedSequenceNumber(); 48 | $this->adapter2->releaseHighestAppliedSequenceNumber(); 49 | parent::tearDown(); 50 | } 51 | 52 | /** 53 | * @test 54 | */ 55 | public function reserveHighestAppliedEventSequenceNumberFailsIfSequenceNumberIsReserved(): void 56 | { 57 | $this->adapter1->reserveHighestAppliedEventSequenceNumber(); 58 | 59 | $this->expectException(HighestAppliedSequenceNumberCantBeReservedException::class); 60 | $this->expectExceptionCode(1523456892); 61 | $this->adapter2->reserveHighestAppliedEventSequenceNumber(); 62 | } 63 | 64 | /** 65 | * @test 66 | */ 67 | public function reserveHighestAppliedEventSequenceNumberFailsWithin3Seconds(): void 68 | { 69 | $this->adapter1->reserveHighestAppliedEventSequenceNumber(); 70 | 71 | $startTime = microtime(true); 72 | $timeDelta = null; 73 | try { 74 | $this->adapter2->reserveHighestAppliedEventSequenceNumber(); 75 | } catch (HighestAppliedSequenceNumberCantBeReservedException $exception) { 76 | $timeDelta = microtime(true) - $startTime; 77 | } 78 | if ($timeDelta === null) { 79 | self::fail('HighestAppliedSequenceNumberCantBeReservedException was not thrown!'); 80 | } 81 | self::assertLessThan(3, $timeDelta); 82 | } 83 | 84 | /** 85 | * @test 86 | */ 87 | public function saveHighestAppliedSequenceNumberAllowsToSetSequenceNumber(): void 88 | { 89 | $this->adapter1->reserveHighestAppliedEventSequenceNumber(); 90 | $this->adapter1->saveHighestAppliedSequenceNumber(42); 91 | $this->adapter1->releaseHighestAppliedSequenceNumber(); 92 | 93 | self::assertSame(42, $this->adapter2->reserveHighestAppliedEventSequenceNumber()); 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /Classes/Event/DecoratedEvent.php: -------------------------------------------------------------------------------- 1 | event = $event; 43 | $this->metadata = $metadata; 44 | $this->identifier = $identifier; 45 | } 46 | 47 | public static function addMetadata(DomainEventInterface $event, array $metadata): self 48 | { 49 | $identifier = null; 50 | if ($event instanceof self) { 51 | $metadata = Arrays::arrayMergeRecursiveOverrule($event->metadata, $metadata); 52 | $identifier = $event->identifier; 53 | $event = $event->getWrappedEvent(); 54 | } 55 | return new self($event, $metadata, $identifier); 56 | } 57 | 58 | public static function addCausationIdentifier(DomainEventInterface $event, string $causationIdentifier): self 59 | { 60 | self::validateIdentifier($causationIdentifier); 61 | return self::addMetadata($event, ['causationIdentifier' => $causationIdentifier]); 62 | } 63 | 64 | public static function addCorrelationIdentifier(DomainEventInterface $event, string $correlationIdentifier): self 65 | { 66 | self::validateIdentifier($correlationIdentifier); 67 | return self::addMetadata($event, ['correlationIdentifier' => $correlationIdentifier]); 68 | } 69 | 70 | public static function addIdentifier(DomainEventInterface $event, string $identifier): self 71 | { 72 | self::validateIdentifier($identifier); 73 | $metadata = []; 74 | if ($event instanceof self) { 75 | $metadata = $event->metadata; 76 | $event = $event->getWrappedEvent(); 77 | } 78 | return new self($event, $metadata, $identifier); 79 | } 80 | 81 | public function getWrappedEvent(): DomainEventInterface 82 | { 83 | return $this->event; 84 | } 85 | 86 | public function getMetadata(): array 87 | { 88 | return $this->metadata; 89 | } 90 | 91 | public function hasIdentifier(): bool 92 | { 93 | return $this->identifier !== null; 94 | } 95 | 96 | public function getIdentifier(): ?string 97 | { 98 | return $this->identifier; 99 | } 100 | 101 | private static function validateIdentifier(string $identifier): void 102 | { 103 | if ($identifier === '') { 104 | throw new \InvalidArgumentException('Empty identifier provided', 1509109037); 105 | } 106 | if (\strlen($identifier) > 255) { 107 | throw new \InvalidArgumentException('Identifier must be 255 characters or less', 1509109039); 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Tests/Unit/EventStore/Storage/InMemory/InMemoryStreamIteratorTest.php: -------------------------------------------------------------------------------- 1 | iterator = new InMemoryStreamIterator([ 19 | [ 20 | 'sequencenumber' => 1, 21 | 'type' => 'FooEventType', 22 | 'payload' => json_encode(['foo' => 'bar'], JSON_THROW_ON_ERROR, 512), 23 | 'metadata' => json_encode([], JSON_THROW_ON_ERROR, 512), 24 | 'recordedat' => '2020-08-17', 25 | 'stream' => 'FooStreamName', 26 | 'version' => 1, 27 | 'id' => Uuid::uuid4()->toString() 28 | ], 29 | [ 30 | 'sequencenumber' => 2, 31 | 'type' => 'FooEventType', 32 | 'payload' => json_encode(['foo' => 'bar'], JSON_THROW_ON_ERROR, 512), 33 | 'metadata' => json_encode([], JSON_THROW_ON_ERROR, 512), 34 | 'recordedat' => '2020-08-17', 35 | 'stream' => 'FooStreamName', 36 | 'version' => 2, 37 | 'id' => Uuid::uuid4()->toString() 38 | ] 39 | ]); 40 | } 41 | 42 | /** 43 | * @test 44 | */ 45 | public function setEventRecordsRejectsInvalidDate(): void 46 | { 47 | $iterator = new InMemoryStreamIterator([ 48 | [ 49 | 'sequencenumber' => 1, 50 | 'type' => 'FooEventType', 51 | 'payload' => json_encode(['foo' => 'bar'], JSON_THROW_ON_ERROR, 512), 52 | 'metadata' => json_encode([], JSON_THROW_ON_ERROR, 512), 53 | 'recordedat' => 'invalid-date', 54 | 'stream' => 'FooStreamName', 55 | 'version' => 1, 56 | 'id' => Uuid::uuid4()->toString() 57 | ] 58 | ]); 59 | 60 | $this->expectExceptionCode(1597843669); 61 | $iterator->current(); 62 | } 63 | 64 | /** 65 | * @test 66 | */ 67 | public function canSetEventRecordsAndGetRawEvents(): void 68 | { 69 | $rawEvent = $this->iterator->current(); 70 | $this->assertSame($rawEvent->getSequenceNumber(), 1); 71 | $this->assertSame($rawEvent->getType(), 'FooEventType'); 72 | $this->assertSame($rawEvent->getRecordedAt()->format('Y-m-d'), '2020-08-17'); 73 | $this->assertSame((string)$rawEvent->getStreamName(), 'FooStreamName'); 74 | } 75 | 76 | /** 77 | * @test 78 | */ 79 | public function providesIteratorFunctions(): void 80 | { 81 | $this->assertSame($this->iterator->key(), 1); 82 | 83 | $this->iterator->next(); 84 | $this->assertSame($this->iterator->key(), 2); 85 | $this->assertSame($this->iterator->current()->getSequenceNumber(), 2); 86 | 87 | $this->assertTrue($this->iterator->valid()); 88 | $this->iterator->next(); 89 | $this->assertFalse($this->iterator->valid()); 90 | 91 | $this->iterator->rewind(); 92 | $this->assertTrue($this->iterator->valid()); 93 | $this->assertSame($this->iterator->key(), 1); 94 | 95 | $this->iterator->rewind(); 96 | $this->assertTrue($this->iterator->valid()); 97 | $this->assertSame($this->iterator->key(), 1); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Classes/EventStore/StreamName.php: -------------------------------------------------------------------------------- 1 | :-" 9 | * 10 | * @Flow\Proxy(false) 11 | */ 12 | final class StreamName 13 | { 14 | /** 15 | * @var string 16 | */ 17 | private $value; 18 | 19 | /** 20 | * @var self[] 21 | */ 22 | private static $instances = []; 23 | 24 | private function __construct(string $value) 25 | { 26 | $this->value = $value; 27 | } 28 | 29 | private static function constant(string $value): self 30 | { 31 | return self::$instances[$value] ?? self::$instances[$value] = new self($value); 32 | } 33 | 34 | public static function fromString(string $value): self 35 | { 36 | $value = self::trimAndValidateNotEmpty($value); 37 | if (self::stringStartsWith($value, '$')) { 38 | throw new \InvalidArgumentException('The stream name must not start with "$"', 1540632865); 39 | } 40 | return self::constant($value); 41 | } 42 | 43 | public static function forCategory(string $categoryName): self 44 | { 45 | $categoryName = self::trimAndValidateNotEmpty($categoryName); 46 | if (self::stringStartsWith($categoryName, '$')) { 47 | throw new \InvalidArgumentException('The category name must not start with "$"', 1540632884); 48 | } 49 | return self::constant('$ce-' . $categoryName); 50 | } 51 | 52 | public static function forCorrelationId(string $correlationId): self 53 | { 54 | $correlationId = self::trimAndValidateNotEmpty($correlationId); 55 | if (self::stringStartsWith($correlationId, '$')) { 56 | throw new \InvalidArgumentException('The correlation identifier must not start with "$"', 1540899066); 57 | } 58 | return self::constant('$correlation-' . $correlationId); 59 | } 60 | 61 | public static function all(): self 62 | { 63 | return self::constant('$all'); 64 | } 65 | 66 | public function isVirtualStream(): bool 67 | { 68 | return self::stringStartsWith($this->value, '$'); 69 | } 70 | 71 | public function isAllStream(): bool 72 | { 73 | return $this->value === '$all'; 74 | } 75 | 76 | public function isCategoryStream(): bool 77 | { 78 | return self::stringStartsWith($this->value, '$ce-'); 79 | } 80 | 81 | public function isCorrelationIdStream(): bool 82 | { 83 | return self::stringStartsWith($this->value, '$correlation-'); 84 | } 85 | 86 | public function getCategoryName(): string 87 | { 88 | if (!$this->isCategoryStream()) { 89 | throw new \RuntimeException(sprintf('Stream "%s" is no category stream', $this->value), 1540633414); 90 | } 91 | return substr($this->value, 4); 92 | } 93 | 94 | public function getCorrelationId(): string 95 | { 96 | if (!$this->isCorrelationIdStream()) { 97 | throw new \RuntimeException(sprintf('Stream "%s" is no correlation id stream', $this->value), 1569398802); 98 | } 99 | return substr($this->value, 13); 100 | } 101 | 102 | private static function trimAndValidateNotEmpty(string $value): string 103 | { 104 | $value = trim($value); 105 | if ($value === '') { 106 | throw new \InvalidArgumentException('The value must not be empty', 1540311275); 107 | } 108 | return $value; 109 | } 110 | 111 | private static function stringStartsWith(string $string, string $search): bool 112 | { 113 | return strncmp($string, $search, \strlen($search)) === 0; 114 | } 115 | 116 | public function __toString(): string 117 | { 118 | return $this->value; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Classes/EventPublisher/JobQueueEventPublisher.php: -------------------------------------------------------------------------------- 1 | eventStoreIdentifier = $eventStoreIdentifier; 73 | $this->mappings = $mappings; 74 | } 75 | 76 | /** 77 | * Iterate through EventToListenerMappings and queue a CatchUpEventListenerJob for every affected Event Listener 78 | * 79 | * @param DomainEvents $events 80 | */ 81 | public function publish(DomainEvents $events): void 82 | { 83 | $queuedEventListenerClassNames = []; 84 | $processedEventClassNames = []; 85 | foreach ($events as $event) { 86 | /** @var string $eventClassName */ 87 | $eventClassName = \get_class($event instanceof DecoratedEvent ? $event->getWrappedEvent() : $event); 88 | // only process every Event type once 89 | if (isset($processedEventClassNames[$eventClassName])) { 90 | continue; 91 | } 92 | $processedEventClassNames[$eventClassName] = true; 93 | foreach ($this->mappings as $mapping) { 94 | if ($mapping->getEventClassName() !== $eventClassName) { 95 | continue; 96 | } 97 | // only process every Event Listener once 98 | if (isset($queuedEventListenerClassNames[$mapping->getListenerClassName()])) { 99 | continue; 100 | } 101 | $queueName = $mapping->getOption('queueName', self::DEFAULT_QUEUE_NAME); 102 | $options = $mapping->getOption('queueOptions', []); 103 | $this->jobManager->queue($queueName, new CatchUpEventListenerJob($mapping->getListenerClassName(), $this->eventStoreIdentifier), $options); 104 | $queuedEventListenerClassNames[$mapping->getListenerClassName()] = true; 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Classes/EventStore/Storage/Doctrine/DoctrineStreamIterator.php: -------------------------------------------------------------------------------- 1 | queryBuilder = clone $queryBuilder; 54 | $this->queryBuilder->setMaxResults(self::BATCH_SIZE); 55 | $this->queryBuilder->andWhere('sequencenumber > :sequenceNumberOffset'); 56 | $this->fetchBatch(); 57 | } 58 | 59 | /** 60 | * @return RawEvent 61 | */ 62 | public function current(): RawEvent 63 | { 64 | $currentEventData = $this->innerIterator->current(); 65 | $payload = json_decode($currentEventData['payload'], true); 66 | $metadata = json_decode($currentEventData['metadata'], true); 67 | try { 68 | $recordedAt = new \DateTimeImmutable($currentEventData['recordedat']); 69 | } catch (\Exception $exception) { 70 | throw new \RuntimeException(sprintf('Could not parse recordedat timestamp "%s" as date.', $currentEventData['recordedat']), 1544211618, $exception); 71 | } 72 | return new RawEvent( 73 | (int)$currentEventData['sequencenumber'], 74 | $currentEventData['type'], 75 | $payload, 76 | $metadata, 77 | StreamName::fromString($currentEventData['stream']), 78 | (int)$currentEventData['version'], 79 | $currentEventData['id'], 80 | $recordedAt 81 | ); 82 | } 83 | 84 | /** 85 | * @return void 86 | */ 87 | public function next(): void 88 | { 89 | $this->currentOffset = $this->innerIterator->current()['sequencenumber']; 90 | $this->innerIterator->next(); 91 | if ($this->innerIterator->valid()) { 92 | return; 93 | } 94 | $this->fetchBatch(); 95 | } 96 | 97 | /** 98 | * @return bool|int 99 | */ 100 | public function key(): mixed 101 | { 102 | return $this->innerIterator->valid() ? $this->innerIterator->current()['sequencenumber'] : null; 103 | } 104 | 105 | /** 106 | * @return bool 107 | */ 108 | public function valid(): bool 109 | { 110 | return $this->innerIterator->valid(); 111 | } 112 | 113 | /** 114 | * @return void 115 | */ 116 | public function rewind(): void 117 | { 118 | if ($this->currentOffset === 0) { 119 | return; 120 | } 121 | $this->currentOffset = 0; 122 | $this->fetchBatch(); 123 | } 124 | 125 | /** 126 | * Fetches a batch of maximum BATCH_SIZE records 127 | * 128 | * @return void 129 | */ 130 | private function fetchBatch(): void 131 | { 132 | // we deliberately don't use "setFirstResult" here, as this translates to an OFFSET query. For resolving 133 | // an OFFSET query, the DB needs to scan the result-set from the beginning (which is slow as hell). 134 | $this->queryBuilder->setParameter('sequenceNumberOffset', $this->currentOffset); 135 | $this->reconnectDatabaseConnection(); 136 | $rawResult = $this->queryBuilder->execute()->fetchAll(); 137 | $this->innerIterator = new \ArrayIterator($rawResult); 138 | } 139 | 140 | /** 141 | * Reconnects the database connection associated with this storage, if it doesn't respond to a ping 142 | * 143 | * @see \Neos\Flow\Persistence\Doctrine\PersistenceManager::persistAll() 144 | * @return void 145 | */ 146 | private function reconnectDatabaseConnection(): void 147 | { 148 | try { 149 | $this->queryBuilder->getConnection()->fetchOne('SELECT 1'); 150 | } catch (\Exception $e) { 151 | $this->queryBuilder->getConnection()->close(); 152 | $this->queryBuilder->getConnection()->connect(); 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /Classes/EventStore/EventStore.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 55 | $this->eventPublisher = $eventPublisher; 56 | $this->eventNormalizer = $eventNormalizer; 57 | } 58 | 59 | public function load(StreamName $streamName, int $minimumSequenceNumber = 0): EventStream 60 | { 61 | try { 62 | return $this->storage->load($streamName, $minimumSequenceNumber); 63 | } catch (\Throwable $exception) { 64 | throw new \RuntimeException(sprintf('Failed to read Events from stream "%s". Did you run the ./flow eventstore:setup command?', $streamName), 1592394137, $exception); 65 | } 66 | } 67 | 68 | /** 69 | * @param StreamName $streamName 70 | * @param DomainEvents $events 71 | * @param int $expectedVersion 72 | * @throws ConcurrencyException | SerializerException 73 | */ 74 | public function commit(StreamName $streamName, DomainEvents $events, int $expectedVersion = ExpectedVersion::ANY): void 75 | { 76 | if ($events->isEmpty()) { 77 | return; 78 | } 79 | $convertedEvents = []; 80 | foreach ($events as $event) { 81 | $eventIdentifier = null; 82 | $metadata = []; 83 | if ($event instanceof DecoratedEvent) { 84 | $eventIdentifier = $event->hasIdentifier() ? $event->getIdentifier() : null; 85 | $metadata = $event->getMetadata(); 86 | $event = $event->getWrappedEvent(); 87 | } 88 | $type = $this->eventNormalizer->getEventType($event); 89 | $data = $this->eventNormalizer->normalize($event); 90 | 91 | if ($eventIdentifier === null) { 92 | try { 93 | $eventIdentifier = (string)Uuid::uuid4(); 94 | } catch (\Exception $exception) { 95 | throw new \RuntimeException('Failed to generate UUID for event', 1576421966, $exception); 96 | } 97 | } 98 | $convertedEvents[] = new WritableEvent($eventIdentifier, $type, $data, $metadata); 99 | } 100 | 101 | $committedEvents = WritableEvents::fromArray($convertedEvents); 102 | try { 103 | $this->storage->commit($streamName, $committedEvents, $expectedVersion); 104 | } catch (ConcurrencyException $exception) { 105 | // a concurrency exception can happen if the event stream has been modified in the meantime 106 | throw $exception; 107 | } catch (\Throwable $exception) { 108 | throw new \RuntimeException(sprintf('Failed to commit Events to stream "%s". Did you run the ./flow eventstore:setup command?', $streamName), 1592393752, $exception); 109 | } 110 | $this->eventPublisher->publish($events); 111 | } 112 | 113 | /** 114 | * Returns the (connection) status of this Event Store, @see EventStorageInterface::getStatus() 115 | * 116 | * @return Result 117 | */ 118 | public function getStatus(): Result 119 | { 120 | return $this->storage->getStatus(); 121 | } 122 | 123 | /** 124 | * Sets up this Event Store and returns a status, @see EventStorageInterface::setup() 125 | * 126 | * @return Result 127 | */ 128 | public function setup(): Result 129 | { 130 | return $this->storage->setup(); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Classes/Event/EventTypeResolver.php: -------------------------------------------------------------------------------- 1 | :) 23 | * 24 | * @Flow\Scope("singleton") 25 | */ 26 | final class EventTypeResolver implements EventTypeResolverInterface 27 | { 28 | /** 29 | * @var ObjectManagerInterface 30 | */ 31 | protected $objectManager; 32 | 33 | /** 34 | * @var array in the format ['' => '', ...] 35 | */ 36 | protected $mapping = []; 37 | 38 | /** 39 | * @var array 40 | */ 41 | protected $reversedMapping = []; 42 | 43 | /** 44 | * Injecting via setter injection because this resolver must also work during compile time, when proxy classes are 45 | * not available. 46 | * 47 | * @param ObjectManagerInterface $objectManager 48 | */ 49 | public function injectObjectManager(ObjectManagerInterface $objectManager): void 50 | { 51 | $this->objectManager = $objectManager; 52 | } 53 | 54 | /** 55 | * Register event listeners based on annotations 56 | */ 57 | public function initializeObject(): void 58 | { 59 | $this->mapping = self::eventTypeMapping($this->objectManager); 60 | $this->reversedMapping = array_flip($this->mapping); 61 | } 62 | 63 | /** 64 | * Return the event type for the given Event object 65 | * 66 | * @param DomainEventInterface $event 67 | * @return string for example "Acme.MyApplication:SomethingImportantHasHappened" 68 | */ 69 | public function getEventType(DomainEventInterface $event): string 70 | { 71 | $className = TypeHandling::getTypeForValue($event); 72 | return $this->getEventTypeByClassName($className); 73 | } 74 | 75 | /** 76 | * Return the event type for the given Event classname 77 | * 78 | * @param string $className 79 | * @return string for example "Acme.MyApplication:SomethingImportantHasHappened" 80 | */ 81 | private function getEventTypeByClassName(string $className): string 82 | { 83 | if (!isset($this->mapping[$className])) { 84 | throw new \InvalidArgumentException(sprintf('Event Type not found for class name "%s"', $className), 1476249954); 85 | } 86 | return $this->mapping[$className]; 87 | } 88 | 89 | /** 90 | * Return the event classname for the given event type 91 | * 92 | * @param string $eventType 93 | * @return string for example "Acme\MyApplication\Some\Namespace\SomethingImportantHasHappened" 94 | */ 95 | public function getEventClassNameByType(string $eventType): string 96 | { 97 | if (!isset($this->reversedMapping[$eventType])) { 98 | throw new \InvalidArgumentException(sprintf('Event class not found for event type "%s"', $eventType), 1568734531); 99 | } 100 | return $this->reversedMapping[$eventType]; 101 | } 102 | 103 | /** 104 | * Create mapping between Event class name and Event type 105 | * 106 | * @param ObjectManagerInterface $objectManager 107 | * @return array 108 | * @Flow\CompileStatic 109 | */ 110 | public static function eventTypeMapping(ObjectManagerInterface $objectManager): array 111 | { 112 | $buildEventType = static function ($eventClassName) use ($objectManager) { 113 | $packageKey = $objectManager->getPackageKeyByObjectName($eventClassName); 114 | if ($packageKey === false) { 115 | throw new \RuntimeException(sprintf('Could not determine package key from object name "%s"', $eventClassName), 1478088597); 116 | } 117 | $shortEventClassName = (new \ReflectionClass($eventClassName))->getShortName(); 118 | return $packageKey . ':' . $shortEventClassName; 119 | }; 120 | $mapping = []; 121 | /** @var ReflectionService $reflectionService */ 122 | $reflectionService = $objectManager->get(ReflectionService::class); 123 | /** @var string $eventClassName */ 124 | foreach ($reflectionService->getAllImplementationClassNamesForInterface(DomainEventInterface::class) as $eventClassName) { 125 | if (is_subclass_of($eventClassName, ProvidesEventTypeInterface::class)) { 126 | /** @noinspection PhpUndefinedMethodInspection */ 127 | $eventTypeIdentifier = $eventClassName::getEventType(); 128 | } else { 129 | $eventTypeIdentifier = $buildEventType($eventClassName); 130 | } 131 | if (\in_array($eventTypeIdentifier, $mapping, true)) { 132 | throw new \RuntimeException(sprintf('Duplicate event type "%s" mapped from "%s".', $eventTypeIdentifier, $eventClassName), 1474710799); 133 | } 134 | $mapping[$eventClassName] = $eventTypeIdentifier; 135 | } 136 | return $mapping; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Classes/EventStore/EventStoreFactory.php: -------------------------------------------------------------------------------- 1 | objectManager = $objectManager; 64 | $this->configuration = $configuration; 65 | $this->eventNormalizer = $eventNormalizer; 66 | } 67 | 68 | /** 69 | * Retrieves/builds an EventStore instance with the given identifier 70 | * 71 | * @param string $eventStoreIdentifier The unique Event Store identifier as configured 72 | * @return EventStore 73 | * @throws \RuntimeException|StorageConfigurationException 74 | */ 75 | public function create(string $eventStoreIdentifier): EventStore 76 | { 77 | if (isset($this->initializedEventStores[$eventStoreIdentifier])) { 78 | return $this->initializedEventStores[$eventStoreIdentifier]; 79 | } 80 | if (!isset($this->configuration[$eventStoreIdentifier])) { 81 | throw new \InvalidArgumentException(sprintf('No Event Store with the identifier "%s" is configured', $eventStoreIdentifier), 1492610857); 82 | } 83 | if (!isset($this->configuration[$eventStoreIdentifier]['storage'])) { 84 | throw new StorageConfigurationException(sprintf('There is no Storage configured for Event Store "%s"', $eventStoreIdentifier), 1492610902); 85 | } 86 | $storageClassName = $this->configuration[$eventStoreIdentifier]['storage']; 87 | $storageOptions = $this->configuration[$eventStoreIdentifier]['storageOptions'] ?? []; 88 | 89 | try { 90 | /** @noinspection PhpMethodParametersCountMismatchInspection */ 91 | $storageInstance = $this->objectManager->get($storageClassName, $storageOptions); 92 | } /** @noinspection PhpRedundantCatchClauseInspection */ catch (UnknownObjectException $exception) { 93 | throw new StorageConfigurationException(sprintf('The configured Storage "%s" for Event Store "%s" is unknown', $storageClassName, $eventStoreIdentifier), 1570194203, $exception); 94 | } 95 | if (!$storageInstance instanceof EventStorageInterface) { 96 | throw new StorageConfigurationException(sprintf('The configured Storage "%s" for Event Store "%s" does not implement the %s', $storageClassName, $eventStoreIdentifier, EventStorageInterface::class), 1492610908); 97 | } 98 | 99 | if (isset($this->configuration[$eventStoreIdentifier]['eventPublisherFactory'])) { 100 | $eventPublisherFactoryClassName = $this->configuration[$eventStoreIdentifier]['eventPublisherFactory']; 101 | try { 102 | $eventPublisherFactory = $this->objectManager->get($eventPublisherFactoryClassName); 103 | } /** @noinspection PhpRedundantCatchClauseInspection */ catch (UnknownObjectException $exception) { 104 | throw new StorageConfigurationException(sprintf('The configured eventPublisherFactory "%s" for Event Store "%s" is unknown', $eventPublisherFactoryClassName, $eventStoreIdentifier), 1579098129, $exception); 105 | } 106 | if (!$eventPublisherFactory instanceof EventPublisherFactoryInterface) { 107 | throw new StorageConfigurationException(sprintf('The configured eventPublisherFactory "%s" for Event Store "%s" does not implement the %s', $eventPublisherFactoryClassName, $eventStoreIdentifier, EventPublisherFactoryInterface::class), 1579101180); 108 | } 109 | } else { 110 | $eventPublisherFactory = $this->objectManager->get(DefaultEventPublisherFactory::class); 111 | } 112 | 113 | $eventPublisher = $eventPublisherFactory->create($eventStoreIdentifier); 114 | $this->initializedEventStores[$eventStoreIdentifier] = new EventStore($storageInstance, $eventPublisher, $this->eventNormalizer); 115 | return $this->initializedEventStores[$eventStoreIdentifier]; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Classes/Command/EventStoreCommandController.php: -------------------------------------------------------------------------------- 1 | getAllEventStores(); 57 | if (!isset($eventStores[$eventStore])) { 58 | $this->outputLine('There is no Event Store "%s" configured', [$eventStore]); 59 | $this->quit(1); 60 | } 61 | $this->outputLine('Setting up Event Store "%s"', [$eventStore]); 62 | $result = $eventStores[$eventStore]->setup(); 63 | $this->renderResult($result); 64 | } 65 | 66 | /** 67 | * Sets up all configured Event Store backends 68 | * 69 | * This command initializes all configured Event Store adapters (i.e. creates required tables for database 70 | * driven storages and/or validates the configuration against the actual backends) 71 | * 72 | * @return void 73 | */ 74 | public function setupAllCommand(): void 75 | { 76 | $eventStores = $this->getAllEventStores(); 77 | $this->outputLine('Setting up %d Event Store backend(s):', [\count($eventStores)]); 78 | foreach ($eventStores as $eventStoreIdentifier => $eventStore) { 79 | $this->outputLine(); 80 | $this->outputLine('Event Store "%s":', [$eventStoreIdentifier]); 81 | $this->outputLine(str_repeat('-', $this->output->getMaximumLineLength())); 82 | $result = $eventStore->setup(); 83 | $this->renderResult($result); 84 | } 85 | } 86 | 87 | /** 88 | * Display Event Store connection status 89 | * 90 | * This command displays some basic status about the connection of the configured Event Stores. 91 | * 92 | * @return void 93 | */ 94 | public function statusCommand(): void 95 | { 96 | $eventStores = $this->getAllEventStores(); 97 | $this->outputLine('Displaying status information for %d Event Store backend(s):', [\count($eventStores)]); 98 | 99 | foreach ($eventStores as $eventStoreIdentifier => $eventStore) { 100 | $this->outputLine(); 101 | $this->outputLine('Event Store "%s"', [$eventStoreIdentifier]); 102 | $this->outputLine(str_repeat('-', $this->output->getMaximumLineLength())); 103 | 104 | $this->renderResult($eventStore->getStatus()); 105 | } 106 | } 107 | 108 | /** 109 | * Outputs the given Result object in a human-readable way 110 | * 111 | * @param Result $result 112 | */ 113 | private function renderResult(Result $result): void 114 | { 115 | if ($result->hasNotices()) { 116 | /** @var Notice $notice */ 117 | foreach ($result->getNotices() as $notice) { 118 | if ($notice->getTitle() !== null) { 119 | $this->outputLine('%s: %s', [$notice->getTitle(), $notice->render()]); 120 | } else { 121 | $this->outputLine($notice->render()); 122 | } 123 | } 124 | } 125 | 126 | if ($result->hasErrors()) { 127 | /** @var Error $error */ 128 | foreach ($result->getErrors() as $error) { 129 | $this->outputLine('ERROR: %s', [$error->render()]); 130 | } 131 | } elseif ($result->hasWarnings()) { 132 | /** @var Warning $warning */ 133 | foreach ($result->getWarnings() as $warning) { 134 | if ($warning->getTitle() !== null) { 135 | $this->outputLine('%s: %s !!!', [$warning->getTitle(), $warning->render()]); 136 | } else { 137 | $this->outputLine('%s !!!', [$warning->render()]); 138 | } 139 | } 140 | } else { 141 | $this->outputLine('SUCCESS'); 142 | } 143 | } 144 | 145 | /** 146 | * @return EventStore[] 147 | */ 148 | private function getAllEventStores(): array 149 | { 150 | $stores = []; 151 | foreach (array_keys($this->eventStoresConfiguration) as $eventStoreIdentifier) { 152 | $stores[$eventStoreIdentifier] = $this->eventStoreFactory->create($eventStoreIdentifier); 153 | } 154 | return $stores; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Tests/Unit/EventStore/EventStreamTest.php: -------------------------------------------------------------------------------- 1 | expectException(\InvalidArgumentException::class); 17 | StreamName::fromString(' '); 18 | } 19 | 20 | /** 21 | * @test 22 | */ 23 | public function fromStringThrowsExceptionIfValueStartsWithDollarSign(): void 24 | { 25 | $this->expectException(\InvalidArgumentException::class); 26 | StreamName::fromString('$some-stream'); 27 | } 28 | 29 | /** 30 | * @test 31 | */ 32 | public function fromStringTrimsValue(): void 33 | { 34 | $streamName = StreamName::fromString(' some-stream '); 35 | self::assertSame('some-stream', (string)$streamName); 36 | } 37 | 38 | /** 39 | * @test 40 | */ 41 | public function fromCategoryThrowsExceptionIfValueIsEmpty(): void 42 | { 43 | $this->expectException(\InvalidArgumentException::class); 44 | StreamName::forCategory(' '); 45 | } 46 | 47 | /** 48 | * @test 49 | */ 50 | public function fromCategoryThrowsExceptionIfValueStartsWithDollarSign(): void 51 | { 52 | $this->expectException(\InvalidArgumentException::class); 53 | StreamName::forCategory('$some-category'); 54 | } 55 | 56 | /** 57 | * @test 58 | */ 59 | public function fromCategoryTrimsValue(): void 60 | { 61 | $streamName = StreamName::forCategory(' some-category '); 62 | self::assertSame('$ce-some-category', (string)$streamName); 63 | } 64 | 65 | /** 66 | * @test 67 | */ 68 | public function forCorrelationIdThrowsExceptionIfValueIsEmpty(): void 69 | { 70 | $this->expectException(\InvalidArgumentException::class); 71 | StreamName::forCorrelationId(' '); 72 | } 73 | 74 | /** 75 | * @test 76 | */ 77 | public function forCorrelationIdThrowsExceptionIfValueStartsWithDollarSign(): void 78 | { 79 | $this->expectException(\InvalidArgumentException::class); 80 | StreamName::forCorrelationId('$some-correlation-id'); 81 | } 82 | 83 | /** 84 | * @test 85 | */ 86 | public function forCorrelationIdTrimsValue(): void 87 | { 88 | $streamName = StreamName::forCorrelationId(' some-correlation-id '); 89 | self::assertSame('$correlation-some-correlation-id', (string)$streamName); 90 | } 91 | 92 | /** 93 | * @test 94 | */ 95 | public function allReturnsAllStream(): void 96 | { 97 | $streamName = StreamName::all(); 98 | self::assertSame('$all', (string)$streamName); 99 | } 100 | 101 | public function classificationDataProvider(): \generator 102 | { 103 | yield [ 104 | 'streamName' => StreamName::fromString('normal-stream'), 105 | 'isVirtual' => false, 106 | 'isAll' => false, 107 | 'isCategory' => false, 108 | 'isCorrelationId' => false, 109 | ]; 110 | yield [ 111 | 'streamName' => StreamName::forCategory('some-category'), 112 | 'isVirtual' => true, 113 | 'isAll' => false, 114 | 'isCategory' => true, 115 | 'isCorrelationId' => false, 116 | ]; 117 | yield [ 118 | 'streamName' => StreamName::forCorrelationId('some-id'), 119 | 'isVirtual' => true, 120 | 'isAll' => false, 121 | 'isCategory' => false, 122 | 'isCorrelationId' => true, 123 | ]; 124 | yield [ 125 | 'streamName' => StreamName::all(), 126 | 'isVirtual' => true, 127 | 'isAll' => true, 128 | 'isCategory' => false, 129 | 'isCorrelationId' => false, 130 | ]; 131 | } 132 | 133 | /** 134 | * @param StreamName $streamName 135 | * @param bool $isVirtual 136 | * @param bool $isAll 137 | * @param bool $isCategory 138 | * @param bool $isCorrelationId 139 | * @test 140 | * @dataProvider classificationDataProvider 141 | */ 142 | public function classificationTests(StreamName $streamName, bool $isVirtual, bool $isAll, bool $isCategory, bool $isCorrelationId): void 143 | { 144 | self::assertSame($isVirtual, $streamName->isVirtualStream()); 145 | self::assertSame($isAll, $streamName->isAllStream()); 146 | self::assertSame($isCategory, $streamName->isCategoryStream()); 147 | self::assertSame($isCorrelationId, $streamName->isCorrelationIdStream()); 148 | } 149 | 150 | /** 151 | * @test 152 | */ 153 | public function getCategoryNameThrowsExceptionIfNoCategoryStream(): void 154 | { 155 | $this->expectException(\RuntimeException::class); 156 | $streamName = StreamName::fromString('some-stream'); 157 | $streamName->getCategoryName(); 158 | } 159 | 160 | /** 161 | * @test 162 | */ 163 | public function getCategoryNameReturnsCategoryNameOfCategoryStream(): void 164 | { 165 | $streamName = StreamName::forCategory('Some-Category'); 166 | self::assertSame('Some-Category', $streamName->getCategoryName()); 167 | } 168 | 169 | /** 170 | * @test 171 | */ 172 | public function getCorrelationIdThrowsExceptionIfNoCorrelationIdStream(): void 173 | { 174 | $this->expectException(\RuntimeException::class); 175 | $streamName = StreamName::fromString('some-stream'); 176 | $streamName->getCorrelationId(); 177 | } 178 | 179 | /** 180 | * @test 181 | */ 182 | public function getCorrelationIdReturnsCorrelationIdOfCorrelationIdStream(): void 183 | { 184 | $streamName = StreamName::forCorrelationId('some-correlation-id'); 185 | self::assertSame('some-correlation-id', $streamName->getCorrelationId()); 186 | } 187 | 188 | /** 189 | * @test 190 | */ 191 | public function streamNameInstancesAreConstant(): void 192 | { 193 | $streamName1 = StreamName::fromString(' some-stream'); 194 | $streamName2 = StreamName::fromString('some-stream '); 195 | self::assertSame($streamName1, $streamName2); 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /Tests/Unit/Event/DecoratedEventTest.php: -------------------------------------------------------------------------------- 1 | mockEvent = $this->getMockBuilder(DomainEventInterface::class)->getMock(); 20 | } 21 | 22 | /** 23 | * @test 24 | */ 25 | public function addCausationIdentifierDoesntAcceptEmptyCausationId(): void 26 | { 27 | $this->expectException(\InvalidArgumentException::class); 28 | DecoratedEvent::addCausationIdentifier($this->mockEvent, ''); 29 | } 30 | 31 | /** 32 | * @test 33 | */ 34 | public function addCausationIdentifierDoesntAcceptCausationIdExceedingMaxLength(): void 35 | { 36 | $this->expectException(\InvalidArgumentException::class); 37 | DecoratedEvent::addCausationIdentifier($this->mockEvent, str_repeat('x', 256)); 38 | } 39 | 40 | /** 41 | * @test 42 | */ 43 | public function causationIdentifierCanBeRetrieved(): void 44 | { 45 | $someCausationId = 'some-id'; 46 | $eventWithMetadata = DecoratedEvent::addCausationIdentifier($this->mockEvent, $someCausationId); 47 | $metadata = $eventWithMetadata->getMetadata(); 48 | self::assertSame($someCausationId, $metadata['causationIdentifier']); 49 | } 50 | 51 | /** 52 | * @test 53 | */ 54 | public function addCorrelationIdentifierDoesntAcceptEmptyCorrelationId(): void 55 | { 56 | $this->expectException(\InvalidArgumentException::class); 57 | DecoratedEvent::addCausationIdentifier($this->mockEvent, ''); 58 | } 59 | 60 | /** 61 | * @test 62 | */ 63 | public function addCorrelationIdentifierDoesntAcceptCausationIdExceedingMaxLength(): void 64 | { 65 | $this->expectException(\InvalidArgumentException::class); 66 | DecoratedEvent::addCorrelationIdentifier($this->mockEvent, str_repeat('x', 256)); 67 | } 68 | 69 | /** 70 | * @test 71 | */ 72 | public function addMetadataAllowsEmptyArray(): void 73 | { 74 | DecoratedEvent::addMetadata($this->mockEvent, []); 75 | self::assertTrue(true); 76 | } 77 | 78 | /** 79 | * @test 80 | */ 81 | public function metadataCanBeRetrieved(): void 82 | { 83 | $someMetadata = [ 84 | 'foo' => 'bar', 85 | 'baz' => 'foos', 86 | ]; 87 | $eventWithMetadata = DecoratedEvent::addMetadata($this->mockEvent, $someMetadata); 88 | self::assertSame($someMetadata, $eventWithMetadata->getMetadata()); 89 | } 90 | 91 | /** 92 | * @test 93 | */ 94 | public function correlationIdentifierCanBeRetrieved(): void 95 | { 96 | $someCorrelationId = 'some-id'; 97 | $eventWithMetadata = DecoratedEvent::addCorrelationIdentifier($this->mockEvent, $someCorrelationId); 98 | $metadata = $eventWithMetadata->getMetadata(); 99 | self::assertSame($someCorrelationId, $metadata['correlationIdentifier']); 100 | } 101 | 102 | /** 103 | * @test 104 | */ 105 | public function identifierIsNullByDefault(): void 106 | { 107 | $decoratedEvent = DecoratedEvent::addMetadata($this->mockEvent, ['foo' => 'bar']); 108 | self::assertNull($decoratedEvent->getIdentifier()); 109 | } 110 | 111 | /** 112 | * @test 113 | */ 114 | public function identifierCanBeRetrieved(): void 115 | { 116 | $decoratedEvent = DecoratedEvent::addIdentifier($this->mockEvent, 'some-identifier'); 117 | self::assertSame('some-identifier', $decoratedEvent->getIdentifier()); 118 | } 119 | 120 | /** 121 | * @test 122 | */ 123 | public function hasIdentifierIsFalseByDefault(): void 124 | { 125 | $decoratedEvent = DecoratedEvent::addMetadata($this->mockEvent, ['foo' => 'bar']); 126 | self::assertFalse($decoratedEvent->hasIdentifier()); 127 | } 128 | 129 | /** 130 | * @test 131 | */ 132 | public function hasIdentifierIsTrueIfIdentifierIsSet(): void 133 | { 134 | $decoratedEvent = DecoratedEvent::addIdentifier($this->mockEvent, 'some-identifier'); 135 | self::assertTrue($decoratedEvent->hasIdentifier()); 136 | } 137 | 138 | /** 139 | * @test 140 | */ 141 | public function addIdentifierDoesntAcceptEmptyCorrelationId(): void 142 | { 143 | $this->expectException(\InvalidArgumentException::class); 144 | DecoratedEvent::addIdentifier($this->mockEvent, ''); 145 | } 146 | 147 | /** 148 | * @test 149 | */ 150 | public function addIdentifierDoesntAcceptCausationIdExceedingMaxLength(): void 151 | { 152 | $this->expectException(\InvalidArgumentException::class); 153 | DecoratedEvent::addIdentifier($this->mockEvent, str_repeat('x', 256)); 154 | } 155 | 156 | /** 157 | * @test 158 | */ 159 | public function metadataIsMergedWhenNestingDecoratedEvents(): void 160 | { 161 | $someMetadata = ['foo' => ['bar' => 'Baz', 'foos' => 'bars'], 'causationIdentifier' => 'existing-causation-id', 'correlationIdentifier' => 'existing-causation-id']; 162 | $decoratedEvent = DecoratedEvent::addMetadata($this->mockEvent, $someMetadata); 163 | 164 | $nestedDecoratedEvent = DecoratedEvent::addCausationIdentifier(DecoratedEvent::addIdentifier(DecoratedEvent::addMetadata($decoratedEvent, ['additional' => 'added', 'foo' => ['bar' => 'Baz overridden']]), 'some-id'), 'overridden-causation-id'); 165 | 166 | $mergedMetadata = ['foo' => ['bar' => 'Baz overridden', 'foos' => 'bars'], 'causationIdentifier' => 'overridden-causation-id', 'correlationIdentifier' => 'existing-causation-id', 'additional' => 'added']; 167 | 168 | self::assertSame($mergedMetadata, $nestedDecoratedEvent->getMetadata()); 169 | } 170 | 171 | /** 172 | * @test 173 | */ 174 | public function eventIsUnwrappedWhenNestingDecoratedEvents(): void 175 | { 176 | $decoratedEvent = DecoratedEvent::addCausationIdentifier($this->mockEvent, 'some-id'); 177 | $nestedDecoratedEvent = DecoratedEvent::addCausationIdentifier($decoratedEvent,'some-id'); 178 | 179 | self::assertSame($this->mockEvent, $nestedDecoratedEvent->getWrappedEvent()); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /Classes/EventStore/Normalizer/ValueObjectNormalizer.php: -------------------------------------------------------------------------------- 1 | " are considered too if: 15 | * * they are public 16 | * * they are static 17 | * * they expect a single parameter of the given type 18 | * * they have a "self", "static" or "" return type annotation 19 | * 20 | * Note: For type "array" a named constructor fromArray() is required! 21 | */ 22 | final class ValueObjectNormalizer implements DenormalizerInterface 23 | { 24 | /** 25 | * @var array 26 | */ 27 | private $resolveConstructorMethodCache = []; 28 | 29 | /** 30 | * @var array 31 | */ 32 | private $resolveNamedConstructorMethodCache = []; 33 | 34 | public function denormalize($data, $className, $format = null, array $context = []): mixed 35 | { 36 | $constructorMethod = $this->resolveConstructorMethod(TypeHandling::normalizeType(TypeHandling::getTypeForValue($data)), $className); 37 | return $constructorMethod->isConstructor() ? new $className($data) : $constructorMethod->invoke(null, $data); 38 | } 39 | 40 | public function supportsDenormalization($data, $className, $format = null): bool 41 | { 42 | $supportedTypes = ['array', 'string', 'integer', 'float', 'boolean']; 43 | $dataType = TypeHandling::normalizeType(TypeHandling::getTypeForValue($data)); 44 | if (!\in_array($dataType, $supportedTypes, true)) { 45 | return false; 46 | } 47 | try { 48 | $this->resolveConstructorMethod($dataType, $className); 49 | return true; 50 | } catch (\InvalidArgumentException $exception) { 51 | return false; 52 | } 53 | } 54 | 55 | private function resolveConstructorMethod(string $dataType, string $className): ReflectionMethod 56 | { 57 | $cacheIdentifier = md5($dataType . '|' . $className); 58 | if (\array_key_exists($cacheIdentifier, $this->resolveConstructorMethodCache)) { 59 | return $this->resolveConstructorMethodCache[$cacheIdentifier]; 60 | } 61 | 62 | try { 63 | $reflectionClass = new \ReflectionClass($className); 64 | } catch (\ReflectionException $exception) { 65 | throw new \InvalidArgumentException(sprintf('Could not reflect class "%s"', $className), 1545233370, $exception); 66 | } 67 | if ($reflectionClass->isAbstract()) { 68 | throw new \InvalidArgumentException(sprintf('Class "%s" is abstract', $className), 1545296135); 69 | } 70 | $namedConstructorMethod = $this->resolveNamedConstructorMethod($dataType, $className, $reflectionClass); 71 | if ($dataType === 'array' && $namedConstructorMethod === null) { 72 | throw new \InvalidArgumentException(sprintf('Missing named constructor static public function fromArray(array $foo): self in class "%s"', $className), 1569500780); 73 | } 74 | if ($namedConstructorMethod !== null) { 75 | $constructorMethod = $namedConstructorMethod; 76 | } else { 77 | $constructorMethod = (interface_exists(ProxyInterface::class) && $reflectionClass->implementsInterface(ProxyInterface::class)) ? $reflectionClass->getParentClass()->getConstructor() : $reflectionClass->getConstructor(); 78 | } 79 | if ($constructorMethod === null) { 80 | throw new \InvalidArgumentException(sprintf('Could not resolve constructor for class "%s"', $className), 1545233397); 81 | } 82 | if (!$constructorMethod->isPublic()) { 83 | throw new \InvalidArgumentException(sprintf('Constructor %s:%s is not public', $className, $constructorMethod->getName()), 1545233434); 84 | } 85 | if ($constructorMethod->getNumberOfParameters() !== 1) { 86 | throw new \InvalidArgumentException(sprintf('Constructor %s:%s has %d parameter but it must have one', $className, $constructorMethod->getName(), $constructorMethod->getNumberOfParameters()), 1545233460); 87 | } 88 | $constructorParameter = $constructorMethod->getParameters()[0]; 89 | $constructorParameterType = $constructorParameter->getType(); 90 | if ($constructorParameterType === null || TypeHandling::normalizeType($constructorParameterType->getName()) !== $dataType) { 91 | throw new \InvalidArgumentException(sprintf('The constructor %s:%s expects a different parameter type', $className, $constructorMethod->getName()), 1545233522); 92 | } 93 | 94 | $this->resolveConstructorMethodCache[$cacheIdentifier] = $constructorMethod; 95 | return $constructorMethod; 96 | } 97 | 98 | private function resolveNamedConstructorMethod(string $dataType, string $className, \ReflectionClass $reflectionClass): ?ReflectionMethod 99 | { 100 | $cacheIdentifier = md5($dataType . '|' . $className); 101 | if (\array_key_exists($cacheIdentifier, $this->resolveNamedConstructorMethodCache)) { 102 | return $this->resolveNamedConstructorMethodCache[$cacheIdentifier]; 103 | } 104 | $staticConstructorName = 'from' . ucfirst($dataType); 105 | try { 106 | $this->resolveNamedConstructorMethodCache[$cacheIdentifier] = null; 107 | $constructorMethod = $reflectionClass->getMethod($staticConstructorName); 108 | } catch (\ReflectionException $exception) { 109 | return null; 110 | } 111 | if (!$constructorMethod->isStatic()) { 112 | return null; 113 | } 114 | $constructorMethodReturnType = $constructorMethod->getReturnType(); 115 | if ($constructorMethodReturnType === null || $constructorMethodReturnType->allowsNull()) { 116 | return null; 117 | } 118 | $constructorMethodReturnTypeName = $constructorMethodReturnType->getName(); 119 | if ($constructorMethodReturnTypeName !== $className && $constructorMethodReturnTypeName !== 'self' && $constructorMethodReturnTypeName !== 'static') { 120 | return null; 121 | } 122 | $this->resolveNamedConstructorMethodCache[$cacheIdentifier] = $constructorMethod; 123 | return $constructorMethod; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Tests/Unit/EventStore/EventNormalizerTest.php: -------------------------------------------------------------------------------- 1 | mockEventTypeResolver = $this->getMockBuilder(EventTypeResolverInterface::class)->getMock(); 37 | $this->eventNormalizer = new EventNormalizer($this->mockEventTypeResolver); 38 | } 39 | 40 | /** 41 | * @test 42 | */ 43 | public function normalizeExtractsPayloadFromArrayBasedEvent(): void 44 | { 45 | $mockData = ['some' => 'data']; 46 | $event = new Fixture\ArrayBasedEvent($mockData); 47 | $result = $this->eventNormalizer->normalize($event); 48 | self::assertSame(['data' => $mockData], $result); 49 | } 50 | 51 | /** 52 | * see https://github.com/neos/Neos.EventSourcing/issues/233 53 | * 54 | * @test 55 | */ 56 | public function denormalizeConstructsArrayBasedEventWithCorrectPayload(): void 57 | { 58 | $mockData = ['some' => 'data']; 59 | $normalizedEvent = ['data' => $mockData]; 60 | 61 | $this->mockEventTypeResolver->method('getEventClassNameByType')->with('Some.Event:Type')->willReturn(Fixture\ArrayBasedEvent::class); 62 | 63 | /** @var Fixture\ArrayBasedEvent $event */ 64 | $event = $this->eventNormalizer->denormalize($normalizedEvent, 'Some.Event:Type'); 65 | self::assertSame($mockData, $event->getData()); 66 | } 67 | 68 | public function normalizeDataProvider(): \generator 69 | { 70 | $dateTimeImmutable = new \DateTimeImmutable('1980-12-13'); 71 | $dateTime = new \DateTime('1980-12-13 15:34:19'); 72 | $jsonSerializable = new class implements \JsonSerializable { public function jsonSerialize(): array { return ['foo' => 'bar'];}}; 73 | yield 'dateTimeImmutable' => ['value' => $dateTimeImmutable, 'expectedResult' => $dateTimeImmutable->format(\DateTimeInterface::RFC3339)]; 74 | yield 'dateTime' => ['value' => $dateTime, 'expectedResult' => $dateTime->format(\DateTimeInterface::RFC3339)]; 75 | yield 'jsonSerializable' => ['value' => $jsonSerializable, 'expectedResult' => ['foo' => 'bar']]; 76 | } 77 | 78 | /** 79 | * @test 80 | * @dataProvider normalizeDataProvider 81 | */ 82 | public function normalizeTests($value, $expectedResult): void 83 | { 84 | $event = $this->getMockBuilder(DomainEventInterface::class)->addMethods(['getProperty'])->getMock(); 85 | /** @noinspection MockingMethodsCorrectnessInspection */ 86 | $event->method('getProperty')->willReturn($value); 87 | $result = $this->eventNormalizer->normalize($event); 88 | self::assertSame(['property' => $expectedResult], $result); 89 | } 90 | 91 | public function denormalizeDataProvider(): \generator 92 | { 93 | $dateTimeImmutable = new \DateTimeImmutable('1980-12-13'); 94 | $dateTime = new \DateTime('1980-12-13 15:34:19'); 95 | $array = ['foo' => 'bar', 'Bar' => ['nested' => 'foos']]; 96 | $string = 'Some string with späcial characterß'; 97 | $integer = 42; 98 | $float = 42.987; 99 | $boolean = true; 100 | yield 'dateTimeImmutable' => ['data' => ['date' => $dateTimeImmutable->format(\DateTimeInterface::RFC3339)], 'expectedResult' => new EventWithDateTime($dateTimeImmutable)]; 101 | yield 'dateTime' => ['data' => ['date' => $dateTime->format(\DateTimeInterface::RFC3339)], 'expectedResult' => new EventWithDateTime($dateTime)]; 102 | yield 'valueObjects' => ['data' => compact('array', 'string', 'integer', 'float', 'boolean'), 'expectedResult' => new EventWithValueObjects(ArrayValueObject::fromArray($array), StringValueObject::fromString($string), IntegerValueObject::fromInteger($integer), FloatValueObject::fromFloat($float), BooleanValueObject::fromBoolean($boolean))]; 103 | } 104 | 105 | /** 106 | * @test 107 | * @dataProvider denormalizeDataProvider 108 | */ 109 | public function denormalizeTests(array $data, object $expectedResult): void 110 | { 111 | $this->mockEventTypeResolver->method('getEventClassNameByType')->with('Some.Event:Type')->willReturn(get_class($expectedResult)); 112 | $result = $this->eventNormalizer->denormalize($data, 'Some.Event:Type'); 113 | self::assertObjectEquals($expectedResult, $result); 114 | } 115 | 116 | /** 117 | * @test 118 | */ 119 | public function normalizeSupportsBackedEnums(): void 120 | { 121 | if (PHP_VERSION_ID < 80100) { 122 | $this->markTestSkipped('Backed enums are only available with PHP 8.1+'); 123 | } 124 | $event = new EventWithBackedEnum(BackedEnum::Hearts); 125 | $result = $this->eventNormalizer->normalize($event); 126 | self::assertSame(['enum' => 'H'], $result); 127 | } 128 | 129 | /** 130 | * @test 131 | */ 132 | public function denormalizeSupportsBackedEnums(): void 133 | { 134 | if (PHP_VERSION_ID < 80100) { 135 | $this->markTestSkipped('Backed enums are only available with PHP 8.1+'); 136 | } 137 | $this->mockEventTypeResolver->method('getEventClassNameByType')->with('Some.Event:Type')->willReturn(EventWithBackedEnum::class); 138 | /** @var EventWithBackedEnum $event */ 139 | $event = $this->eventNormalizer->denormalize(['enum' => 'C'], 'Some.Event:Type'); 140 | self::assertSame(BackedEnum::Clubs, $event->getEnum()); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Classes/EventListener/AppliedEventsStorage/DoctrineAppliedEventsStorage.php: -------------------------------------------------------------------------------- 1 | dbal = $dbal; 40 | $this->eventListenerIdentifier = $eventListenerIdentifier; 41 | $this->initializeHighestAppliedSequenceNumber(); 42 | } 43 | 44 | private function initializeHighestAppliedSequenceNumber(): void 45 | { 46 | if ($this->dbal->getTransactionNestingLevel() !== 0) { 47 | throw new \RuntimeException('initializeHighestAppliedSequenceNumber only works not inside a transaction.', 1551005203); 48 | } 49 | 50 | try { 51 | $this->dbal->executeUpdate( 52 | 'INSERT INTO ' . AppliedEventsLog::TABLE_NAME . ' (eventListenerIdentifier, highestAppliedSequenceNumber) VALUES (:eventListenerIdentifier, -1)', 53 | ['eventListenerIdentifier' => $this->eventListenerIdentifier] 54 | ); 55 | } catch (\Exception $exception) { 56 | // UniqueConstraintViolationException = The sequence number is already registered => ignore 57 | if ($exception instanceof UniqueConstraintViolationException) { 58 | return; 59 | } 60 | throw new \RuntimeException(sprintf('Failed to initialize highest sequence number for "%s": %s', $this->eventListenerIdentifier, $exception->getMessage()), 1567081020, $exception); 61 | } 62 | } 63 | 64 | /** 65 | * @inheritDoc 66 | */ 67 | public function reserveHighestAppliedEventSequenceNumber(): int 68 | { 69 | if ($this->dbal->getTransactionNestingLevel() !== 0) { 70 | throw new \RuntimeException('A transaction is active already, can\'t fetch highestAppliedSequenceNumber!', 1550865301); 71 | } 72 | 73 | $this->dbal->beginTransaction(); 74 | $this->setLockTimeout(); 75 | try { 76 | $highestAppliedSequenceNumber = $this->dbal->fetchOne( 77 | ' 78 | SELECT highestAppliedSequenceNumber 79 | FROM ' . $this->dbal->quoteIdentifier(AppliedEventsLog::TABLE_NAME) . ' 80 | WHERE eventlisteneridentifier = :eventListenerIdentifier ' 81 | . $this->dbal->getDatabasePlatform()->getForUpdateSQL(), 82 | ['eventListenerIdentifier' => $this->eventListenerIdentifier] 83 | ); 84 | } /** @noinspection PhpRedundantCatchClauseInspection */ catch (DriverException $exception) { 85 | try { 86 | $this->dbal->rollBack(); 87 | } catch (ConnectionException $e) { 88 | } 89 | // Error code "1205" = ER_LOCK_WAIT_TIMEOUT in MySQL (https://dev.mysql.com/doc/refman/8.0/en/server-error-reference.html#error_er_lock_wait_timeout) 90 | // SQL State "55P03" = lock_not_available in PostgreSQL (https://www.postgresql.org/docs/9.4/errcodes-appendix.html) 91 | if ($exception->getErrorCode() !== 1205 && $exception->getSQLState() !== '55P03') { 92 | throw new \RuntimeException($exception->getMessage(), 1544207633, $exception); 93 | } 94 | throw new HighestAppliedSequenceNumberCantBeReservedException(sprintf('Could not reserve highest applied sequence number for listener "%s"', $this->eventListenerIdentifier), 1523456892, $exception); 95 | } catch (DBALException $exception) { 96 | throw new \RuntimeException($exception->getMessage(), 1544207778, $exception); 97 | } 98 | if ($highestAppliedSequenceNumber === false) { 99 | throw new HighestAppliedSequenceNumberCantBeReservedException(sprintf('Could not reserve highest applied sequence number for listener "%s", because the corresponding row was not found in the %s table. This means the method initializeHighestAppliedSequenceNumber() was not called beforehand.', $this->eventListenerIdentifier, AppliedEventsLog::TABLE_NAME), 1550948433); 100 | } 101 | return (int)$highestAppliedSequenceNumber; 102 | } 103 | 104 | private function setLockTimeout(): void 105 | { 106 | try { 107 | $platform = $this->dbal->getDatabasePlatform()->getName(); 108 | } catch (DBALException $exception) { 109 | throw new \RuntimeException(sprintf('Failed to determine database platform: %s', $exception->getMessage()), 1567080718, $exception); 110 | } 111 | if ($platform === 'mysql') { 112 | $statement = 'SET innodb_lock_wait_timeout = 1'; 113 | } elseif ($platform === 'postgresql') { 114 | $statement = 'SET LOCAL lock_timeout = \'1s\''; 115 | } else { 116 | return; 117 | } 118 | try { 119 | $this->dbal->executeQuery($statement); 120 | } catch (DBALException $exception) { 121 | throw new \RuntimeException(sprintf('Failed to set lock timeout: %s', $exception->getMessage()), 1544207612, $exception); 122 | } 123 | } 124 | 125 | /** 126 | * @inheritDoc 127 | */ 128 | public function releaseHighestAppliedSequenceNumber(): void 129 | { 130 | try { 131 | $this->dbal->commit(); 132 | } catch (ConnectionException $e) { 133 | } 134 | } 135 | 136 | /** 137 | * @inheritDoc 138 | */ 139 | public function saveHighestAppliedSequenceNumber(int $sequenceNumber): void 140 | { 141 | // Fails if no matching entry exists; which is fine because initializeHighestAppliedSequenceNumber() must be called beforehand. 142 | try { 143 | $this->dbal->update( 144 | AppliedEventsLog::TABLE_NAME, 145 | ['highestAppliedSequenceNumber' => $sequenceNumber], 146 | ['eventListenerIdentifier' => $this->eventListenerIdentifier] 147 | ); 148 | } catch (DBALException $exception) { 149 | throw new \RuntimeException(sprintf('Could not save highest applied sequence number for listener "%s". Did you call initializeHighestAppliedSequenceNumber() beforehand?', $this->eventListenerIdentifier), 1544207099, $exception); 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Tests/Unit/Projection/ProjectionManagerTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('We need to fix these tests (to not rely on dg/bypass-finals'); 60 | 61 | $this->mockReflectionService = $this->createMock(ReflectionService::class); 62 | $md5 = md5((string)time()); 63 | $this->projectorIdentifiers = [ 64 | 'acme.somepackage:acmesomepackagetest' . $md5 . '0', 65 | 'acme.somepackage:acmesomepackagetest' . $md5 . '1' 66 | ]; 67 | 68 | $this->projectorClassNames = [ 69 | $this->projectorIdentifiers[0] => $this->getMockClass(ProjectorInterface::class, [], [], 'AcmeSomePackageTest' . $md5 . '0Projector'), 70 | $this->projectorIdentifiers[1] => $this->getMockClass(ProjectorInterface::class, [], [], 'AcmeSomePackageTest' . $md5 . '1Projector'), 71 | ]; 72 | 73 | $this->mockReflectionService->method('getAllImplementationClassNamesForInterface')->with(ProjectorInterface::class)->willReturn([ 74 | $this->projectorClassNames[$this->projectorIdentifiers[0]], 75 | $this->projectorClassNames[$this->projectorIdentifiers[1]] 76 | ]); 77 | 78 | $this->mockObjectManager = $this->createMock(ObjectManagerInterface::class); 79 | $this->mockObjectManager->method('get')->with(ReflectionService::class)->willReturn($this->mockReflectionService); 80 | $this->mockObjectManager->method('getPackageKeyByObjectName')->willReturn('Acme.SomePackage'); 81 | 82 | $this->mockEventStoreFactory = $this->createMock(EventStoreFactory::class); 83 | $this->mockMappingProvider = $this->createMock(DefaultEventToListenerMappingProvider::class); 84 | 85 | $this->projectionManager = new ProjectionManager($this->mockObjectManager, $this->mockEventStoreFactory, $this->mockMappingProvider); 86 | } 87 | 88 | /** 89 | * @test 90 | * @throws 91 | */ 92 | public function getProjectionsReturnsDetectedProjections(): void 93 | { 94 | $this->projectionManager->initializeObject(); 95 | 96 | $projections = $this->projectionManager->getProjections(); 97 | $this->assertSame($this->projectorClassNames[$this->projectorIdentifiers[0]], $projections[0]->getProjectorClassName()); 98 | $this->assertSame($this->projectorClassNames[$this->projectorIdentifiers[1]], $projections[1]->getProjectorClassName()); 99 | } 100 | 101 | /** 102 | * @test 103 | * @noinspection ClassMockingCorrectnessInspection 104 | */ 105 | public function catchUpCallsEventListenerInvokerForCatchingUp(): void 106 | { 107 | $mockEventListenerInvoker = $this->createMock(EventListenerInvoker::class); 108 | 109 | $projectionManager = $this->createPartialMock(ProjectionManager::class, ['createEventListenerInvokerForProjection']); 110 | $projectionManager->method('createEventListenerInvokerForProjection')->with($this->projectorIdentifiers[0])->willReturn($mockEventListenerInvoker); 111 | 112 | $mockEventListenerInvoker->expects($this->once())->method('catchUp'); 113 | $projectionManager->catchUp($this->projectorIdentifiers[0]); 114 | } 115 | 116 | /** 117 | * @test 118 | * @noinspection ClassMockingCorrectnessInspection 119 | */ 120 | public function catchUpSetsCallbackOnEventListenerInvoker(): void 121 | { 122 | $mockEventListenerInvoker = $this->createMock(EventListenerInvoker::class); 123 | 124 | $projectionManager = $this->createPartialMock(ProjectionManager::class, ['createEventListenerInvokerForProjection']); 125 | $projectionManager->method('createEventListenerInvokerForProjection')->with($this->projectorIdentifiers[0])->willReturn($mockEventListenerInvoker); 126 | 127 | $callback = static function() { echo 'hello'; }; 128 | 129 | $mockEventListenerInvoker->expects($this->once())->method('onProgress')->with($callback); 130 | $projectionManager->catchUp($this->projectorIdentifiers[0], $callback); 131 | } 132 | 133 | /** 134 | * @test 135 | * @noinspection ClassMockingCorrectnessInspection 136 | */ 137 | public function catchUpUntilSequenceNumberCallsEventListenerInvokerForCatchingUp(): void 138 | { 139 | $mockEventListenerInvoker = $this->createMock(EventListenerInvoker::class); 140 | 141 | $projectionManager = $this->createPartialMock(ProjectionManager::class, ['createEventListenerInvokerForProjection']); 142 | $projectionManager->method('createEventListenerInvokerForProjection')->with($this->projectorIdentifiers[0])->willReturn($mockEventListenerInvoker); 143 | 144 | $mockEventListenerInvoker->expects($this->once())->method('withMaximumSequenceNumber')->with(42)->willReturn($mockEventListenerInvoker); 145 | $mockEventListenerInvoker->expects($this->once())->method('catchUp'); 146 | $projectionManager->catchUpUntilSequenceNumber($this->projectorIdentifiers[0], 42); 147 | } 148 | 149 | /** 150 | * @test 151 | * @noinspection ClassMockingCorrectnessInspection 152 | */ 153 | public function catchUpUntilSequenceNumberSetCallbackOnEventListenerInvoker(): void 154 | { 155 | $mockEventListenerInvoker = $this->createMock(EventListenerInvoker::class); 156 | 157 | $projectionManager = $this->createPartialMock(ProjectionManager::class, ['createEventListenerInvokerForProjection']); 158 | $projectionManager->method('createEventListenerInvokerForProjection')->with($this->projectorIdentifiers[0])->willReturn($mockEventListenerInvoker); 159 | 160 | $callback = static function() { echo 'hello'; }; 161 | 162 | $mockEventListenerInvoker->method('withMaximumSequenceNumber')->willReturn($mockEventListenerInvoker); 163 | $mockEventListenerInvoker->expects($this->once())->method('onProgress')->with($callback); 164 | $projectionManager->catchUpUntilSequenceNumber($this->projectorIdentifiers[0], 100, $callback); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /Tests/Unit/EventListener/EventListenerInvokerTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('We need to fix these tests (to not rely on dg/bypass-finals'); 64 | $this->mockEventStore = $this->getMockBuilder(EventStore::class)->disableOriginalConstructor()->getMock(); 65 | 66 | $this->mockConnection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->getMock(); 67 | $this->mockConnection->method('getTransactionNestingLevel')->willReturn(0); 68 | $mockPlatform = $this->getMockBuilder(AbstractPlatform::class)->getMock(); 69 | $this->mockConnection->method('getDatabasePlatform')->willReturn($mockPlatform); 70 | 71 | $this->mockAppliedEventsStorage = $this->getMockBuilder(AppliedEventsStorageInterface::class)->getMock(); 72 | 73 | $this->mockEventListener = $this->getMockBuilder(AppliedEventsStorageEventListener::class)->disableOriginalConstructor()->getMock(); 74 | $this->mockEventListener->method('getAppliedEventsStorage')->willReturn($this->mockAppliedEventsStorage); 75 | 76 | $this->eventListenerInvoker = new EventListenerInvoker($this->mockEventStore, $this->mockEventListener, $this->mockConnection); 77 | 78 | $this->mockEventStream = $this->getMockBuilder(EventStream::class)->disableOriginalConstructor()->getMock(); 79 | $this->mockEventNormalizer = $this->getMockBuilder(EventNormalizer::class)->disableOriginalConstructor()->getMock(); 80 | } 81 | 82 | /** 83 | * @test 84 | */ 85 | public function withMaximumSequenceNumberRejectsSequenceNumbersSmallerThanZero(): void 86 | { 87 | $this->eventListenerInvoker = new EventListenerInvoker($this->mockEventStore, $this->mockEventListener, $this->mockConnection); 88 | 89 | $this->expectExceptionCode(1597821711); 90 | $this->eventListenerInvoker->withMaximumSequenceNumber(-1); 91 | } 92 | 93 | /** 94 | * @test 95 | * @throws 96 | */ 97 | public function catchUpAppliesEventsUpToTheDefinedMaximumSequenceNumber(): void 98 | { 99 | $eventRecords = []; 100 | for ($sequenceNumber = 1; $sequenceNumber < 123; $sequenceNumber++) { 101 | $eventRecords[] = [ 102 | 'sequencenumber' => $sequenceNumber, 103 | 'type' => 'FooEventType', 104 | 'payload' => json_encode(['foo' => 'bar'], JSON_THROW_ON_ERROR, 512), 105 | 'metadata' => json_encode([], JSON_THROW_ON_ERROR, 512), 106 | 'recordedat' => '2020-08-17', 107 | 'stream' => 'FooStreamName', 108 | 'version' => $sequenceNumber, 109 | 'id' => Uuid::uuid4()->toString() 110 | ]; 111 | } 112 | 113 | $streamIterator = new InMemoryStreamIterator($eventRecords); 114 | $eventStream = new EventStream(StreamName::fromString('FooStreamName'), $streamIterator, $this->mockEventNormalizer); 115 | 116 | // Simulate that the first 10 events have already been applied: 117 | $this->mockAppliedEventsStorage->expects($this->atLeastOnce())->method('reserveHighestAppliedEventSequenceNumber')->willReturn(10); 118 | $this->mockEventStore->expects($this->once())->method('load')->with(StreamName::all(), 11)->willReturn($eventStream); 119 | 120 | $eventListenerInvoker = new EventListenerInvoker($this->mockEventStore, $this->mockEventListener, $this->mockConnection); 121 | 122 | $appliedEventsCounter = 0; 123 | $eventListenerInvoker->onProgress(static function() use(&$appliedEventsCounter){ 124 | $appliedEventsCounter ++; 125 | }); 126 | 127 | $eventListenerInvoker = $eventListenerInvoker->withMaximumSequenceNumber(50); 128 | $eventListenerInvoker->catchUp(); 129 | 130 | $this->assertSame(40, $appliedEventsCounter); 131 | } 132 | 133 | /** 134 | * @test 135 | * @throws EventCouldNotBeAppliedException 136 | */ 137 | public function catchUpPassesRespectsReservedSequenceNumber(): void 138 | { 139 | $this->mockAppliedEventsStorage->expects($this->atLeastOnce())->method('reserveHighestAppliedEventSequenceNumber')->willReturn(123); 140 | $this->mockEventStore->expects($this->once())->method('load')->with(StreamName::all(), 124)->willReturn($this->mockEventStream); 141 | $this->eventListenerInvoker->catchUp(); 142 | } 143 | 144 | /** 145 | * @test 146 | * @throws EventCouldNotBeAppliedException 147 | */ 148 | public function catchUpPassesRespectsStreamAwareEventListenerInterface(): void 149 | { 150 | $streamName = StreamName::fromString('some-stream'); 151 | $mockEventListener = $this->buildMockEventListener($streamName); 152 | $this->eventListenerInvoker = new EventListenerInvoker($this->mockEventStore, $mockEventListener, $this->mockConnection); 153 | $this->mockEventStore->expects($this->once())->method('load')->with($streamName, 1)->willReturn($this->mockEventStream); 154 | $this->eventListenerInvoker->catchUp(); 155 | } 156 | 157 | /** 158 | * @test 159 | * @throws 160 | */ 161 | public function replaySetsHighestAppliedSequenceNumberToMinusOneAndCallsCatchup(): void 162 | { 163 | $this->mockAppliedEventsStorage->expects($this->once())->method('saveHighestAppliedSequenceNumber')->with(-1); 164 | 165 | $eventListenerInvokerPartialMock = $this->createPartialMock(EventListenerInvoker::class, ['catchUp']); 166 | $eventListenerInvokerPartialMock->__construct($this->mockEventStore, $this->mockEventListener, $this->mockConnection); 167 | $eventListenerInvokerPartialMock->expects($this->once())->method('catchUp'); 168 | 169 | $eventListenerInvokerPartialMock->replay(); 170 | } 171 | 172 | /** 173 | * @param StreamName|null $streamName 174 | * @return EventListenerInterface 175 | */ 176 | private function buildMockEventListener(StreamName $streamName = null): EventListenerInterface 177 | { 178 | $listenerClassName = 'Mock_EventListener_' . md5(uniqid('', true)); 179 | $listenerCode = 'class ' . $listenerClassName . ' implements ' . EventListenerInterface::class; 180 | if ($streamName !== null) { 181 | $listenerCode .= ', ' . StreamAwareEventListenerInterface::class; 182 | } 183 | $listenerCode .= ' {'; 184 | if ($streamName !== null) { 185 | $listenerCode .= 'public static function listensToStream(): ' . StreamName::class . ' { return ' . StreamName::class . '::fromString(\'' . $streamName . '\'); }'; 186 | } 187 | $listenerCode .= '}'; 188 | 189 | eval($listenerCode); 190 | return new $listenerClassName(); 191 | } 192 | 193 | } 194 | -------------------------------------------------------------------------------- /Tests/Unit/EventPublisher/JobQueueEventPublisherTest.php: -------------------------------------------------------------------------------- 1 | mockJobManager = $this->getMockBuilder(JobManager::class)->disableOriginalConstructor()->getMock(); 41 | 42 | $this->mockEvent1 = $this->getMockBuilder(DomainEventInterface::class)->getMock(); 43 | $this->mockEvent2 = $this->getMockBuilder(DomainEventInterface::class)->getMock(); 44 | $this->mockEvents = DomainEvents::fromArray([$this->mockEvent1, $this->mockEvent2]); 45 | } 46 | 47 | /** 48 | * @test 49 | */ 50 | public function publishDoesNotQueueJobsIfDomainEventsAreEmpty(): void 51 | { 52 | $jobQueueEventPublisher = $this->buildPublisher('some-event-store', EventToListenerMappings::createEmpty()); 53 | 54 | $this->mockJobManager->expects($this->never())->method('queue'); 55 | 56 | $jobQueueEventPublisher->publish(DomainEvents::createEmpty()); 57 | } 58 | 59 | /** 60 | * @test 61 | */ 62 | public function publishDoesNotQueueJobsIfMappingsIsEmpty(): void 63 | { 64 | $jobQueueEventPublisher = $this->buildPublisher('some-event-store', EventToListenerMappings::createEmpty()); 65 | 66 | $this->mockJobManager->expects($this->never())->method('queue'); 67 | 68 | $mockEvent1 = $this->getMockBuilder(DomainEventInterface::class)->getMock(); 69 | $mockEvent2 = $this->getMockBuilder(DomainEventInterface::class)->getMock(); 70 | $jobQueueEventPublisher->publish(DomainEvents::fromArray([$mockEvent1, $mockEvent2])); 71 | } 72 | 73 | /** 74 | * @test 75 | */ 76 | public function publishDoesNotQueueJobsIfNoMatchingMappingExists(): void 77 | { 78 | $mappings = EventToListenerMappings::createEmpty() 79 | ->withMapping(EventToListenerMapping::create('SomeEventClassName', 'SomeListenerClassName', [])) 80 | ->withMapping(EventToListenerMapping::create('SomeOtherEventClassName', 'SomeListenerClassName', [])); 81 | $jobQueueEventPublisher = $this->buildPublisher('some-event-store', $mappings); 82 | 83 | $this->mockJobManager->expects($this->never())->method('queue'); 84 | 85 | $mockEvent1 = $this->getMockBuilder(DomainEventInterface::class)->getMock(); 86 | $mockEvent2 = $this->getMockBuilder(DomainEventInterface::class)->getMock(); 87 | $jobQueueEventPublisher->publish(DomainEvents::fromArray([$mockEvent1, $mockEvent2])); 88 | } 89 | 90 | /** 91 | * @test 92 | */ 93 | public function publishPassesTheEventStoreIdToTheJob(): void 94 | { 95 | $someEventStoreId = 'some-event-store'; 96 | $mappings = EventToListenerMappings::createEmpty() 97 | ->withMapping(EventToListenerMapping::create(\get_class($this->mockEvent1), 'SomeListenerClassName', [])); 98 | $jobQueueEventPublisher = $this->buildPublisher($someEventStoreId, $mappings); 99 | 100 | $this->mockJobManager->method('queue')->willReturnCallback(static function(string $_, CatchUpEventListenerJob $job) use ($someEventStoreId) { 101 | self::assertSame($someEventStoreId, $job->getEventStoreIdentifier()); 102 | }); 103 | 104 | $jobQueueEventPublisher->publish($this->mockEvents); 105 | } 106 | 107 | /** 108 | * @test 109 | */ 110 | public function publishQueuesTheJobInTheDefaultQueueByDefault(): void 111 | { 112 | $mappings = EventToListenerMappings::createEmpty() 113 | ->withMapping(EventToListenerMapping::create(\get_class($this->mockEvent1), 'SomeListenerClassName', [])); 114 | $jobQueueEventPublisher = $this->buildPublisher('event-store-id', $mappings); 115 | 116 | $this->mockJobManager->method('queue')->willReturnCallback(static function($queueName) { 117 | self::assertSame('neos-eventsourcing', $queueName); 118 | }); 119 | 120 | $jobQueueEventPublisher->publish($this->mockEvents); 121 | } 122 | 123 | /** 124 | * @test 125 | */ 126 | public function publishQueuesTheJobInTheSpecifiedQueue(): void 127 | { 128 | $queueName = 'Some-Queue'; 129 | $mappings = EventToListenerMappings::createEmpty() 130 | ->withMapping(EventToListenerMapping::create(\get_class($this->mockEvent1), 'SomeListenerClassName', ['queueName' => $queueName])); 131 | $jobQueueEventPublisher = $this->buildPublisher('event-store-id', $mappings); 132 | 133 | $this->mockJobManager->method('queue')->willReturnCallback(static function(string $actualQueueName) use ($queueName) { 134 | self::assertSame($queueName, $actualQueueName); 135 | }); 136 | 137 | $jobQueueEventPublisher->publish($this->mockEvents); 138 | } 139 | 140 | /** 141 | * @test 142 | */ 143 | public function publishPassesQueueOptionsToJob(): void 144 | { 145 | $queueOptions = ['foo' => ['bar' => 'Baz']]; 146 | $mappings = EventToListenerMappings::createEmpty() 147 | ->withMapping(EventToListenerMapping::create(\get_class($this->mockEvent1), 'SomeListenerClassName', ['queueOptions' => $queueOptions])); 148 | $jobQueueEventPublisher = $this->buildPublisher('event-store-id', $mappings); 149 | 150 | $this->mockJobManager->method('queue')->willReturnCallback(static function(string $_, CatchUpEventListenerJob $__, array $actualOptions) use ($queueOptions) { 151 | self::assertSame($queueOptions, $actualOptions); 152 | }); 153 | 154 | $jobQueueEventPublisher->publish($this->mockEvents); 155 | } 156 | 157 | /** 158 | * @test 159 | */ 160 | public function publishQueuesOnlyOneJobPerListener(): void 161 | { 162 | $eventListenerClassName = 'SomeListenerClassName'; 163 | $mappings = EventToListenerMappings::createEmpty() 164 | ->withMapping(EventToListenerMapping::create(\get_class($this->mockEvent1), $eventListenerClassName, [])) 165 | ->withMapping(EventToListenerMapping::create(\get_class($this->mockEvent2), $eventListenerClassName, [])); 166 | $jobQueueEventPublisher = $this->buildPublisher('event-store-id', $mappings); 167 | 168 | $this->mockJobManager->expects($this->once())->method('queue')->willReturnCallback(static function(string $_, CatchUpEventListenerJob $job) use ($eventListenerClassName) { 169 | self::assertSame($eventListenerClassName, $job->getListenerClassName()); 170 | }); 171 | 172 | $jobQueueEventPublisher->publish($this->mockEvents); 173 | } 174 | 175 | /** 176 | * @test 177 | */ 178 | public function publishQueuesAJobForEachListener(): void 179 | { 180 | $mappings = EventToListenerMappings::createEmpty() 181 | ->withMapping(EventToListenerMapping::create(\get_class($this->mockEvent1), 'SomeListenerClassName1', [])) 182 | ->withMapping(EventToListenerMapping::create(\get_class($this->mockEvent1), 'SomeListenerClassName2', [])) 183 | ->withMapping(EventToListenerMapping::create(\get_class($this->mockEvent2), 'SomeListenerClassName1', [])); 184 | $jobQueueEventPublisher = $this->buildPublisher('event-store-id', $mappings); 185 | 186 | $this->mockJobManager->expects(self::exactly(2))->method('queue')->withConsecutive( 187 | ['neos-eventsourcing', self::callback(static function(CatchUpEventListenerJob $job) { return $job->getListenerClassName() === 'SomeListenerClassName1'; })], 188 | ['neos-eventsourcing', self::callback(static function(CatchUpEventListenerJob $job) { return $job->getListenerClassName() === 'SomeListenerClassName2'; })], 189 | ); 190 | $jobQueueEventPublisher->publish($this->mockEvents); 191 | } 192 | 193 | private function buildPublisher(string $eventStoreIdentifier, EventToListenerMappings $mappings): JobQueueEventPublisher 194 | { 195 | $publisher = new JobQueueEventPublisher($eventStoreIdentifier, $mappings); 196 | $this->inject($publisher, 'jobManager', $this->mockJobManager); 197 | return $publisher; 198 | } 199 | } 200 | --------------------------------------------------------------------------------