Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

use attribute to define projection target for dx #447

Merged
merged 4 commits into from
Dec 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion baseline.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="5.17.0@c620f6e80d0abfca532b00bda366062aaedf6e5d">
<files psalm-version="5.18.0@b113f3ed0259fd6e212d87c3df80eec95a6abf19">
<file src="src/Aggregate/AggregateRootBehaviour.php">
<UnsafeInstantiation>
<code>new static()</code>
Expand Down Expand Up @@ -55,6 +55,13 @@
<code><![CDATA[$this->projectors]]></code>
</InvalidOperand>
</file>
<file src="src/Projection/Projector/MetadataProjectorResolver.php">
<MixedMethodCall>
<code>$method</code>
<code>$method</code>
<code>$subscribeMethod</code>
</MixedMethodCall>
</file>
<file src="src/Repository/DefaultRepository.php">
<PropertyTypeCoercion>
<code>new WeakMap()</code>
Expand Down
40 changes: 24 additions & 16 deletions docs/pages/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,28 +157,27 @@ use Doctrine\DBAL\Connection;
use Patchlevel\EventSourcing\Attribute\Create;
use Patchlevel\EventSourcing\Attribute\Drop;
use Patchlevel\EventSourcing\Attribute\Subscribe;
use Patchlevel\EventSourcing\Attribute\Projection;
use Patchlevel\EventSourcing\EventBus\Message;
use Patchlevel\EventSourcing\Projection\Projection\ProjectionId;
use Patchlevel\EventSourcing\Projection\Projector\Projector;
use Patchlevel\EventSourcing\Projection\Projector\ProjectorUtil;

final class HotelProjection implements Projector
#[Projection('hotel')]
final class HotelProjector
{
use ProjectorUtil;

public function __construct(
private readonly Connection $db
) {
}

public function targetProjection(): ProjectionId
{
return new ProjectionId('hotel', 1);
}

/**
* @return list<array{id: string, name: string, guests: int}>
*/
public function getHotels(): array
{
return $this->db->fetchAllAssociative('SELECT id, name, guests FROM hotel;')
return $this->db->fetchAllAssociative("SELECT id, name, guests FROM ${this->table()};");
}

#[Subscribe(HotelCreated::class)]
Expand All @@ -187,7 +186,7 @@ final class HotelProjection implements Projector
$event = $message->event();

$this->db->insert(
'hotel',
$this->table(),
[
'id' => $event->hotelId,
'name' => $event->hotelName,
Expand All @@ -200,7 +199,7 @@ final class HotelProjection implements Projector
public function handleGuestIsCheckedIn(Message $message): void
{
$this->db->executeStatement(
'UPDATE hotel SET guests = guests + 1 WHERE id = ?;',
"UPDATE ${this->table()} SET guests = guests + 1 WHERE id = ?;",
[$message->aggregateId()]
);
}
Expand All @@ -209,21 +208,30 @@ final class HotelProjection implements Projector
public function handleGuestIsCheckedOut(Message $message): void
{
$this->db->executeStatement(
'UPDATE hotel SET guests = guests - 1 WHERE id = ?;',
"UPDATE ${this->table()} SET guests = guests - 1 WHERE id = ?;",
[$message->aggregateId()]
);
}

#[Create]
public function create(): void
{
$this->db->executeStatement('CREATE TABLE IF NOT EXISTS hotel (id VARCHAR PRIMARY KEY, name VARCHAR, guests INTEGER);');
$this->db->executeStatement("CREATE TABLE IF NOT EXISTS ${this->table()} (id VARCHAR PRIMARY KEY, name VARCHAR, guests INTEGER);");
}

#[Drop]
public function drop(): void
{
$this->db->executeStatement('DROP TABLE IF EXISTS hotel;');
$this->db->executeStatement("DROP TABLE IF EXISTS ${this->table()};");
}

private function table(): string
{
return sprintf(
'projection_%s_%s',
$this->projectionName(),
$this->projectionVersion()
);
}
}
```
Expand All @@ -234,7 +242,7 @@ final class HotelProjection implements Projector

## Processor

In our example we also want to send an email to the head office as soon as a guest is checked in.
In our example we also want to email the head office as soon as a guest is checked in.

```php
use Patchlevel\EventSourcing\Attribute\Subscribe;
Expand Down Expand Up @@ -296,10 +304,10 @@ $store = new DoctrineDbalStore(
$aggregateRegistry
);

$hotelProjection = new HotelProjection($connection);
$hotelProjector = new HotelProjector($connection);

$projectorRepository = new ProjectorRepository([
$hotelProjection,
$hotelProjector,
]);

$projectionStore = new DoctrineStore($connection);
Expand Down
115 changes: 78 additions & 37 deletions docs/pages/projection.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Projections

With `projections` you can create your data optimized for reading.
With `projections` you can transform your data optimized for reading.
projections can be adjusted, deleted or rebuilt at any time.
This is possible because the source of truth remains untouched
This is possible because the event store remains untouched
and everything can always be reproduced from the events.

A projection can be anything.
Expand All @@ -18,49 +18,40 @@ use Doctrine\DBAL\Connection;
use Patchlevel\EventSourcing\Attribute\Create;
use Patchlevel\EventSourcing\Attribute\Drop;
use Patchlevel\EventSourcing\Attribute\Subscribe;
use Patchlevel\EventSourcing\Attribute\Projection;
use Patchlevel\EventSourcing\EventBus\Message;
use Patchlevel\EventSourcing\Projection\Projection\ProjectionId;
use Patchlevel\EventSourcing\Projection\Projector\Projector;
use Patchlevel\EventSourcing\Projection\Projector\ProjectorUtil;

final class ProfileProjection implements Projector
#[Projection('profile')]
final class ProfileProjector
{
use ProjectorUtil;

public function __construct(
private readonly Connection $connection
) {
}

public function targetProjection(): ProjectionId
{
return new ProjectionId('profile', 1)
}

/**
* @return list<array{id: string, name: string}>
*/
public function getProfiles(): array
{
return $this->connection->fetchAllAssociative(
sprintf('SELECT id, name FROM %s;', $this->table())
);
return $this->connection->fetchAllAssociative("SELECT id, name FROM ${this->table()};");
}

#[Create]
public function create(): void
{
$this->connection->executeStatement(
sprintf(
'CREATE TABLE IF NOT EXISTS %s (id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL);',
$this->table()
)
"CREATE TABLE IF NOT EXISTS ${this->table()} (id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL);"
);
}

#[Drop]
public function drop(): void
{
$this->connection->executeStatement(
sprintf('DROP TABLE IF EXISTS %s;', $this->table())
);
$this->connection->executeStatement("DROP TABLE IF EXISTS ${this->table()};");
}

#[Subscribe(ProfileCreated::class)]
Expand All @@ -69,9 +60,9 @@ final class ProfileProjection implements Projector
$profileCreated = $message->event();

$this->connection->executeStatement(
sprintf('INSERT INTO %s (`id`, `name`) VALUES(:id, :name);', $this->table()),
"INSERT INTO ${this->table()} (id, name) VALUES(?, ?);",
[
'id' => $profileCreated->profileId,
'id' => $profileCreated->profileId->toString(),
'name' => $profileCreated->name
]
);
Expand All @@ -81,21 +72,25 @@ final class ProfileProjection implements Projector
{
return sprintf(
'projection_%s_%s',
$this->targetProjection()->name(),
$this->targetProjection()->version()
$this->projectionName(),
$this->projectionVersion()
);
}
}
```

Each projector is responsible for a specific projection and version.
In order for us to be able to define this, we have to use the `targetProjection` method to return a ProjectionId.
So that several versions of the projection can exist,
the version of the projection should flow into the table or collection name.
Each projector is responsible for a specific projection and version.
This combination of information results in the so-called `project ID`.
In order for us to be able to define this, we have to use the `Projection` attribute.
In our example, the projection is called "profile" and has the version "0" because we did not specify it.
So that there is no problems with existing projection,
both the name of the projection and the version should be part of the table/collection name.
In our example, we build a `table` helper method, what creates the following string: "projection_profile_0".

Projectors can have one `create` and `drop` method that is executed when the projection is created or deleted.
In some cases it may be that no schema has to be created for the projection, as the target does it automatically.
To do this, you must add either the `Create` or `Drop` attribute to the method. The method name itself doesn't matter.
For this there are the attributes `Create` and `Drop`. The method name itself doesn't matter.
In some cases it may be that no schema has to be created for the projection,
as the target does it automatically, so you can skip this.

Otherwise, a projector can subscribe any number of events.
In order to say which method is responsible for which event, you need the `Subscribe` attribute.
Expand All @@ -107,17 +102,46 @@ Several projectors can also listen to the same event.

!!! danger

You should not execute any actions with projectors,
You should not execute any actions like commands with projectors,
otherwise these will be executed again if you rebuild the projection!

!!! tip

If you are using psalm then you can install the event sourcing [plugin](https://github.com/patchlevel/event-sourcing-psalm-plugin)
to make the event method return the correct type.

## Versioning

As soon as the structure of a projection changes, the version must be change or increment.
Otherwise the projectionist will not recognize that the projection has changed and will not rebuild it.
To do this, you have to change the version in the `Projection` attribute.

```php
use Doctrine\DBAL\Connection;
use Patchlevel\EventSourcing\Attribute\Create;
use Patchlevel\EventSourcing\Attribute\Drop;
use Patchlevel\EventSourcing\Attribute\Handle;
use Patchlevel\EventSourcing\Attribute\Projection;
use Patchlevel\EventSourcing\EventBus\Message;

#[Projection('profile', version: 1)]
final class ProfileProjector
{
// ...
}
```

!!! warning

If you change the version, you must also change the table/collection name.

!!! tip

You can also use the `ProjectorUtil` to build the table/collection name.

## Projector Repository

The projector repository can hold and make available all projectors.
The projector repository is responsible for managing the projectors.

```php
use Patchlevel\EventSourcing\Projection\Projector\InMemoryProjectorRepository;
Expand Down Expand Up @@ -145,8 +169,25 @@ If something breaks, the projectionist marks the individual projections as fault
## Projection Id

A projection id consists of a unique name and a version.
It can be defined using the `Projection` attribute.

```php
use Patchlevel\EventSourcing\Attribute\Projection;

#[Projection('profile', version: 1)]
final class ProfileProjector
{
// ...
}
```

As soon as the projection changes, such as the structure or the data, the version of the projection must be incremented.
This tells the projectionist to build an another projection.
This tells the projectionist to build an another projection with this projector.

!!! note

Most databases have a limit on the length of the table/collection name.
The limit is usually 64 characters.

## Projection Position

Expand Down Expand Up @@ -175,7 +216,7 @@ stateDiagram-v2

### New

A projection gets the status new if there is a projector with an unknown projection id.
A projection gets the status new if there is a projector with an unknown `projection id`.
This can happen when either a new projector has been added, the version has changed
or the projection has been manually deleted from the projection store.

Expand All @@ -188,7 +229,7 @@ As soon as the projection is built up to the current status, the status changes
### Active

The active status describes the projections currently being actively managed by the projectionist.
These projections have a projector, follow the event stream and are up to date.
These projections have a projector, follow the event stream and should be up-to-date.

### Outdated

Expand All @@ -197,7 +238,7 @@ that does not have a projector in the source code with a corresponding projectio
then this projection is marked as outdated.
This happens when either the projector has been deleted
or the projection id of a projector has changed.
In the last case there is a new projection.
In the last case there should be a new projection.

An outdated projection does not automatically become active again when the projection id exists again.
This happens, for example, when an old version was deployed again during a rollback.
Expand All @@ -224,7 +265,7 @@ In order for the projectionist to be able to do its work, you have to assemble i

In order for the projectionist to know the status and position of the projections, they must be saved.

Currently there is only the Doctrine Store.
We can use the `DoctrineStore` for this:

```php
use Patchlevel\EventSourcing\Projection\Projection\Store\DoctrineStore;
Expand Down
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ parameters:
path: src/EventBus/Message.php

-
message: "#^Method Patchlevel\\\\EventSourcing\\\\Projection\\\\Projector\\\\InMemoryProjectorRepository\\:\\:projectors\\(\\) should return array\\<int, Patchlevel\\\\EventSourcing\\\\Projection\\\\Projector\\\\Projector\\> but returns array\\<int\\|string, Patchlevel\\\\EventSourcing\\\\Projection\\\\Projector\\\\Projector\\>\\.$#"
message: "#^Method Patchlevel\\\\EventSourcing\\\\Projection\\\\Projector\\\\InMemoryProjectorRepository\\:\\:projectors\\(\\) should return array\\<int, object\\> but returns array\\<int\\|string, object\\>\\.$#"
count: 1
path: src/Projection/Projector/InMemoryProjectorRepository.php

Expand Down
27 changes: 27 additions & 0 deletions src/Attribute/Projection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Attribute;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
final class Projection
{
public function __construct(
private string $name,
private int $version = 0,

Check warning on line 14 in src/Attribute/Projection.php

View workflow job for this annotation

GitHub Actions / Mutation tests (locked, 8.3, ubuntu-latest)

Escaped Mutant for Mutator "DecrementInteger": --- Original +++ New @@ @@ #[Attribute(Attribute::TARGET_CLASS)] final class Projection { - public function __construct(private string $name, private int $version = 0) + public function __construct(private string $name, private int $version = -1) { } public function name() : string

Check warning on line 14 in src/Attribute/Projection.php

View workflow job for this annotation

GitHub Actions / Mutation tests (locked, 8.3, ubuntu-latest)

Escaped Mutant for Mutator "IncrementInteger": --- Original +++ New @@ @@ #[Attribute(Attribute::TARGET_CLASS)] final class Projection { - public function __construct(private string $name, private int $version = 0) + public function __construct(private string $name, private int $version = 1) { } public function name() : string
) {
}

public function name(): string
{
return $this->name;
}

public function version(): int
{
return $this->version;
}
}
Loading
Loading