diff --git a/README.md b/README.md index 08776cd..da99919 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,12 @@ ## Features -* [Property initialization](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started#property-initialization) for aggregate roots and child aggregates, so PHPStan does not report false uninitialized property errors. +* [Property initialization](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started#property-initialization) for aggregate roots, so PHPStan does not report false uninitialized property errors. * [Unused properties](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started#unused-properties) are reported when no apply method writes them, because such a property can never receive state from an event. * [Write only properties](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started#write-only-properties) are reported when apply methods store state that is never read, because state that is not used to check invariants belongs in a projection. * [Recording in apply methods](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started#recording-in-apply-methods) is reported as an error, because recording events while replaying them leads to duplicated events. * [Writing state outside apply methods](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started#writing-state-outside-apply-methods) is reported as an error, because state that is not derived from an event is lost when the aggregate is reloaded. +* [Typed decisions](https://patchlevel.dev/docs/event-sourcing-phpstan-extension/latest/getting-started#typed-decisions) for the dynamic consistency boundary, so PHPStan knows the state of every projection a decision reads, also when several are read at once. ## Installation diff --git a/composer.json b/composer.json index df7485f..47d511d 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ }, "require-dev": { "patchlevel/coding-standard": "^1.3.0", - "patchlevel/event-sourcing": "^3.4.0", + "patchlevel/event-sourcing": "4.0.x-dev", "phpstan/phpstan-strict-rules": "^2.0.4", "roave/security-advisories": "dev-master", "wnx/commonmark-markdown-renderer": "^1.6" diff --git a/composer.lock b/composer.lock index 730f3ad..920403d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7cb749825cf320425ee3f31b906b5649", + "content-hash": "b363046df53e136bcc2353ea4eaa432e", "packages": [ { "name": "phpstan/phpstan", @@ -1055,24 +1055,24 @@ }, { "name": "patchlevel/event-sourcing", - "version": "3.22.1", + "version": "4.0.x-dev", "source": { "type": "git", "url": "https://github.com/patchlevel/event-sourcing.git", - "reference": "809e5641b1d2caf575fbddc5d0a988055af0aaf1" + "reference": "88eaac6892931d167a27511d1c5cdb6fc4d1db25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/event-sourcing/zipball/809e5641b1d2caf575fbddc5d0a988055af0aaf1", - "reference": "809e5641b1d2caf575fbddc5d0a988055af0aaf1", + "url": "https://api.github.com/repos/patchlevel/event-sourcing/zipball/88eaac6892931d167a27511d1c5cdb6fc4d1db25", + "reference": "88eaac6892931d167a27511d1c5cdb6fc4d1db25", "shasum": "" }, "require": { "doctrine/dbal": "^4.4.0", "doctrine/migrations": "^3.3.2", - "patchlevel/hydrator": "^1.8.0", - "patchlevel/worker": "^1.4.0", - "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "patchlevel/hydrator": "^2.0.2", + "patchlevel/worker": "^1.7.0", + "php": "~8.4.0 || ~8.5.0", "psr/cache": "^2.0.0 || ^3.0.0", "psr/clock": "^1.0", "psr/container": "^2.0", @@ -1086,10 +1086,11 @@ }, "require-dev": { "doctrine/orm": "^2.18.0 || ^3.0.0", - "ext-pdo_sqlite": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "ext-pdo_sqlite": "~8.4.0 || ~8.5.0", "infection/infection": "^0.32.0", "league/commonmark": "^2.6.1", "patchlevel/coding-standard": "^1.3.0", + "patchlevel/event-sourcing-phpstan-extension": "^1.2.0", "phpat/phpat": "^0.12.0", "phpbench/phpbench": "^1.4.1", "phpstan/phpstan": "^2.1.11", @@ -1124,7 +1125,7 @@ } ], "description": "A lightweight but also all-inclusive event sourcing library with a focus on developer experience", - "homepage": "https://event-sourcing.patchlevel.io", + "homepage": "https://patchlevel.dev/docs/event-sourcing/latest", "keywords": [ "Domain Driven Design", "aggregates", @@ -1143,22 +1144,22 @@ ], "support": { "issues": "https://github.com/patchlevel/event-sourcing/issues", - "source": "https://github.com/patchlevel/event-sourcing/tree/3.22.1" + "source": "https://github.com/patchlevel/event-sourcing/tree/4.0.x" }, - "time": "2026-10-07T10:28:14+00:00" + "time": "2026-10-16T20:28:35+00:00" }, { "name": "patchlevel/hydrator", - "version": "1.24.3", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/patchlevel/hydrator.git", - "reference": "b0c79d2b80ccf0ab95cc9b4494105af6b6bf00de" + "reference": "16dd8ddfe6f27a57fc793637bdee691238592992" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/b0c79d2b80ccf0ab95cc9b4494105af6b6bf00de", - "reference": "b0c79d2b80ccf0ab95cc9b4494105af6b6bf00de", + "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/16dd8ddfe6f27a57fc793637bdee691238592992", + "reference": "16dd8ddfe6f27a57fc793637bdee691238592992", "shasum": "" }, "require": { @@ -1166,7 +1167,6 @@ "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "psr/cache": "^2.0.0 || ^3.0.0", "psr/simple-cache": "^2.0.0 || ^3.0.0", - "symfony/event-dispatcher": "^5.4.29 || ^6.4.0 || ^7.0.0 || ^8.0.0", "symfony/type-info": "^7.3.0 || ^8.0.0" }, "require-dev": { @@ -1178,7 +1178,8 @@ "phpstan/phpstan-phpunit": "^2.0.15", "phpunit/phpunit": "^11.5.53", "symfony/cache": "^6.4.0 || ^7.0.0 || ^8.0.0", - "symfony/var-dumper": "^5.4.29 || ^6.4.0 || ^7.0.0 || ^8.0.0" + "symfony/var-dumper": "^5.4.29 || ^6.4.0 || ^7.0.0 || ^8.0.0", + "wnx/commonmark-markdown-renderer": "^1.6" }, "type": "library", "autoload": { @@ -1200,17 +1201,21 @@ "email": "david.badura@patchlevel.de" } ], - "description": "Hydrator", - "homepage": "https://github.com/patchlevel/hydrator", + "description": "A library for seamless hydration of objects to arrays - and back again, optimized for developer experience and performance", + "homepage": "https://patchlevel.dev/docs/hydrator/latest", "keywords": [ + "denormalizer", "hydrator", + "normalizer", + "object mapping", + "patchlevel", "serializer" ], "support": { "issues": "https://github.com/patchlevel/hydrator/issues", - "source": "https://github.com/patchlevel/hydrator/tree/1.24.3" + "source": "https://github.com/patchlevel/hydrator/tree/2.1.0" }, - "time": "2026-10-07T10:27:41+00:00" + "time": "2026-10-17T20:59:04+00:00" }, { "name": "patchlevel/worker", @@ -3129,16 +3134,16 @@ }, { "name": "symfony/console", - "version": "v8.1.8", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "4b81146e3ee248ea969186f85499b54fbc78db08" + "reference": "630e74196a2635ec0f06c12bd3fb47cfedf85157" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/4b81146e3ee248ea969186f85499b54fbc78db08", - "reference": "4b81146e3ee248ea969186f85499b54fbc78db08", + "url": "https://api.github.com/repos/symfony/console/zipball/630e74196a2635ec0f06c12bd3fb47cfedf85157", + "reference": "630e74196a2635ec0f06c12bd3fb47cfedf85157", "shasum": "" }, "require": { @@ -3205,7 +3210,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.8" + "source": "https://github.com/symfony/console/tree/v8.1.9" }, "funding": [ { @@ -3225,7 +3230,7 @@ "type": "tidelift" } ], - "time": "2026-09-18T16:00:27+00:00" + "time": "2026-10-12T19:11:16+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3617,16 +3622,16 @@ }, { "name": "symfony/polyfill-deepclone", - "version": "v1.43.1", + "version": "v1.43.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-deepclone.git", - "reference": "aaba44b6ee7299ae65717811c55b57ab9bc0fedd" + "reference": "82274308b59f6e96f53a3fb37fc40877d843e5a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/aaba44b6ee7299ae65717811c55b57ab9bc0fedd", - "reference": "aaba44b6ee7299ae65717811c55b57ab9bc0fedd", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/82274308b59f6e96f53a3fb37fc40877d843e5a5", + "reference": "82274308b59f6e96f53a3fb37fc40877d843e5a5", "shasum": "" }, "require": { @@ -3680,7 +3685,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.43.1" + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.43.2" }, "funding": [ { @@ -3700,7 +3705,7 @@ "type": "tidelift" } ], - "time": "2026-10-06T19:46:18+00:00" + "time": "2026-10-14T07:17:09+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -4273,16 +4278,16 @@ }, { "name": "symfony/string", - "version": "v8.1.7", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c" + "reference": "07fb208558f941eab50ec49c89c2a6a93fb11f01" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/d950140b5f56f31901e5b7a0c04ffc3a3deb943c", - "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c", + "url": "https://api.github.com/repos/symfony/string/zipball/07fb208558f941eab50ec49c89c2a6a93fb11f01", + "reference": "07fb208558f941eab50ec49c89c2a6a93fb11f01", "shasum": "" }, "require": { @@ -4339,7 +4344,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.7" + "source": "https://github.com/symfony/string/tree/v8.1.9" }, "funding": [ { @@ -4359,7 +4364,7 @@ "type": "tidelift" } ], - "time": "2026-09-11T14:51:16+00:00" + "time": "2026-10-03T15:16:03+00:00" }, { "name": "symfony/type-info", @@ -4445,22 +4450,22 @@ }, { "name": "symfony/var-exporter", - "version": "v8.1.6", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "802362f41128c430fd6685491e1fb72127fae78a" + "reference": "3717217839a76f6fa284931a224c4cada514da2a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/802362f41128c430fd6685491e1fb72127fae78a", - "reference": "802362f41128c430fd6685491e1fb72127fae78a", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/3717217839a76f6fa284931a224c4cada514da2a", + "reference": "3717217839a76f6fa284931a224c4cada514da2a", "shasum": "" }, "require": { "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-deepclone": "^1.40" + "symfony/polyfill-deepclone": "^1.43.1" }, "require-dev": { "symfony/property-access": "^7.4|^8.0", @@ -4504,7 +4509,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.1.6" + "source": "https://github.com/symfony/var-exporter/tree/v8.1.9" }, "funding": [ { @@ -4524,7 +4529,7 @@ "type": "tidelift" } ], - "time": "2026-08-29T06:30:03+00:00" + "time": "2026-10-07T07:28:40+00:00" }, { "name": "wnx/commonmark-markdown-renderer", @@ -4591,6 +4596,7 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": { + "patchlevel/event-sourcing": 20, "roave/security-advisories": 20 }, "prefer-stable": false, diff --git a/docs/getting-started.md b/docs/getting-started.md index 74d81ad..b82cf29 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -29,9 +29,9 @@ that are assigned inside apply methods, not in a constructor. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; -use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Id; +use Patchlevel\EventSourcing\Identifier\Uuid; final class Profile extends BasicAggregateRoot { @@ -68,7 +68,7 @@ the events are applied. The extension knows that `Profile` is an aggregate and m as initialized, so the analysis passes. :::note -This works for both aggregate roots and child aggregates: any class implementing `AggregateRoot` or `ChildAggregate` has its properties treated as initialized. +This works for every class implementing `AggregateRoot`, not only for subclasses of `BasicAggregateRoot`. ::: ## Unused properties @@ -79,9 +79,9 @@ extension reports it as unused: ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; -use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Id; +use Patchlevel\EventSourcing\Identifier\Uuid; final class Profile extends BasicAggregateRoot { @@ -118,9 +118,9 @@ so a property that is written but never read is not part of any decision. The ex ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; -use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Id; +use Patchlevel\EventSourcing\Identifier\Uuid; final class Profile extends BasicAggregateRoot { @@ -152,8 +152,7 @@ but never read, so it is not used to check any invariants. for reading belongs in a projection. ``` Any read counts: an invariant check in a command method, a read inside an apply method, or a getter. -Only private properties are checked, and properties the library itself reads, `#[Id]` and -`#[ChildAggregate]`, are skipped. +Only private properties are checked, and the `#[Id]` property is skipped, because the library itself reads it. ## Recording in apply methods @@ -163,9 +162,9 @@ flags this: ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; -use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Id; +use Patchlevel\EventSourcing\Identifier\Uuid; final class Profile extends BasicAggregateRoot { @@ -201,9 +200,9 @@ lost the next time the aggregate is loaded from the store. The extension flags e ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; -use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Id; +use Patchlevel\EventSourcing\Identifier\Uuid; final class Profile extends BasicAggregateRoot { @@ -243,6 +242,77 @@ properties. This also applies to helper methods inside the aggregate: a private method that assigns a property is reported at the offending line, no matter where it is called from. ::: +## Typed decisions + +With the [dynamic consistency boundary](https://patchlevel.dev/docs/event-sourcing/latest/dynamic-consistency-boundary), +a command handler reads the state of projections through a `Decision`. Reading a single projection is typed by +PHPStan itself through the generic of the projection. Reading several projections at once returns an array, and +PHPStan alone only knows its values as `mixed`. The extension types each entry with the state of its projection: + +```php +use Patchlevel\EventSourcing\Attribute\Handle; +use Patchlevel\EventSourcing\DecisionModel\Decision; + +final class ChangeNameHandler +{ + #[Handle] + public function __invoke(ChangeName $command, Decision $decision): void + { + $state = $decision->read([ + 'exists' => new ProfileExists($command->profileId), + 'name' => new ProfileName($command->profileId), + ]); // array{exists: bool, name: string} + + if (!$state['exists']) { + throw new RuntimeException('Profile does not exist'); + } + + if ($state['name'] === $command->name) { + return; + } + + $decision->append([new NameChanged($command->profileId, $command->name)]); + } +} +``` +The state type is taken from the generic of the projection, like `@extends BasicProjection`. +If a projection does not declare it, the extension uses the return type of `initialState()` instead, +so this projection is typed as `bool` as well: + +```php +use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\Identifier\Uuid; +use Patchlevel\EventSourcing\Projection\BasicProjection; + +final class ProfileExists extends BasicProjection +{ + public function __construct( + private readonly Uuid $profileId, + ) { + } + + public function initialState(): bool + { + return false; + } + + #[Apply] + public function applyProfileCreated(bool $state, ProfileCreated $event): bool + { + return true; + } + + /** @return list */ + protected function tagFilter(): array + { + return ["profile:{$this->profileId->toString()}"]; + } +} +``` +:::note +The same applies to `ProjectionBuilder::build()`, which returns the states of the given projections without a decision. +::: + ## Configuration All rules are enabled by default. You can deactivate single rules in your `phpstan.neon`, @@ -262,10 +332,11 @@ parameters: With the extension enabled, PHPStan understands your aggregates: it stops complaining about properties that are initialized through events, it reports properties that no apply method ever writes, it reports state that is written but never used for a decision, it fails the build when an apply method records an event, and it fails the build when -aggregate state is written outside an apply method. You get accurate static analysis without writing a single -annotation. You get accurate static analysis without writing a single annotation. +aggregate state is written outside an apply method. For decisions, it knows the state of every projection you read. +You get accurate static analysis without writing a single annotation. ## Learn more * [What the extension offers](introduction.md) * [How to build aggregates with patchlevel/event-sourcing](https://patchlevel.dev/docs/event-sourcing/latest) +* [How to make decisions with the dynamic consistency boundary](https://patchlevel.dev/docs/event-sourcing/latest/dynamic-consistency-boundary) diff --git a/docs/introduction.md b/docs/introduction.md index 421ff40..2972c8d 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -6,11 +6,12 @@ and it catches common mistakes before they ever reach runtime. ## Features -* [Property initialization](getting-started.md#property-initialization) for aggregate roots and child aggregates, so PHPStan does not report false uninitialized property errors. +* [Property initialization](getting-started.md#property-initialization) for aggregate roots, so PHPStan does not report false uninitialized property errors. * [Unused properties](getting-started.md#unused-properties) are reported when no apply method writes them, because such a property can never receive state from an event. * [Write only properties](getting-started.md#write-only-properties) are reported when apply methods store state that is never read, because state that is not used to check invariants belongs in a projection. * [Recording in apply methods](getting-started.md#recording-in-apply-methods) is reported as an error, because recording events while replaying them leads to duplicated events. * [Writing state outside apply methods](getting-started.md#writing-state-outside-apply-methods) is reported as an error, because state that is not derived from an event is lost when the aggregate is reloaded. +* [Typed decisions](getting-started.md#typed-decisions) for the dynamic consistency boundary, so PHPStan knows the state of every projection a decision reads, also when several are read at once. ## Installation @@ -31,5 +32,5 @@ and single rules can be deactivated through the [configuration parameters](getti * [patchlevel/event-sourcing](https://github.com/patchlevel/event-sourcing) is the library this extension analyses. :::tip -New to the extension? The [getting started](getting-started.md) guide walks you through enabling it and seeing both rules in action. +New to the extension? The [getting started](getting-started.md) guide walks you through enabling it and seeing every check in action. ::: diff --git a/extension.neon b/extension.neon index 706536c..b7a4b9f 100644 --- a/extension.neon +++ b/extension.neon @@ -46,3 +46,13 @@ services: - class: Patchlevel\EventSourcingPHPStanExtension\DontWriteStateWhenNotApplyingRule + + - + class: Patchlevel\EventSourcingPHPStanExtension\DecisionExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + + - + class: Patchlevel\EventSourcingPHPStanExtension\ProjectionBuilderExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 50d58be..59ad6c3 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,11 +1,5 @@ parameters: ignoreErrors: - - - rawMessage: 'Method Patchlevel\EventSourcing\Aggregate\BasicChildAggregate::recordThat() records an event and is called from apply method applyNameChanged().' - identifier: patchlevel.noRecordThatWhenApplying - count: 1 - path: tests/Invalid/PersonalInformation.php - - rawMessage: 'Aggregate state property "count" should only be written in an #[Apply] method, but is written in "Patchlevel\EventSourcingPHPStanExtension\Tests\Invalid\Profile::otherWriteForms()".' identifier: patchlevel.noStateWriteWhenNotApplying diff --git a/src/AggregateRootExtension.php b/src/AggregateRootExtension.php index 797b5a6..dad5e81 100644 --- a/src/AggregateRootExtension.php +++ b/src/AggregateRootExtension.php @@ -5,7 +5,6 @@ namespace Patchlevel\EventSourcingPHPStanExtension; use Patchlevel\EventSourcing\Aggregate\AggregateRoot; -use Patchlevel\EventSourcing\Aggregate\ChildAggregate; use PHPStan\Reflection\PropertyReflection; use PHPStan\Rules\Properties\ReadWritePropertiesExtension; @@ -31,7 +30,7 @@ private function isAggregate(PropertyReflection $property): bool $interfaces = $property->getDeclaringClass()->getInterfaces(); foreach ($interfaces as $interface) { - if ($interface->getName() === AggregateRoot::class || $interface->getName() === ChildAggregate::class) { + if ($interface->getName() === AggregateRoot::class) { return true; } } diff --git a/src/DecisionExtension.php b/src/DecisionExtension.php new file mode 100644 index 0000000..b9e9801 --- /dev/null +++ b/src/DecisionExtension.php @@ -0,0 +1,77 @@ +getName() === 'read'; + } + + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): Type|null { + if (count($methodCall->getArgs()) === 0) { + return null; + } + + $type = $scope->getType($methodCall->getArgs()[0]->value); + + if ($type->isObject()->yes()) { + return self::stateType($type, $scope); + } + + if (!$type->isConstantArray()->yes()) { + return null; + } + + return $type->traverse(static function (Type $type) use ($scope): Type { + if (!$type->isObject()->yes()) { + return $type; + } + + return self::stateType($type, $scope) ?? $type; + }); + } + + /** + * The state type of the projection: its generic type if declared, otherwise the return type of initialState(). + */ + private static function stateType(Type $projection, Scope $scope): Type|null + { + $templateType = $projection->getTemplateType(Projection::class, 'S'); + + if (!$templateType instanceof MixedType) { + return $templateType; + } + + if (!$projection->hasMethod('initialState')->yes()) { + return null; + } + + $variants = $projection->getMethod('initialState', $scope)->getVariants(); + + return ($variants[0] ?? null)?->getReturnType(); + } +} diff --git a/src/DontRecordWhenApplyingExtension.php b/src/DontRecordWhenApplyingExtension.php index b30a796..01707d7 100644 --- a/src/DontRecordWhenApplyingExtension.php +++ b/src/DontRecordWhenApplyingExtension.php @@ -5,7 +5,6 @@ namespace Patchlevel\EventSourcingPHPStanExtension; use Patchlevel\EventSourcing\Aggregate\AggregateRoot; -use Patchlevel\EventSourcing\Aggregate\ChildAggregate; use Patchlevel\EventSourcing\Attribute\Apply; use PhpParser\Node; use PhpParser\Node\Expr\MethodCall; @@ -80,8 +79,7 @@ public function isRestrictedMethodUsage( private function isAggregate(ClassReflection $classReflection): bool { - return $classReflection->implementsInterface(AggregateRoot::class) - || $classReflection->implementsInterface(ChildAggregate::class); + return $classReflection->implementsInterface(AggregateRoot::class); } private function isApplyMethod(FunctionReflection|MethodReflection $function): bool diff --git a/src/ProjectionBuilderExtension.php b/src/ProjectionBuilderExtension.php new file mode 100644 index 0000000..2f86098 --- /dev/null +++ b/src/ProjectionBuilderExtension.php @@ -0,0 +1,61 @@ +getName() === 'build'; + } + + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): Type|null { + if (count($methodCall->getArgs()) === 0) { + return null; + } + + $arg = $methodCall->getArgs()[0]->value; + $type = $scope->getType($arg); + + if (!$type->isConstantArray()->yes()) { + return null; + } + + return $type->traverse(static function (Type $type) use ($scope) { + if (!$type->isObject()->yes()) { + return $type; + } + + if (!$type->hasMethod('initialState')->yes()) { + return $type; + } + + $method = $type->getMethod('initialState', $scope); + $variants = $method->getVariants(); + + $acceptor = $variants[0] ?? null; + + return $acceptor?->getReturnType() ?? $type; + }); + } +} diff --git a/src/UnusedPropertyRule.php b/src/UnusedPropertyRule.php index 643892e..57b8c85 100644 --- a/src/UnusedPropertyRule.php +++ b/src/UnusedPropertyRule.php @@ -5,7 +5,6 @@ namespace Patchlevel\EventSourcingPHPStanExtension; use Patchlevel\EventSourcing\Aggregate\AggregateRoot; -use Patchlevel\EventSourcing\Aggregate\ChildAggregate; use Patchlevel\EventSourcing\Attribute\Apply; use PhpParser\Node; use PhpParser\Node\Expr; @@ -52,10 +51,7 @@ public function processNode(Node $node, Scope $scope): array { $classReflection = $node->getClassReflection(); - if ( - !$classReflection->implementsInterface(AggregateRoot::class) - && !$classReflection->implementsInterface(ChildAggregate::class) - ) { + if (!$classReflection->implementsInterface(AggregateRoot::class)) { return []; } diff --git a/src/WriteOnlyPropertyRule.php b/src/WriteOnlyPropertyRule.php index 177f009..4acba45 100644 --- a/src/WriteOnlyPropertyRule.php +++ b/src/WriteOnlyPropertyRule.php @@ -5,9 +5,7 @@ namespace Patchlevel\EventSourcingPHPStanExtension; use Patchlevel\EventSourcing\Aggregate\AggregateRoot; -use Patchlevel\EventSourcing\Aggregate\ChildAggregate; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\Attribute\ChildAggregate as ChildAggregateAttribute; use Patchlevel\EventSourcing\Attribute\Id; use PhpParser\Node; use PhpParser\Node\Expr; @@ -57,10 +55,7 @@ public function processNode(Node $node, Scope $scope): array { $classReflection = $node->getClassReflection(); - if ( - !$classReflection->implementsInterface(AggregateRoot::class) - && !$classReflection->implementsInterface(ChildAggregate::class) - ) { + if (!$classReflection->implementsInterface(AggregateRoot::class)) { return []; } @@ -149,7 +144,7 @@ private function isLibraryReadProperty(Property $property): bool { foreach ($property->attrGroups as $attrGroup) { foreach ($attrGroup->attrs as $attr) { - if ($attr->name->toString() === Id::class || $attr->name->toString() === ChildAggregateAttribute::class) { + if ($attr->name->toString() === Id::class) { return true; } } diff --git a/tests/Invalid/PersonalInformation.php b/tests/Invalid/PersonalInformation.php deleted file mode 100644 index 7cebe4d..0000000 --- a/tests/Invalid/PersonalInformation.php +++ /dev/null @@ -1,26 +0,0 @@ -name = $event->name; - $this->recordThat(new NameChanged($event->name)); - } - - public function name(): string - { - return $this->name; - } -} diff --git a/tests/Invalid/Profile.php b/tests/Invalid/Profile.php index 8c8892a..e8e225e 100644 --- a/tests/Invalid/Profile.php +++ b/tests/Invalid/Profile.php @@ -3,7 +3,7 @@ namespace Patchlevel\EventSourcingPHPStanExtension\Tests\Invalid; use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; -use Patchlevel\EventSourcing\Aggregate\Uuid; +use Patchlevel\EventSourcing\Identifier\Uuid; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Handle; use Patchlevel\EventSourcing\Attribute\Id; diff --git a/tests/Valid/CreateInvoice.php b/tests/Valid/CreateInvoice.php new file mode 100644 index 0000000..3b1085a --- /dev/null +++ b/tests/Valid/CreateInvoice.php @@ -0,0 +1,13 @@ +read([ + 'nextInvoiceNumber' => new NextInvoiceNumberProjection(), + 'invoiceCount' => new InvoiceCountProjection(), + ]); + + [$nextInvoiceNumber, $invoiceCount] = $decision->read([ + new NextInvoiceNumberProjection(), + new InvoiceCountProjection(), + ]); + + $decision->append([ + new InvoiceCreated( + $state['nextInvoiceNumber'] + $state['invoiceCount'] + $nextInvoiceNumber + $invoiceCount, + $decision->read(new NextInvoiceNumberProjection()) + $decision->read(new InvoiceCountProjection()), + ), + ]); + } +} diff --git a/tests/Valid/InvoiceCountProjection.php b/tests/Valid/InvoiceCountProjection.php new file mode 100644 index 0000000..7b667ed --- /dev/null +++ b/tests/Valid/InvoiceCountProjection.php @@ -0,0 +1,27 @@ + */ + public function tagFilter(): array + { + return []; + } + + public function initialState(): int + { + return 0; + } + + public function applyInvoiceCreated(int $state, InvoiceCreated $event): int + { + return $state + 1; + } +} diff --git a/tests/Valid/InvoiceCreated.php b/tests/Valid/InvoiceCreated.php new file mode 100644 index 0000000..ccba6cd --- /dev/null +++ b/tests/Valid/InvoiceCreated.php @@ -0,0 +1,19 @@ +projectionBuilder->build([ + 'nextInvoiceNumber' => new NextInvoiceNumberProjection(), + 'invoiceCount' => new InvoiceCountProjection(), + ]); + + return $state['nextInvoiceNumber'] - 1 + $state['invoiceCount']; + } +} diff --git a/tests/Valid/NextInvoiceNumberProjection.php b/tests/Valid/NextInvoiceNumberProjection.php new file mode 100644 index 0000000..c04e29d --- /dev/null +++ b/tests/Valid/NextInvoiceNumberProjection.php @@ -0,0 +1,27 @@ + */ +final class NextInvoiceNumberProjection extends BasicProjection +{ + /** @return list */ + public function tagFilter(): array + { + return []; + } + + public function initialState(): int + { + return 1; + } + + public function applyInvoiceCreated(int $state, InvoiceCreated $event): int + { + return $state + 1; + } +} diff --git a/tests/Valid/PersonalInformation.php b/tests/Valid/PersonalInformation.php deleted file mode 100644 index 096748f..0000000 --- a/tests/Valid/PersonalInformation.php +++ /dev/null @@ -1,39 +0,0 @@ -recordThat(new ProfileCreated($id, $name)); - - return $self; - } - - #[Apply] - protected function applyProfileCreated(ProfileCreated $event): void - { - $this->id = $event->id; - $this->name = $event->name; - } - - public function id(): Uuid - { - return $this->id; - } - - public function name(): string - { - return $this->name; - } -} \ No newline at end of file diff --git a/tests/Valid/ProductId.php b/tests/Valid/ProductId.php index 59327f4..99af939 100644 --- a/tests/Valid/ProductId.php +++ b/tests/Valid/ProductId.php @@ -4,10 +4,10 @@ namespace Patchlevel\EventSourcingPHPStanExtension\Tests\Valid; -use Patchlevel\EventSourcing\Aggregate\AggregateRootId; -use Patchlevel\EventSourcing\Aggregate\RamseyUuidV7Behaviour; +use Patchlevel\EventSourcing\Identifier\Identifier; +use Patchlevel\EventSourcing\Identifier\RamseyUuidV7Behaviour; -final class ProductId implements AggregateRootId +final class ProductId implements Identifier { use RamseyUuidV7Behaviour; } diff --git a/tests/Valid/Profile.php b/tests/Valid/Profile.php index a14cc61..b797449 100644 --- a/tests/Valid/Profile.php +++ b/tests/Valid/Profile.php @@ -3,7 +3,7 @@ namespace Patchlevel\EventSourcingPHPStanExtension\Tests\Valid; use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; -use Patchlevel\EventSourcing\Aggregate\Uuid; +use Patchlevel\EventSourcing\Identifier\Uuid; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Id; diff --git a/tests/Valid/ProfileCreated.php b/tests/Valid/ProfileCreated.php index bca8ff6..7f7a7a2 100644 --- a/tests/Valid/ProfileCreated.php +++ b/tests/Valid/ProfileCreated.php @@ -2,7 +2,7 @@ namespace Patchlevel\EventSourcingPHPStanExtension\Tests\Valid; -use Patchlevel\EventSourcing\Aggregate\Uuid; +use Patchlevel\EventSourcing\Identifier\Uuid; use Patchlevel\EventSourcing\Serializer\Normalizer\IdNormalizer; class ProfileCreated diff --git a/tests/Valid/StockId.php b/tests/Valid/StockId.php index fc2d1eb..59103b7 100644 --- a/tests/Valid/StockId.php +++ b/tests/Valid/StockId.php @@ -4,10 +4,10 @@ namespace Patchlevel\EventSourcingPHPStanExtension\Tests\Valid; -use Patchlevel\EventSourcing\Aggregate\AggregateRootId; -use Patchlevel\EventSourcing\Aggregate\RamseyUuidV7Behaviour; +use Patchlevel\EventSourcing\Identifier\Identifier; +use Patchlevel\EventSourcing\Identifier\RamseyUuidV7Behaviour; -final class StockId implements AggregateRootId +final class StockId implements Identifier { use RamseyUuidV7Behaviour; diff --git a/tests/Valid/Usage.php b/tests/Valid/Usage.php index 4544aca..b90f890 100644 --- a/tests/Valid/Usage.php +++ b/tests/Valid/Usage.php @@ -4,7 +4,7 @@ namespace Patchlevel\EventSourcingPHPStanExtension\Tests\Valid; -use Patchlevel\EventSourcing\Aggregate\Uuid; +use Patchlevel\EventSourcing\Identifier\Uuid; final class Usage {