Skip to content

Commit 31c497b

Browse files
committed
add stateful subscriber feature
1 parent 30220c6 commit 31c497b

8 files changed

Lines changed: 313 additions & 16 deletions

File tree

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
3535
"doctrine/dbal": "^4.4.0",
3636
"doctrine/migrations": "^3.3.2",
37-
"patchlevel/hydrator": "^1.8.0",
37+
"patchlevel/hydrator": "^1.19.0",
3838
"patchlevel/worker": "^1.4.0",
3939
"psr/cache": "^2.0.0 || ^3.0.0",
4040
"psr/clock": "^1.0",

composer.lock

Lines changed: 15 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

phpstan-baseline.neon

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,18 @@ parameters:
162162
count: 1
163163
path: src/Subscription/RetryStrategy/ClockBasedRetryStrategy.php
164164

165+
-
166+
message: '#^Parameter \#1 \$json of function json_decode expects string, mixed given\.$#'
167+
identifier: argument.type
168+
count: 1
169+
path: src/Subscription/StatefulSubscriber/DoctrineStatefulSubscriberStore.php
170+
171+
-
172+
message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\HydratorWithContext\:\:hydrate\(\) expects array\<string, mixed\>, mixed given\.$#'
173+
identifier: argument.type
174+
count: 1
175+
path: src/Subscription/StatefulSubscriber/DoctrineStatefulSubscriberStore.php
176+
165177
-
166178
message: '#^Parameter \#3 \$errorContext of class Patchlevel\\EventSourcing\\Subscription\\SubscriptionError constructor expects list\<array\{namespace\: string, short_name\: string, class\: class\-string, message\: string, code\: int\|string, file\: string, line\: int, trace\: list\<array\{file\?\: string, line\?\: int, function\?\: string, class\?\: string, type\?\: string, args\?\: array\<mixed\>\}\>\}\>\|null, mixed given\.$#'
167179
identifier: argument.type
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Subscription\StatefulSubscriber;
6+
7+
use Doctrine\DBAL\Connection;
8+
use Doctrine\DBAL\Schema\Schema;
9+
use Doctrine\DBAL\Types\Types;
10+
use Patchlevel\EventSourcing\Metadata\Subscriber\AttributeSubscriberMetadataFactory;
11+
use Patchlevel\EventSourcing\Metadata\Subscriber\SubscriberMetadataFactory;
12+
use Patchlevel\EventSourcing\Schema\DoctrineHelper;
13+
use Patchlevel\EventSourcing\Schema\DoctrineSchemaConfigurator;
14+
use Patchlevel\Hydrator\HydratorWithContext;
15+
use Patchlevel\Hydrator\MetadataHydrator;
16+
17+
use function json_decode;
18+
use function json_encode;
19+
20+
final readonly class DoctrineStatefulSubscriberStore implements StatefulSubscriberStore, DoctrineSchemaConfigurator
21+
{
22+
public function __construct(
23+
private Connection $connection,
24+
private HydratorWithContext $hydrator = new MetadataHydrator(),
25+
private SubscriberMetadataFactory $subscriberMetadataFactory = new AttributeSubscriberMetadataFactory(),
26+
private string $tableName = 'stateful_subscriber_state',
27+
) {
28+
}
29+
30+
public function store(StatefulSubscriber $subscriber): void
31+
{
32+
$subscriberId = $this->subscriberId($subscriber);
33+
$data = $this->hydrator->extract($subscriber);
34+
35+
$this->connection->insert(
36+
$this->tableName,
37+
[
38+
'id' => $subscriberId,
39+
'state' => json_encode($data),
40+
],
41+
);
42+
}
43+
44+
public function load(StatefulSubscriber $subscriber): void
45+
{
46+
$subscriberId = $this->subscriberId($subscriber);
47+
48+
$data = $this->connection->fetchAssociative(
49+
'SELECT * FROM ' . $this->tableName . ' WHERE id = ?',
50+
[$subscriberId],
51+
);
52+
53+
if (!$data) {
54+
return;
55+
}
56+
57+
$this->hydrator->hydrate(
58+
$subscriber::class,
59+
json_decode($data['state'], true),
60+
[HydratorWithContext::OBJECT_TO_POPULATE => $subscriber],
61+
);
62+
}
63+
64+
public function configureSchema(Schema $schema, Connection $connection): void
65+
{
66+
if (!DoctrineHelper::sameDatabase($this->connection, $connection)) {
67+
return;
68+
}
69+
70+
$table = $schema->createTable($this->tableName);
71+
72+
$table->addColumn('id', Types::STRING)
73+
->setLength(255)
74+
->setNotnull(true);
75+
$table->addColumn('state', Types::JSON)
76+
->setNotnull(true);
77+
78+
$table->setPrimaryKey(['id']);
79+
}
80+
81+
public function subscriberId(StatefulSubscriber $projection): string
82+
{
83+
return $this->subscriberMetadataFactory->metadata($projection::class)->id;
84+
}
85+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Subscription\StatefulSubscriber;
6+
7+
use Patchlevel\EventSourcing\Subscription\Subscriber\BatchableSubscriber;
8+
use Patchlevel\Hydrator\Attribute\Ignore;
9+
10+
abstract class StatefulSubscriber implements BatchableSubscriber
11+
{
12+
public function __construct(
13+
#[Ignore]
14+
private readonly StatefulSubscriberStore $store,
15+
) {
16+
$this->store->load($this);
17+
}
18+
19+
public function beginBatch(): void
20+
{
21+
// do nothing
22+
}
23+
24+
public function commitBatch(): void
25+
{
26+
$this->store->store($this);
27+
}
28+
29+
public function rollbackBatch(): void
30+
{
31+
$this->store->load($this);
32+
}
33+
34+
public function forceCommit(): bool
35+
{
36+
return false;
37+
}
38+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Subscription\StatefulSubscriber;
6+
7+
interface StatefulSubscriberStore
8+
{
9+
public function store(StatefulSubscriber $subscriber): void;
10+
11+
public function load(StatefulSubscriber $subscriber): void;
12+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber;
6+
7+
use Patchlevel\EventSourcing\Attribute\Projector;
8+
use Patchlevel\EventSourcing\Attribute\Subscribe;
9+
use Patchlevel\EventSourcing\Subscription\StatefulSubscriber\StatefulSubscriber;
10+
use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated;
11+
use Patchlevel\EventSourcing\Tests\Integration\Subscription\ProfileId;
12+
13+
#[Projector('profile_inline')]
14+
final class ProfileInlineStatefulSubscriber extends StatefulSubscriber
15+
{
16+
/** @var array<string, string> */
17+
public array $profiles = [];
18+
19+
#[Subscribe(ProfileCreated::class)]
20+
public function handleProfileCreated(ProfileCreated $profileCreated): void
21+
{
22+
$this->profiles[$profileCreated->profileId->toString()] = $profileCreated->name;
23+
}
24+
25+
public function findById(ProfileId $id): string|null
26+
{
27+
return $this->profiles[$id->toString()] ?? null;
28+
}
29+
}

0 commit comments

Comments
 (0)