├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ ├── documentation.yml │ └── release.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── benchmark ├── fill_storage.php └── load.php ├── blackbox.php ├── codecov.yml ├── composer.json ├── docker-compose.yml ├── documentation ├── adapters │ ├── elasticsearch.md │ ├── filesystem.md │ ├── index.md │ └── sql.md ├── assets │ ├── favicon.png │ ├── fonts │ │ └── MonaspaceNeon-Regular.woff │ ├── logo.svg │ └── stylesheets │ │ └── extra.css ├── benchmark.md ├── blog │ ├── .authors.yml │ ├── index.md │ └── posts │ │ ├── float-support.md │ │ ├── mass-update.md │ │ └── pseudo-union-type.md ├── effects │ └── index.md ├── enums.md ├── getting-started │ ├── aggregate.md │ ├── count.md │ ├── index.md │ ├── persist.md │ ├── remove.md │ ├── retrieve.md │ └── update.md ├── index.md ├── issues.md ├── limitations.md ├── mapping │ ├── collections.md │ ├── entities.md │ ├── index.md │ ├── optionals.md │ └── type.md ├── pagination.md ├── philosophy.md ├── specifications │ ├── count.md │ ├── cross-matching.md │ ├── custom.md │ ├── index.md │ ├── remove.md │ └── retrieve.md ├── terminology.md ├── upgrade │ └── v4-to-v5.md └── use-cases │ ├── elasticsearch.md │ ├── export.md │ └── import.md ├── fixtures ├── AddressValue.php ├── CreatedAt.php ├── CreatedAtType.php ├── Ids.php ├── MainAddress.php ├── Random.php ├── Role.php ├── Sibling.php ├── Sortable.php ├── SortableType.php ├── User.php ├── User │ └── Address.php └── Username.php ├── mkdocs.yml ├── properties ├── AddAggregate.php ├── AddElementToCollections.php ├── AddingOutsideOfTransactionIsNotAllowed.php ├── Any.php ├── ContainsAggregate.php ├── CrossAggregateMatching.php ├── DroppingMoreElementsThanWasTakenReturnsNothing.php ├── EffectChildAddOnAggregate.php ├── EffectChildAddOnAllAggregates.php ├── EffectChildRemoveOnAllAggregates.php ├── EffectEntityPropertiesOnAggregate.php ├── EffectEntityPropertiesOnAllAggregates.php ├── EffectEntityPropertyOnAggregate.php ├── EffectOptionalNothingOnAllAggregates.php ├── EffectOptionalPropertiesOnAggregate.php ├── EffectOptionalPropertiesOnAllAggregates.php ├── EffectPropertiesOnAggregate.php ├── EffectPropertyOnAggregate.php ├── EffectPropertyOnAllAggregates.php ├── FailingTransactionDueToException.php ├── FailingTransactionDueToLeftSide.php ├── IncrementallyAddElementsToACollection.php ├── ListingAggregatesUseConstantMemory.php ├── Matching.php ├── MatchingCollection.php ├── MatchingCollectionOfEnums.php ├── MatchingComposite.php ├── MatchingDrop.php ├── MatchingDropAndTake.php ├── MatchingEntity.php ├── MatchingExclusion.php ├── MatchingIds.php ├── MatchingOptional.php ├── MatchingSort.php ├── MatchingSortEntity.php ├── MatchingTake.php ├── None.php ├── Properties.php ├── RemoveAggregate.php ├── RemoveSpecification.php ├── RemoveUnknownAggregateDoesNothing.php ├── RemoveWhereEntity.php ├── RemovingOutsideOfTransactionIsNotAllowed.php ├── SavingAggregateTwiceAddsItOnce.php ├── Size.php ├── SizeWithSpecification.php ├── StreamUpdate.php ├── SuccessfulTransaction.php ├── UpdateAggregate.php ├── UpdateCollection.php ├── UpdateCollectionOfEnums.php ├── UpdateEntity.php ├── UpdateOptional.php ├── UpdateOptionalWithoutChangingInnerProperties.php └── UpdatingOutsideOfTransactionIsNotAllowed.php ├── psalm.xml └── src ├── Adapter.php ├── Adapter ├── Elasticsearch.php ├── Elasticsearch │ ├── CreateIndex.php │ ├── Decode.php │ ├── DropIndex.php │ ├── ElasticsearchType.php │ ├── Encode.php │ ├── MapType.php │ ├── Mapping.php │ ├── Painless.php │ ├── Query.php │ ├── Refresh.php │ ├── Repository.php │ └── Transaction.php ├── Filesystem.php ├── Filesystem │ ├── Decode.php │ ├── Encode.php │ ├── EncodeEffect.php │ ├── Fold.php │ ├── Repository.php │ └── Transaction.php ├── Repository.php ├── Repository │ ├── CrossAggregateMatching.php │ ├── Effectful.php │ └── SubMatch.php ├── SQL.php ├── SQL │ ├── CollectionTable.php │ ├── Decode.php │ ├── Encode.php │ ├── EntityTable.php │ ├── MainTable.php │ ├── MapType.php │ ├── OptionalTable.php │ ├── Repository.php │ ├── SQLType.php │ ├── ShowCreateTable.php │ ├── SubQuery.php │ ├── Transaction.php │ └── Update.php ├── Transaction.php └── Transaction │ └── Failure.php ├── Definition ├── Aggregate.php ├── Aggregate │ ├── Collection.php │ ├── Entity.php │ ├── Identity.php │ ├── Optional.php │ ├── Parsing.php │ └── Property.php ├── Aggregates.php ├── Contains.php ├── Contains │ └── Primitive.php ├── Type.php ├── Type │ ├── BoolType.php │ ├── EnumType.php │ ├── IdType.php │ ├── IntType.php │ ├── MaybeType.php │ ├── NullableType.php │ ├── PointInTimeType.php │ ├── PointInTimeType │ │ └── Formats.php │ ├── StrType.php │ ├── StringType.php │ └── Support.php └── Types.php ├── Effect.php ├── Effect ├── Collection │ ├── Add.php │ └── Remove.php ├── Entity.php ├── Normalize.php ├── Normalized.php ├── Normalized │ ├── Collection │ │ ├── Add.php │ │ └── Remove.php │ ├── Entity.php │ ├── Optional.php │ ├── Optional │ │ └── Nothing.php │ ├── Properties.php │ └── Property.php ├── Optional.php ├── Optional │ └── Nothing.php ├── Properties.php ├── Property.php ├── Provider.php └── Provider │ ├── Collection.php │ ├── Entity.php │ ├── Optional.php │ ├── Properties.php │ └── Property.php ├── Id.php ├── Manager.php ├── Matching.php ├── Raw ├── Aggregate.php ├── Aggregate │ ├── Collection.php │ ├── Collection │ │ └── Entity.php │ ├── Entity.php │ ├── Id.php │ ├── Optional.php │ ├── Optional │ │ └── BrandNew.php │ └── Property.php └── Diff.php ├── Repository.php ├── Repository ├── Active.php ├── Context.php ├── Denormalize.php ├── Denormalize │ ├── Collection.php │ ├── Entity.php │ └── Optional.php ├── Denormalized.php ├── Diff.php ├── Diff │ └── Property.php ├── Extract.php ├── Instanciate.php ├── Loaded.php ├── Normalize.php ├── Normalize │ ├── Collection.php │ ├── Entity.php │ └── Optional.php └── Sort.php ├── Sort.php ├── Sort ├── Entity.php └── Property.php └── Specification ├── Child.php ├── Child └── Enum.php ├── CrossMatch.php ├── Entity.php ├── Has.php ├── Just.php ├── Normalize.php └── Property.php /.gitattributes: -------------------------------------------------------------------------------- 1 | /proofs export-ignore 2 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | on: 3 | push: 4 | branches: [master] 5 | 6 | permissions: 7 | contents: write 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Configure Git Credentials 14 | run: | 15 | git config user.name github-actions[bot] 16 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: 3.x 20 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 21 | - uses: actions/cache@v4 22 | with: 23 | key: mkdocs-material-${{ env.cache_id }} 24 | path: .cache 25 | restore-keys: | 26 | mkdocs-material- 27 | - run: pip install mkdocs-material 28 | - run: pip install mkdocs-rss-plugin 29 | - run: mkdocs gh-deploy --force 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | name: Create release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Create release 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | tag: ${{ github.ref_name }} 20 | run: | 21 | gh release create "$tag" \ 22 | --repo="$GITHUB_REPOSITORY" \ 23 | --generate-notes 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /vendor 3 | /.cache 4 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | repository(YourAggregate::class) 44 | ->all() 45 | ->sort('someProperty', Sort::asc) 46 | ->drop(150) 47 | ->take(50) 48 | ->foreach(static fn($aggregate) => doStuff($aggregate)); 49 | ``` 50 | 51 | This simple example will retrieve from the database `50` elements (from index `151` to `200`) sorted by `someProperty` in ascending order and will call the function `doStuff` on each aggregate. 52 | 53 | > [!NOTE] 54 | > The elements are streamed meaning only one aggregate is in memory at a time allowing you to deal with long lists of elements in a memory safe way. 55 | 56 | ## Documentation 57 | 58 | Full documentation available [here](https://formal-php.github.io/orm/). 59 | 60 | [^1]: Object Relational Mapping 61 | -------------------------------------------------------------------------------- /benchmark/fill_storage.php: -------------------------------------------------------------------------------- 1 | remote()->sql(Url::of("mysql://root:root@127.0.0.1:3306/example")); 26 | $aggregates = Aggregates::of(Types::of( 27 | Type\Support::class( 28 | PointInTime::class, 29 | Type\PointInTimeType::new($os->clock()), 30 | ), 31 | )); 32 | 33 | $_ = Adapter\SQL\ShowCreateTable::of($aggregates)(User::class)->foreach($connection); 34 | 35 | $manager = Manager::sql($connection, $aggregates); 36 | $repository = $manager->repository(User::class); 37 | 38 | $users = Set::compose( 39 | User::new(...), 40 | FPointInTime::any(), 41 | Set::strings() 42 | ->madeOf(Set::strings()->chars()->alphanumerical()) 43 | ->between(0, 250), 44 | ); 45 | $users = $users->randomize()->take(100_000)->values(Random::default); 46 | 47 | $manager->transactional(function() use ($repository, $users) { 48 | foreach ($users as $user) { 49 | $repository->put($user->unwrap()); 50 | } 51 | 52 | return Either::right(null); 53 | }); 54 | -------------------------------------------------------------------------------- /benchmark/load.php: -------------------------------------------------------------------------------- 1 | remote()->sql(Url::of("mysql://root:root@127.0.0.1:3306/example")); 19 | $aggregates = Aggregates::of(Types::of( 20 | Type\Support::class( 21 | PointInTime::class, 22 | Type\PointInTimeType::new($os->clock()), 23 | ), 24 | )); 25 | 26 | $manager = Manager::sql($connection, $aggregates); 27 | 28 | $_ = $manager 29 | ->repository(User::class) 30 | ->all() 31 | ->foreach(static fn() => null); 32 | 33 | printf( 34 | "Memory: %.2f Mo\n", 35 | ((\memory_get_peak_usage(true) / 1024) / 1024), 36 | ); 37 | -------------------------------------------------------------------------------- /blackbox.php: -------------------------------------------------------------------------------- 1 | self::filesystem, 24 | 'sql' => self::sql, 25 | 'es', 'elasticsearch' => self::elasticsearch, 26 | default => null, 27 | }; 28 | } 29 | } 30 | 31 | Application::new($argv) 32 | ->codeCoverage( 33 | CodeCoverage::of( 34 | __DIR__.'/src/', 35 | __DIR__.'/proofs/', 36 | __DIR__.'/fixtures/', 37 | ) 38 | ->dumpTo('coverage.clover') 39 | ->enableWhen(\getenv('ENABLE_COVERAGE') !== false), 40 | ) 41 | ->scenariiPerProof(match (\getenv('ENABLE_COVERAGE')) { 42 | false => 100, 43 | default => 1, 44 | }) 45 | ->when( 46 | \getenv('CI') !== false, 47 | static fn($app) => $app->allowProofsToNotMakeAnyAssertions(), 48 | ) 49 | ->parseTagWith(Storage::of(...)) 50 | ->disableShrinking() 51 | ->tryToProve(Load::everythingIn(__DIR__.'/proofs/')) 52 | ->exit(); 53 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - fixtures 3 | github_checks: false 4 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "formal/orm", 3 | "type": "library", 4 | "description": "", 5 | "keywords": [], 6 | "homepage": "http://github.com/formal/orm", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Baptiste Langlade", 11 | "email": "baptiste.langlade@hey.com" 12 | } 13 | ], 14 | "support": { 15 | "issues": "http://github.com/formal-php/orm/issues" 16 | }, 17 | "require": { 18 | "php": "~8.2", 19 | "innmind/foundation": "~1.1", 20 | "ramsey/uuid": "~4.7", 21 | "innmind/type": "~1.2", 22 | "formal/access-layer": "~4.2", 23 | "innmind/url-template": "~3.1" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Formal\\ORM\\": "src/", 28 | "Properties\\Formal\\ORM\\": "properties/", 29 | "Fixtures\\Formal\\ORM\\": "fixtures/" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | } 35 | }, 36 | "require-dev": { 37 | "innmind/static-analysis": "^1.2.1", 38 | "innmind/black-box": "~6.1", 39 | "innmind/coding-standard": "~2.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mariadb: 3 | image: mariadb:10 4 | environment: 5 | MYSQL_ROOT_PASSWORD: root 6 | MYSQL_DATABASE: example 7 | ports: 8 | - '3306:3306' 9 | elasticsearch: 10 | image: elasticsearch:7.17.18 11 | environment: 12 | discovery.type: single-node 13 | ports: 14 | - '9200:9200' 15 | postgres: 16 | image: postgres:16 17 | environment: 18 | POSTGRES_USER: root 19 | POSTGRES_PASSWORD: root 20 | POSTGRES_DB: example 21 | ports: 22 | - '5432:5432' 23 | -------------------------------------------------------------------------------- /documentation/adapters/filesystem.md: -------------------------------------------------------------------------------- 1 | # Filesystem 2 | 3 | This is the adapter you've been using since the start of this documentation. 4 | 5 | You can use any implementation of the `Innmind\Filesystem\Adapter` interface provided by [`innmind/filesystem`](https://packagist.org/packages/innmind/filesystem). 6 | 7 | ## In memory 8 | 9 | It allows to quickly iterate on some code and see if it works, and move later on on a more persistent storage. 10 | 11 | This adapter is also useful when testing an application. By using an in memory storage your tests can run faster as it doesn't have to persist anything to the filesystem. 12 | 13 | ## Persistent 14 | 15 | You can persist your aggregates to the filesystem via: 16 | 17 | ```php 18 | use Formal\ORM\Manager; 19 | use Innmind\Filesystem\Adapter\Filesystem; 20 | use Innmind\Url\Path; 21 | 22 | $orm = Manager::filesystem(Filesystem::mount(Path::of('somewhere/'))); 23 | ``` 24 | 25 | You should use this storage for proof of concept kind of apps. Or for small CLI apps you use locally. 26 | 27 | !!! warning "" 28 | **DO NOT** use this storage for a production app. As this will be very slow and not concurrent safe. 29 | 30 | ## S3 31 | 32 | The package [`innmind/s3`](https://packagist.org/packages/innmind/s3) exposes a filesystem adapter. You could use it like this: 33 | 34 | ```php 35 | use Formal\ORM\Manager; 36 | use Innmind\OperatingSystem\Factory as OSFactory; 37 | use Innmind\S3\{ 38 | Factory, 39 | Region, 40 | Filesystem\Adapter, 41 | }; 42 | use Innmind\Url\Url; 43 | 44 | $os = OSFactory::build(); //(1) 45 | $bucket = Factory::of($os)->build( 46 | Url::of('https://acces_key:acces_secret@bucket-name.s3.region-name.scw.cloud/'), 47 | Region::of('region-name'), 48 | ); 49 | 50 | $orm = Manager::filesystem( 51 | Adapter::of($bucket), 52 | ); 53 | ``` 54 | 55 | 1. See [`innmind/operating-system`](https://innmind.github.io/documentation/getting-started/operating-system/) 56 | 57 | You should use this storage for proof of concept kind of apps. Or for small CLI apps and you want the storage to be available across multiple computers. 58 | 59 | !!! warning "" 60 | **DO NOT** use this storage for a production app. As this will be very slow (due to network latency) and not concurrent safe. 61 | -------------------------------------------------------------------------------- /documentation/adapters/index.md: -------------------------------------------------------------------------------- 1 | # Adapters 2 | 3 | As you've seen throughout this documentation none of actions possible are dependent on a storage engine, all his abstracted away. 4 | 5 | This allows Formal to offer different storage adapters tailored to some use cases. 6 | 7 | This project uses [Property Based Testing](https://en.wikipedia.org/wiki/Property_testing) to make sure all the adapters behave the same way. This allows you to switch from one to another safely. 8 | -------------------------------------------------------------------------------- /documentation/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/formal-php/orm/747585bca898cd24c6ff9a5463894f95d0331c04/documentation/assets/favicon.png -------------------------------------------------------------------------------- /documentation/assets/fonts/MonaspaceNeon-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/formal-php/orm/747585bca898cd24c6ff9a5463894f95d0331c04/documentation/assets/fonts/MonaspaceNeon-Regular.woff -------------------------------------------------------------------------------- /documentation/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /documentation/benchmark.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | - toc 5 | --- 6 | 7 | # Benchmark 8 | 9 | A small benchmark as a reference point for the performance of this ORM consists in generating and persisting 100K users in a single transaction and then loading them. 10 | 11 | ```sh 12 | time php benchmark/fill_storage.php 13 | php benchmark/fill_storage.php 222.24s user 5.20s system 60% cpu 6:18.40 total 14 | time php benchmark/load.php 15 | Memory: 40.00 Mo 16 | php benchmark/load.php 11.06s user 0.08s system 97% cpu 11.388 total 17 | ``` 18 | 19 | This means the ORM can load 1 aggregate in 0.1 millisecond. 20 | 21 | This was run on a MacbookPro 16" with a M1 Max with the mariadb running inside Docker. 22 | 23 | !!! note "" 24 | If all the aggregates were to be stored in memory it would take around 2Go of RAM and 15 seconds to complete. 25 | -------------------------------------------------------------------------------- /documentation/blog/.authors.yml: -------------------------------------------------------------------------------- 1 | authors: 2 | baptouuuu: 3 | name: Baptiste Langlade 4 | description: Maintainer 5 | avatar: https://avatars.githubusercontent.com/u/851425 6 | url: https://github.com/Baptouuuu 7 | -------------------------------------------------------------------------------- /documentation/blog/index.md: -------------------------------------------------------------------------------- 1 | # Blog 2 | 3 | [:material-rss-box: RSS Feed](/feed_rss_created.xml) 4 | -------------------------------------------------------------------------------- /documentation/enums.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | - toc 5 | --- 6 | 7 | # Enums 8 | 9 | !!! success "" 10 | Formal natively support enums as a property and inside collections (1). 11 | {.annotate} 12 | 13 | 1. Via [`Set`s](mapping/collections.md). 14 | 15 | This means you don't need to create [custom types](mapping/type.md) for each enum in your project, it just works. 16 | 17 | ??? info 18 | Formal uses the `case` name as the value persisted in the storage. Even when you use backed enums. 19 | 20 | !!! tip "" 21 | In order to search aggregates having an enum case inside a collection you can use this [specification](specifications/index.md): 22 | 23 | === "Find one" 24 | ```php 25 | use Formal\ORM\Specification\Child\Enum; 26 | 27 | Enum::any( 28 | 'collectionPropertyName', 29 | YourEnum::someCase, 30 | ) 31 | ``` 32 | 33 | This will return the aggregate if this case is present in the collection. 34 | 35 | === "Find amongst many" 36 | ```php 37 | use Formal\ORM\Specification\Child\Enum; 38 | 39 | Enum::in( 40 | 'collectionPropertyName', 41 | YourEnum::someCase, 42 | YourEnum::someOtherCase, 43 | ) 44 | ``` 45 | 46 | This will return the aggregate if one of the cases is present in the collection. 47 | -------------------------------------------------------------------------------- /documentation/getting-started/aggregate.md: -------------------------------------------------------------------------------- 1 | # Create an aggregate 2 | 3 | Throughout this documentation you'll learn how to persist a `User` aggregate. For now this aggregate will only have an id and a name: 4 | 5 | ```php title="User.php" 6 | use Formal\ORM\Id; 7 | 8 | final readonly class User 9 | { 10 | /** 11 | * @param Id $id 12 | */ 13 | private function __construct( 14 | private Id $id, 15 | private string $name, 16 | ) {} 17 | 18 | public static function new(string $name): self 19 | { 20 | return new self(Id::new(self::class), $name); 21 | } 22 | 23 | public function name(): string 24 | { 25 | return $this->name; 26 | } 27 | } 28 | ``` 29 | 30 | There's not much code but there's already much to know! 31 | 32 | The class is declared `readonly` to make sure once it's instanciated it can't be modified. This the [immutability](../philosophy.md#immutability) required for the ORM to work properly. (1) 33 | { .annotate } 34 | 35 | 1. If you use [Psalm](https://psalm.dev) you can also add the `@psalm-immutable` annotation on the class. 36 | 37 | An aggregate **must** have an `$id` property with the type `Formal\ORM\Id`. It's this property that uniquely reference an aggregate in the storage. (You'll also see in the next chapter how the ORM uses it internally). 38 | 39 | `Id::new()` will generate a brand new value (1). The class passed as argument allows Psalm to know to which aggregate type it belongs to, this prevents you from mistakenly use an id of an aggregate A when trying to retrieve an aggregate B. 40 | { .annotate } 41 | 42 | 1. Internally it uses uuids. 43 | 44 | !!! info "" 45 | For now we'll only use this `string` property, you'll learn in the [mapping chapter](../mapping/index.md) how to use more complex types. 46 | 47 | ??? tip 48 | In this example we use a private constructor that list all properties and a public named constructor. While this design is not mandatory it will be clearer to see what's happening when you'll _modify_ the aggregate. 49 | 50 | Also note that the ORM **does not** need your aggregate constructor to be public in order to instanciate objects coming from the storage. 51 | -------------------------------------------------------------------------------- /documentation/getting-started/count.md: -------------------------------------------------------------------------------- 1 | # Count aggregates 2 | 3 | At some point you may need to count the number of aggregates stored. You can do it like this: 4 | 5 | ```php 6 | $count = $orm->repository(User::class)->size(); 7 | ``` 8 | 9 | This will return `0` or more. 10 | 11 | And sometime you may need to simply know if there's at least one aggregate or none. For this case you **should not** use the `size` method as it overfetches data. Instead you can do: 12 | 13 | ```php 14 | $trueWhenTheresAtLeastOneUser = $orm->repository(User::class)->any(); 15 | // or 16 | $trueWhenTheresNoUser = $orm->repository(User::class)->none(); 17 | ``` 18 | 19 | ??? note 20 | If you want to count the number of aggregates corresponding to a set of criteria head to the [Specification chapter](../specifications/index.md). 21 | -------------------------------------------------------------------------------- /documentation/getting-started/index.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | ## Installation 4 | 5 | ```sh 6 | composer require formal/orm 7 | ``` 8 | 9 | ## Setup 10 | 11 | === "Ephemeral storage" 12 | ```php 13 | use Formal\ORM\Manager; 14 | use Innmind\Filesystem\Adapter\InMemory; 15 | 16 | $orm = Manager::filesystem(InMemory::emulateFilesystem()); 17 | ``` 18 | 19 | === "Persistent storage" 20 | ```php 21 | use Formal\ORM\Manager; 22 | use Innmind\Filesystem\Adapter\Filesystem; 23 | use Innmind\Url\Path; 24 | 25 | $orm = Manager::filesystem(Filesystem::mount(Path::of('some/directory/'))); 26 | ``` 27 | 28 | !!! info "" 29 | In the rest of this documentation the variable `$orm` will reference this `Manager` object. 30 | 31 | While you learn how to use this ORM the filesystem storage is enough, you'll learn later on how to use [other adapters](../adapters/index.md). As the examples names suggest one is ephemeral meaning nothing is persisted to the filesystem allowing you to run your code without side effects. On the other hand the _persistent_ storage will store the data to your filesystem and you'll need to delete the data when the aggregate class will change. 32 | -------------------------------------------------------------------------------- /documentation/getting-started/remove.md: -------------------------------------------------------------------------------- 1 | # Remove an aggregate 2 | 3 | In order to remove an aggregate you need 3 things: 4 | 5 | - an `Id` of the [aggregate](../terminology.md#aggregate) 6 | - a [repository](../terminology.md#repository) in which to put the aggregate 7 | - a [transaction](../terminology.md#transaction) to atomically persist the aggregate 8 | 9 | Translated into code this gives: 10 | 11 | ```php 12 | $users = $orm->repository(User::class); 13 | $result = $orm->transactional( 14 | static fn() => $repository 15 | ->remove(Id::of(User::class, 'alice-uuid')) 16 | ->either(), 17 | ); 18 | ``` 19 | 20 | If alice exists in the storage it will remove it and if it doesn't then nothing will happen. And like for [persisting](persist.md) the `->either()` will indicate to `transactional` to commit the transaction. 21 | 22 | ??? note 23 | If you want to remove multiple aggregates at once corresponding to a set of criteria head to the [Specification chapter](../specifications/index.md). 24 | -------------------------------------------------------------------------------- /documentation/getting-started/retrieve.md: -------------------------------------------------------------------------------- 1 | # Retrieve an aggregate 2 | 3 | Once an aggregate has been persisted you'll want to load it at some point. 4 | 5 | The easiest way to retrieve it is to load all aggregates and filter the one you want: 6 | 7 | ```php 8 | $alice = $orm 9 | ->repository(User::class) 10 | ->all() 11 | ->find(static fn(User $user) => $user->name() === 'alice') 12 | ->match( 13 | static fn(User $user) => $user, 14 | static fn() => null, 15 | ); 16 | ``` 17 | 18 | If there's an `alice` user in the storage then `$alice` will be an instance of `User` otherwise it will be `null`. 19 | 20 | ??? note 21 | Note that you don't need to be in a transaction to fetch your aggregates. 22 | 23 | While this example is simple enough it's not very performant as it loads every aggregate from the storage until it finds alice. The better approach is to directly fetch alice via its id: 24 | 25 | ```php 26 | $alice = $orm 27 | ->repository(User::class) 28 | ->get(Id::of(User::class, 'alice-uuid')) 29 | ->match( 30 | static fn(User $user) => $user, 31 | static fn() => null, 32 | ); 33 | ``` 34 | 35 | Here we use `alice-uuid` as the id value but this is a placeholder. You should replace it with the real id value, usually it will come from a HTTP route parameter. 36 | 37 | ??? info 38 | An `Id` can be transformed to a string via the `$id->toString()` method. 39 | 40 | The `get` method returns a `Maybe` as the corresponding user may not exist in the storage. Here we return `null` if alice doesn't exist but you can return or call any code you'd like. 41 | 42 | ??? note 43 | If you want to learn how to retrieve mutliple aggregates corresponding to a set of criteria head to the [Specification chapter](../specifications/index.md). 44 | 45 | ??? note 46 | Note that the monads are lazy evaluated when retrieving data. This means that it will hit the storage only when trying to extract data from them and will only load one aggregate at a time. 47 | 48 | For a `Maybe` this means calling `match` or `memoize`. For a `Sequence` it's all methods marked with :material-memory-arrow-down: in [its documentation](http://innmind.github.io/Immutable/structures/sequence/). 49 | -------------------------------------------------------------------------------- /documentation/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | - toc 5 | --- 6 | 7 | # Welcome to the Formal ORM 8 | 9 | This ORM focuses on simplifying data manipulation. 10 | 11 | This is achieved by: 12 | 13 | - using immutable objects 14 | - each aggregate _owning_ the objects it references 15 | - using monads to fetch aggregates (from the [Innmind](https://innmind.github.io/documentation/getting-started/handling-data/) ecosystem) 16 | - using the specification pattern to match aggregates 17 | 18 | This allows: 19 | 20 | - simpler app design (as it can be [pure](https://innmind.github.io/documentation/philosophy/oop-fp/#purity)) 21 | - memory efficiency (the ORM doesn't keep objects in memory) 22 | - long living processes (since there is no memory leaks) 23 | - to work asynchronously 24 | 25 | ??? example "Sneak peak" 26 | ```php 27 | use Formal\ORM\{ 28 | Manager, 29 | Sort, 30 | }; 31 | use Formal\AccessLayer\Connection\PDO; 32 | use Innmind\Url\Url; 33 | 34 | $manager = Manager::sql( 35 | PDO::of(Url::of('mysql://user:pwd@host:3306/database?charset=utf8mb4')), 36 | ); 37 | $_ = $manager 38 | ->repository(YourAggregate::class) 39 | ->all() 40 | ->sort('someProperty', Sort::asc) 41 | ->drop(150) 42 | ->take(50) 43 | ->foreach(static fn($aggregate) => doStuff($aggregate)); 44 | ``` 45 | 46 | If you've worked with [C# Entity Framework](https://learn.microsoft.com/en-us/ef/core/get-started/overview/first-app) you should find a few similarities. 47 | 48 | *[ORM]: Object Relational Mapping 49 | -------------------------------------------------------------------------------- /documentation/limitations.md: -------------------------------------------------------------------------------- 1 | # Limitations 2 | 3 | ## Entity collection 4 | 5 | Due to the current design of entities not having ids it is not possible to build a diff of collections of entities. This means that as soon as a collection is modified the whole collection is persisted to the storage. 6 | 7 | For small sets of entities this is fine but can become quite time consuming if you store a lot of data inside a given collection. 8 | 9 | ## Elasticsearch 10 | 11 | This adapter has 2 major limitations: 12 | 13 | - it does not support transactions 14 | - it can't list more than 10k aggregates 15 | 16 | Elasticsearch have no concept of transactions. The adapter implementation do not try to emulate a transaction mechanism as it would be too complex. This means that has soon you make an operation on a repository the change is directly applied to the underlying index. 17 | 18 | The Elasticsearch api doesn't allow a pagination above 10k documents. This is a hardcoded behaviour on their part, this is a design choice as to not interpret an index as a database. This means that if you have more than 10k aggregates you won't be able to list them all. 19 | 20 | !!! warning "" 21 | These limitations mean that you can't swap another adapter by this one without behaviours changes in your app. 22 | 23 | ## Effects 24 | 25 | It's not possible to apply multiple effects at once. You'll need to apply each one of them individually, and can still be done in a same transaction. 26 | 27 | This is due to a current limitation of the SQL adapter. 28 | 29 | To apply multiple effects would require to execute multiple queries. But the aggregates matched by the specification could change between each query in the case an effect change a value being matched by the specification. 30 | 31 | For the same reason it's not possible to overwrite a whole optional entity. In SQL it would require to execute a delete before an insert, but the matched aggregates could also change between the queries. 32 | 33 | To lift this limitation requires a significant change of the SQL adapter. 34 | -------------------------------------------------------------------------------- /documentation/mapping/collections.md: -------------------------------------------------------------------------------- 1 | # Collection of entities 2 | 3 | Another common use cases could be to have an unknown number of entities. Our `User` may want to specify multiple addresses. 4 | 5 | You can do it like this: 6 | 7 | ```php title="User.php" hl_lines="5 11 16 17 22 32 35-42" 8 | use Formal\ORM\{ 9 | Id, 10 | Definition\Contains, 11 | }; 12 | use Innmind\Immutable\Set; 13 | 14 | final readonly class User 15 | { 16 | /** 17 | * @param Id $id 18 | * @param Set
$address 19 | */ 20 | private function __construct( 21 | private Id $id, 22 | private Name $name, 23 | #[Contains(Address::class)] 24 | private Set $addresses, 25 | ) {} 26 | 27 | public static function new(Name $name): self 28 | { 29 | return new self(Id::new(self::class), $name, Set::of()); 30 | } 31 | 32 | public function name(): Name 33 | { 34 | return $this->name; 35 | } 36 | 37 | public function rename(Name $name): self 38 | { 39 | return new self($this->id, $name, $this->addresses); 40 | } 41 | 42 | public function addAddress(Address $address): self 43 | { 44 | return new self( 45 | $this->id, 46 | $this->name, 47 | $this->addresses->add($address), 48 | ); 49 | } 50 | } 51 | ``` 52 | 53 | This is very similar to [optional entities](optionals.md). 54 | 55 | The `Set` monad represents an unordered collection of unique values. 56 | 57 | ??? warning 58 | When a `Set` contains objects, the uniqueness is defined by the reference of the objects and not their values. 59 | 60 | This means that these 2 sets are not the same: 61 | 62 | ```php 63 | $address = new Address('foo', 'bar', 'baz'); 64 | $set1 = Set::of($address, $address); 65 | $set2 = Set::of( 66 | new Address('foo', 'bar', 'baz'), 67 | new Address('foo', 'bar', 'baz'), 68 | ); 69 | ``` 70 | 71 | `$set1` only contains 1 `Address` while `$set2` contains 2. 72 | 73 | ??? note 74 | If you're not familiar with the `Set` monad you can head to this [documentation](http://innmind.github.io/Immutable/structures/set/) describing all its methods. 75 | -------------------------------------------------------------------------------- /documentation/mapping/entities.md: -------------------------------------------------------------------------------- 1 | # Entities 2 | 3 | The `User` name represent a _single value_ and you now know to handle this kind of property. But at some point you'll need to use an object that has multiple properties. 4 | 5 | This kind of object is called an Entity. 6 | 7 | An example for a user would be an `Address`: 8 | 9 | ```php title="Address.php" 10 | final readonly class Address 11 | { 12 | public function __construct( 13 | private string $street, 14 | private string $zipCode, 15 | private string $city, 16 | ) {} 17 | 18 | // (1) 19 | } 20 | ``` 21 | 22 | 1. No methods such as getters for conciseness of the example. 23 | 24 | And to use it in `User`: 25 | 26 | ```php title="User.php" hl_lines="11 14 16 26" 27 | use Formal\ORM\Id; 28 | 29 | final readonly class User 30 | { 31 | /** 32 | * @param Id $id 33 | */ 34 | private function __construct( 35 | private Id $id, 36 | private Name $name, 37 | private Address $address, 38 | ) {} 39 | 40 | public static function new(Name $name, Address $address): self 41 | { 42 | return new self(Id::new(self::class), $name, $address); 43 | } 44 | 45 | public function name(): Name 46 | { 47 | return $this->name; 48 | } 49 | 50 | public function rename(Name $name): self 51 | { 52 | return new self($this->id, $name, $this->address); 53 | } 54 | } 55 | ``` 56 | 57 | And that's it! 58 | 59 | Thanks to immutability Formal knows for sure that an `Address` only belongs to a `User`, so no need for the `Address` to have an id. 60 | 61 | ??? warning 62 | Formal's implementation, while providing a high level abstraction, aims to remain simple. 63 | 64 | For that reason only an Aggregate can have entities. You can't define an Entity inside an Entity! 65 | 66 | This choice is also to force you to really think about the design of your aggregates. If you try to nest entities then maybe you're not using the right approach. And if you really want to use nesting then Formal is not the right abstraction! 67 | -------------------------------------------------------------------------------- /documentation/mapping/index.md: -------------------------------------------------------------------------------- 1 | # Mapping 2 | 3 | So far the `User` aggregate only contains a `string` property. By default Formal also supports these primitive types: 4 | 5 | - `bool` 6 | - `int` 7 | - `string` 8 | - `?bool` 9 | - `?int` 10 | - `?string` 11 | 12 | Using primitive types is fine when you prototype the design of your aggregates. But you **SHOULD** use dedicated classes for each kind of value to better convey meaning and expected behaviour, this is what [_Typing_](https://innmind.github.io/documentation/philosophy/explicit/#parse-dont-validate) is truely about. (1) 13 | {.annotate} 14 | 15 | 1. It also has the benefit to immensely simplify refactoring your code. 16 | 17 | Types are essential in the Formal design. You'll learn in the next chapter how to support your custom types. 18 | 19 | By default Formal also supports: 20 | 21 |
22 | - `Innmind\Immutable\Str` from [`innmind/immutable`](https://packagist.org/packages/innmind/immutable) (1) 23 | - `Innmind\TimeContinuum\PointInTime` from [`innmind/time-continuum`](https://packagist.org/packages/innmind/time-continuum) 24 |
25 | 26 | 1. Beware! It won't store the encoding, when fetched it will use `#!php Innmind\Immutable\Str\Encoding::utf8` 27 | 28 | ??? note 29 | Formal can support the `PointInTime` type but you still need to declare it like this: 30 | 31 | ```php 32 | use Formal\ORM\{ 33 | Manager, 34 | Definition\Aggregates, 35 | Definition\Types, 36 | Definition\Type\Support, 37 | Definition\Type\PointInTimeType, 38 | }; 39 | use Innmind\TimeContinuum\PointInTime; 40 | 41 | $orm = Manager::of( 42 | /* any adapter (1) */, 43 | Aggregates::of( 44 | Types::of( 45 | Support::class( 46 | PointInTime::class, 47 | PointInTimeType::new($os->clock()), 48 | ), 49 | ), 50 | ), 51 | ); 52 | ``` 53 | 54 | 1. See the [Adapters](../adapters/index.md) chapter to see all the adapters you can use. 55 | 56 | The `$os` variable comes from the [`innmind/operating-system`](https://innmind.github.io/documentation/getting-started/operating-system/) package. 57 | -------------------------------------------------------------------------------- /documentation/mapping/optionals.md: -------------------------------------------------------------------------------- 1 | # Optional entities 2 | 3 | In the previous chapter you've seen what an Entity is. But an Entity can't always be required! 4 | 5 | For our example, not every `User` wan't to specify an `Address`. 6 | 7 | This is how you make it optional: 8 | 9 | ```php title="User.php" hl_lines="3 5 11 16 17 20 22 35-42" 10 | use Formal\ORM\{ 11 | Id, 12 | Definition\Contains, 13 | }; 14 | use Innmind\Immutable\Maybe; 15 | 16 | final readonly class User 17 | { 18 | /** 19 | * @param Id $id 20 | * @param Maybe
$address 21 | */ 22 | private function __construct( 23 | private Id $id, 24 | private Name $name, 25 | #[Contains(Address::class)] 26 | private Maybe $address, 27 | ) {} 28 | 29 | public static function new(Name $name): self 30 | { 31 | return new self(Id::new(self::class), $name, Maybe::nothing()); 32 | } 33 | 34 | public function name(): Name 35 | { 36 | return $this->name; 37 | } 38 | 39 | public function rename(Name $name): self 40 | { 41 | return new self($this->id, $name, $this->address); 42 | } 43 | 44 | public function addAddress(Address $address): self 45 | { 46 | return new self( 47 | $this->id, 48 | $this->name, 49 | Maybe::just($address), 50 | ); 51 | } 52 | } 53 | ``` 54 | 55 | The `#!php #[Contains(Address::class)]` tells Formal the kind of Entity contained inside the `Maybe`. 56 | 57 | The `Maybe` monad is a way to describe the possible absence of data. This is kind of a nullable type. 58 | 59 | ??? note 60 | Formal doesn't use `null` to represent the possible absence of an Entity as it would force it to load all optional entities when fetching the aggregate. 61 | 62 | With `Maybe` it can lazy load them when first used after fetching an aggregate from the storage. 63 | 64 | If you're not familiar with the `Maybe` monad you can start learning it [here](https://innmind.github.io/documentation/getting-started/handling-data/maybe/). You can follow up by reading this [documentation](http://innmind.github.io/Immutable/structures/maybe/) describing all its methods. 65 | 66 | ??? info 67 | The `Contains` attribute is here to avoid to have to parse the docblock to extract the information specified for static analysis tools such as [Psalm](http://psalm.dev). 68 | 69 | If a standard emerges between static analysis tools in attributes to specify this kind of information then Formal may migrate to it. 70 | -------------------------------------------------------------------------------- /documentation/specifications/count.md: -------------------------------------------------------------------------------- 1 | # Count multiple aggregates 2 | 3 | You can count aggregates by [retrieving them](retrieve.md) and count the elements in the returned sequence like this: 4 | 5 | ```php 6 | $numberOfAlices = $orm 7 | ->repository(User::class) 8 | ->matching( 9 | SearchByName::of(Name::of('alice')), 10 | ) 11 | ->sequence() 12 | ->size(); 13 | ``` 14 | 15 | !!! danger "" 16 | But you **MUST NOT** do this. This will fetch the aggregates in memory and count them in PHP, this will be extremely slow! 17 | 18 | The right approach is: 19 | 20 | ```php 21 | $numberOfAlices = $orm 22 | ->repository(User::class) 23 | ->size( 24 | SearchByName::of(Name::of('alice')), 25 | ); 26 | ``` 27 | 28 | This will run an optimized count in your storage. 29 | 30 | But if you only need to know if there's an aggregate in the storage matching the specification, you **SHOULD** do: 31 | 32 | ```php hl_lines="3" 33 | $numberOfAlices = $orm 34 | ->repository(User::class) 35 | ->any( 36 | SearchByName::of(Name::of('alice')), 37 | ); 38 | ``` 39 | 40 | This runs an even more optimized query against your storage. 41 | 42 | And if you need to make sure no aggregate matches a specification: 43 | 44 | ```php hl_lines="3" 45 | $numberOfAlices = $orm 46 | ->repository(User::class) 47 | ->none( 48 | SearchByName::of(Name::of('alice')), 49 | ); 50 | ``` 51 | 52 | ??? tip 53 | The specification passed to `any` and `none` is optional, allowing you to know if there at least one aggregate or none (as the name would suggest). 54 | -------------------------------------------------------------------------------- /documentation/specifications/cross-matching.md: -------------------------------------------------------------------------------- 1 | # Matching across aggregates 2 | 3 | You can match aggregates of some kind based on conditions from another aggregate kind. 4 | 5 | For example if you have 2 [aggregates](../terminology.md#aggregate) `User` and `Movie` and you want: 6 | 7 | > All movies where the director's last name is Blomkamp. 8 | 9 | You can write the following code: 10 | 11 | ```php 12 | use Innmind\Specification\{ 13 | Comparator\Property, 14 | Sign, 15 | }; 16 | 17 | $orm 18 | ->repository(Movie::class) 19 | ->matching(Property::of( 20 | 'director', 21 | Sign::in, 22 | $orm 23 | ->repository(User::class) 24 | ->matching(Property::of( 25 | 'lastName', 26 | Sign::equality, 27 | 'Blomkamp', 28 | )), 29 | )) 30 | ->foreach(static fn($movie) => doSomething($movie)); 31 | ``` 32 | 33 | For this to work the `director` property must be typed `Formal\ORM\Id`. 34 | 35 | If your storage [adapter](../adapters/index.md) supports it this is even optimised at the storage level. 36 | 37 | ??? warning 38 | Only [SQL](../adapters/sql.md) is able to optimize this at the storage level. 39 | 40 | It still works with [Elasticsearch](../adapters/elasticsearch.md) and [Filesystem](../adapters/filesystem.md) but Formal will fetch the matching ids in memory and use them as input value. 41 | -------------------------------------------------------------------------------- /documentation/specifications/index.md: -------------------------------------------------------------------------------- 1 | # Specifications 2 | 3 | Specifications is a [pattern](https://en.wikipedia.org/wiki/Specification_pattern) to describe a tree of conditions. It's composed of: 4 | 5 | - a way to describe a comparison 6 | - an `AND` _gate_ 7 | - an `OR` _gate_ 8 | - a `NOT` _gate_ 9 | 10 | This is the basis of boolean logic. 11 | 12 | The big advantage is that a specification can be translated to many languages: pure PHP, SQL, Elasticsearch query, and more... 13 | 14 | That's why Formal uses them to _target_ multiple aggregates, it allows to provide multiple storages and still be optimized for all of them. 15 | 16 | Another big advantage is that they compose very easily. This allows to both abstract complex queries behind a domain semantic and tweak a query locally for a specific use case without having to duplicate the whole query. 17 | -------------------------------------------------------------------------------- /documentation/specifications/remove.md: -------------------------------------------------------------------------------- 1 | # Remove multiple aggregates 2 | 3 | This is pretty straightforward: 4 | 5 | ```php 6 | $orm 7 | ->repository(User::class) 8 | ->remove(SearchByName::of(Name::of('alice'))) 9 | ->unwrap(); 10 | ``` 11 | -------------------------------------------------------------------------------- /documentation/specifications/retrieve.md: -------------------------------------------------------------------------------- 1 | # Retrieve multiple aggregates 2 | 3 | You can do so via the `matching` method on a repository: 4 | 5 | ```php 6 | $orm 7 | ->repository(User::class) 8 | ->matching( 9 | SearchByName::of(Name::of('alice')), 10 | ) 11 | ->foreach(static fn(User $alice) => businessLogic($alice)); 12 | ``` 13 | 14 | ??? tip 15 | The `foreach` method used here is a shortcut for `->sequence()->foreach()`. This means that you can have access to a [`Sequence`](https://innmind.github.io/documentation/getting-started/handling-data/sequence/) by just calling `->sequence()` and use it like any other `Sequence`. 16 | -------------------------------------------------------------------------------- /documentation/upgrade/v4-to-v5.md: -------------------------------------------------------------------------------- 1 | # V4 to V5 2 | 3 | ## `Repository->put()` 4 | 5 | === "Before" 6 | ```php 7 | $repository->put($aggregate); 8 | ``` 9 | 10 | === "After" 11 | ```php 12 | $repository->put($aggregate)->unwrap(); 13 | ``` 14 | 15 | ## `Repository->remove()` 16 | 17 | === "Before" 18 | ```php 19 | $repository->remove($idOrSpecification); 20 | ``` 21 | 22 | === "After" 23 | ```php 24 | $repository->remove($idOrSpecification)->unwrap(); 25 | ``` 26 | 27 | ## `Repository->effect()` 28 | 29 | === "Before" 30 | ```php 31 | $repository->effect($effect); 32 | ``` 33 | 34 | === "After" 35 | ```php 36 | $repository->effect($effect)->unwrap(); 37 | ``` 38 | 39 | ## Transactions 40 | 41 | === "Before" 42 | ```php 43 | use Innmind\Immutable\Either; 44 | 45 | $manager 46 | ->transactional(static function() { 47 | if (/* some condition */) { 48 | return Either::right(new SomeValue); 49 | } 50 | 51 | return Either::left(new SomeError); 52 | }) 53 | ->match( 54 | static fn(SomeValue $value) => domSomething($value), 55 | static fn(SomeError $value) => domSomething($value), 56 | ); 57 | ``` 58 | 59 | === "After" 60 | ```php hl_lines="14-15" 61 | use Formal\ORM\Adapter\Transaction\Failure 62 | use Innmind\Immutable\Either; 63 | 64 | $manager 65 | ->transactional(static function() { 66 | if (/* some condition */) { 67 | return Either::right(new SomeValue); 68 | } 69 | 70 | return Either::left(new SomeError); 71 | }) 72 | ->match( 73 | static fn(SomeValue $value) => domSomething($value), 74 | static fn(SomeError|Failure $value) => match (true) { 75 | $value instanceof Failure => throw $value->unwrap(), 76 | default => domSomething($value), 77 | }, 78 | ); 79 | ``` 80 | 81 | Errors happening during the transaction commit/rollback are now returned on the left side instead of thrown to let you decide if you prefer using exceptions or a monadic style. 82 | -------------------------------------------------------------------------------- /documentation/use-cases/export.md: -------------------------------------------------------------------------------- 1 | # Export aggregates as a CSV 2 | 3 | Since Formal sits on top of the [Innmind ecosystem](https://innmind.github.io/documentation/) this pretty simple. 4 | 5 | ```php 6 | use Innmind\OperatingSystem\Factory; 7 | use Innmind\Filesystem\{ 8 | File, 9 | File\Content\Line, 10 | }; 11 | use Innmind\Url\Path; 12 | use Innmind\Immutable\Str; 13 | 14 | $os = Factory::build(); 15 | $lines = $orm 16 | ->repository(User::class) 17 | ->all() 18 | ->map(static fn(User $user): string => $user->name()->toString()) 19 | ->map(Str::of(...)) 20 | ->map(Line::of(...)); 21 | $file = File::named( 22 | 'users.csv', 23 | File\Content::ofLines($lines), 24 | ); 25 | $os 26 | ->filesystem() 27 | ->mount(Path::of('somewhere')) 28 | ->add($file); 29 | ``` 30 | 31 | This create a `users.csv` file where each line contains the name of a user. 32 | 33 | !!! success "" 34 | Since everything is lazy by default you can generate files of any size. 35 | 36 | You can learn more about handling files [here](https://innmind.github.io/documentation/getting-started/operating-system/filesystem/). 37 | -------------------------------------------------------------------------------- /documentation/use-cases/import.md: -------------------------------------------------------------------------------- 1 | # Import aggregates from a CSV 2 | 3 | ## In a single transaction 4 | 5 | ```php 6 | use Innmind\OperatingSystem\Factory; 7 | use Innmind\Filesystem\{ 8 | File, 9 | Name as FileName, 10 | File\Content\Line, 11 | }; 12 | use Innmind\Url\Path; 13 | use Innmind\Immutable\{ 14 | SideEffect, 15 | Predicate\Instance, 16 | }; 17 | 18 | $os = Factory::of(); 19 | $repository = $orm->repository(User::class); 20 | $orm->transactional( 21 | static fn() => $os 22 | ->filesystem() 23 | ->mount(Path::of('somewhere')) 24 | ->get(FileName::of('users.csv')) 25 | ->keep(Instance::of(File::class)) 26 | ->toSequence() 27 | ->flatMap(static fn(File $users) => $users->content()->lines()) 28 | ->map(static fn(Line $line) => User::new(Name::of( 29 | $line->toString(), //(1) 30 | ))) 31 | ->sink(SideEffect::identity()) 32 | ->attempt(static fn($_, User $user) => $repository->put($user)) 33 | ->either(), 34 | ); 35 | ``` 36 | 37 | 1. Ths line never contains the `\n` character, so you don't have to handle it yourself. 38 | 39 | ## Commit the transaction every 100 users 40 | 41 | ```php 42 | use Innmind\OperatingSystem\Factory; 43 | use Innmind\Filesystem\{ 44 | File, 45 | Name as FileName, 46 | File\Content\Line, 47 | }; 48 | use Innmind\Url\Path; 49 | use Innmind\Immutable\{ 50 | SideEffect, 51 | Sequence, 52 | Predicate\Instance, 53 | }; 54 | 55 | $os = Factory::of(); 56 | $repository = $orm->repository(User::class); 57 | 58 | $_ = $os 59 | ->filesystem() 60 | ->mount(Path::of('somewhere')) 61 | ->get(FileName::of('users.csv')) 62 | ->keep(Instance::of(File::class)) 63 | ->toSequence() 64 | ->flatMap(static fn(File $users) => $users->content()->lines()) 65 | ->map(static fn(Line $line) => User::new(Name::of( 66 | $line->toString(), 67 | ))) 68 | ->chunk(100) 69 | ->foreach( 70 | static fn(Sequence $users) => $orm->transactional( 71 | static fn() => $users 72 | ->sink(SideEffect::identity()) 73 | ->attempt(static fn($_, User $user) => $repository->put($user)) 74 | ->either(), 75 | ), 76 | ); 77 | ``` 78 | -------------------------------------------------------------------------------- /fixtures/AddressValue.php: -------------------------------------------------------------------------------- 1 | sign = $sign; 25 | $this->value = $value; 26 | } 27 | 28 | /** 29 | * @psalm-pure 30 | */ 31 | public static function of(Sign $sign, string $value): self 32 | { 33 | return new self($sign, $value); 34 | } 35 | 36 | #[\Override] 37 | public function property(): string 38 | { 39 | return 'value'; 40 | } 41 | 42 | #[\Override] 43 | public function sign(): Sign 44 | { 45 | return $this->sign; 46 | } 47 | 48 | #[\Override] 49 | public function value(): string 50 | { 51 | return $this->value; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /fixtures/CreatedAt.php: -------------------------------------------------------------------------------- 1 | value = $value; 16 | } 17 | 18 | public function toFloat(): float 19 | { 20 | return $this->value; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /fixtures/CreatedAtType.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | final class CreatedAtType implements Type, SQLType, ElasticsearchType 25 | { 26 | private function __construct() 27 | { 28 | } 29 | 30 | /** 31 | * @psalm-pure 32 | * 33 | * @return Maybe 34 | */ 35 | public static function of(Types $types, Concrete $type): Maybe 36 | { 37 | return Maybe::just($type) 38 | ->filter(static fn($type) => $type->accepts(ClassName::of(CreatedAt::class))) 39 | ->map(static fn() => new self); 40 | } 41 | 42 | #[\Override] 43 | public function elasticsearchType(): array 44 | { 45 | return ['type' => 'double']; 46 | } 47 | 48 | #[\Override] 49 | public function sqlType(): Definition 50 | { 51 | return Definition::decimal(65, 2); 52 | } 53 | 54 | #[\Override] 55 | public function normalize(mixed $value): null|string|int|float|bool 56 | { 57 | return $value->toFloat(); 58 | } 59 | 60 | #[\Override] 61 | public function denormalize(null|string|int|float|bool $value): mixed 62 | { 63 | if (!\is_numeric($value)) { 64 | throw new \LogicException("'$value' is not a float"); 65 | } 66 | 67 | // With SQL the value is read from the database as a string. Adding 0 68 | // allows to convert the string to the correct type (int or float) 69 | // without modifying its value. 70 | /** @psalm-suppress InvalidOperand */ 71 | return new CreatedAt($value + 0); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /fixtures/Ids.php: -------------------------------------------------------------------------------- 1 | > */ 22 | private Sequence $values; 23 | 24 | /** 25 | * @param Sequence> $values 26 | */ 27 | private function __construct(Sequence $values) 28 | { 29 | $this->values = $values; 30 | } 31 | 32 | /** 33 | * @psalm-pure 34 | * 35 | * @param Sequence> $values 36 | */ 37 | public static function in(Sequence $values): self 38 | { 39 | return new self($values); 40 | } 41 | 42 | #[\Override] 43 | public function property(): string 44 | { 45 | return 'id'; 46 | } 47 | 48 | #[\Override] 49 | public function sign(): Sign 50 | { 51 | return Sign::in; 52 | } 53 | 54 | /** 55 | * @return Sequence> 56 | */ 57 | #[\Override] 58 | public function value(): Sequence 59 | { 60 | return $this->values; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /fixtures/MainAddress.php: -------------------------------------------------------------------------------- 1 | */ 11 | private Id $id; 12 | 13 | private function __construct() 14 | { 15 | $this->id = Id::new(self::class); 16 | } 17 | 18 | public static function new(): self 19 | { 20 | return new self; 21 | } 22 | 23 | public function id(): Id 24 | { 25 | return $this->id; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /fixtures/Role.php: -------------------------------------------------------------------------------- 1 | */ 11 | private Id $id; 12 | 13 | /** 14 | * @param Id $id 15 | */ 16 | private function __construct(Id $id) 17 | { 18 | $this->id = $id; 19 | } 20 | 21 | /** 22 | * @param Id $id 23 | */ 24 | public static function of(Id $id): self 25 | { 26 | return new self($id); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /fixtures/Sortable.php: -------------------------------------------------------------------------------- 1 | value = $value; 16 | } 17 | 18 | /** 19 | * @psalm-pure 20 | */ 21 | public static function of(?string $value): ?self 22 | { 23 | return match ($value) { 24 | null => null, 25 | default => new self($value), 26 | }; 27 | } 28 | 29 | public function toString(): string 30 | { 31 | return $this->value; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /fixtures/SortableType.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class SortableType implements Type, ElasticsearchType 23 | { 24 | private function __construct() 25 | { 26 | } 27 | 28 | /** 29 | * @psalm-pure 30 | * 31 | * @return Maybe 32 | */ 33 | public static function of(Types $types, Concrete $type): Maybe 34 | { 35 | return Maybe::just($type) 36 | ->filter(static fn($type) => $type->accepts(ClassName::of(Sortable::class))) 37 | ->map(static fn() => new self); 38 | } 39 | 40 | #[\Override] 41 | public function elasticsearchType(): array 42 | { 43 | return ['type' => 'keyword']; 44 | } 45 | 46 | #[\Override] 47 | public function normalize(mixed $value): null|string|int|float|bool 48 | { 49 | return $value->toString(); 50 | } 51 | 52 | #[\Override] 53 | public function denormalize(null|string|int|float|bool $value): mixed 54 | { 55 | if (!\is_string($value)) { 56 | throw new \LogicException("'$value' is not a string"); 57 | } 58 | 59 | return new Sortable($value); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /fixtures/User/Address.php: -------------------------------------------------------------------------------- 1 | value = $value; 27 | $this->sortable = new Sortable($value); 28 | $this->enabled = $enabled; 29 | } 30 | 31 | public static function new(string $value): self 32 | { 33 | return new self($value, true); 34 | } 35 | 36 | public function disable(): self 37 | { 38 | return new self($this->value, false); 39 | } 40 | 41 | public function enabled(): bool 42 | { 43 | return $this->enabled; 44 | } 45 | 46 | public function toString(): string 47 | { 48 | return $this->value; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /fixtures/Username.php: -------------------------------------------------------------------------------- 1 | |Sequence> */ 26 | private Maybe|Sequence $value; 27 | 28 | /** 29 | * @param Maybe|Sequence> $value 30 | */ 31 | private function __construct(Sign $sign, Maybe|Sequence $value) 32 | { 33 | $this->sign = $sign; 34 | $this->value = $value; 35 | } 36 | 37 | /** 38 | * @psalm-pure 39 | * 40 | * @param Str|Sequence $value 41 | */ 42 | public static function of(Sign $sign, Str|Sequence $value): self 43 | { 44 | $value = match (true) { 45 | $value instanceof Str => Maybe::just($value), 46 | default => $value->map(Maybe::just(...)), 47 | }; 48 | 49 | return new self($sign, $value); 50 | } 51 | 52 | #[\Override] 53 | public function property(): string 54 | { 55 | return 'nameStr'; 56 | } 57 | 58 | #[\Override] 59 | public function sign(): Sign 60 | { 61 | return $this->sign; 62 | } 63 | 64 | /** 65 | * @return Maybe|Sequence> 66 | */ 67 | #[\Override] 68 | public function value(): Maybe|Sequence 69 | { 70 | return $this->value; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /properties/AddElementToCollections.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class AddElementToCollections implements Property 20 | { 21 | private $createdAt; 22 | private string $address; 23 | 24 | private function __construct( 25 | $createdAt, 26 | string $address, 27 | ) { 28 | $this->createdAt = $createdAt; 29 | $this->address = $address; 30 | } 31 | 32 | public static function any(): Set\Provider 33 | { 34 | return Set::compose( 35 | static fn(...$args) => new self(...$args), 36 | PointInTime::any(), 37 | Set::strings()->madeOf(Set::strings()->chars()->alphanumerical()), 38 | ); 39 | } 40 | 41 | public function applicableTo(object $manager): bool 42 | { 43 | return true; 44 | } 45 | 46 | public function ensureHeldBy(Assert $assert, object $manager): object 47 | { 48 | $repository = $manager->repository(User::class); 49 | $user = User::new($this->createdAt); 50 | 51 | $manager->transactional( 52 | fn() => $repository 53 | ->all() 54 | ->map(fn($user) => $user->addAddress($this->address)) 55 | ->sink(SideEffect::identity()) 56 | ->attempt(static fn($_, $user) => $repository->put($user)) 57 | ->either(), 58 | ); 59 | 60 | $repository 61 | ->all() 62 | ->foreach(fn($user) => $assert->true( 63 | $user 64 | ->addresses() 65 | ->map(static fn($address) => $address->toString()) 66 | ->contains($this->address), 67 | )); 68 | 69 | return $manager; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /properties/AddingOutsideOfTransactionIsNotAllowed.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class AddingOutsideOfTransactionIsNotAllowed implements Property 19 | { 20 | private $createdAt; 21 | 22 | private function __construct($createdAt) 23 | { 24 | $this->createdAt = $createdAt; 25 | } 26 | 27 | public static function any(): Set 28 | { 29 | return PointInTime::any()->map(static fn($createdAt) => new self($createdAt)); 30 | } 31 | 32 | public function applicableTo(object $manager): bool 33 | { 34 | return true; 35 | } 36 | 37 | public function ensureHeldBy(Assert $assert, object $manager): object 38 | { 39 | $user = User::new($this->createdAt); 40 | 41 | $assert->throws( 42 | static fn() => $manager 43 | ->repository(User::class) 44 | ->put($user) 45 | ->unwrap(), 46 | \LogicException::class, 47 | 'Mutation outside of a transaction', 48 | ); 49 | 50 | return $manager; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /properties/ContainsAggregate.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class ContainsAggregate implements Property 19 | { 20 | private $createdAt; 21 | 22 | private function __construct($createdAt) 23 | { 24 | $this->createdAt = $createdAt; 25 | } 26 | 27 | public static function any(): Set 28 | { 29 | return PointInTime::any()->map(static fn($createdAt) => new self($createdAt)); 30 | } 31 | 32 | public function applicableTo(object $manager): bool 33 | { 34 | return true; 35 | } 36 | 37 | public function ensureHeldBy(Assert $assert, object $manager): object 38 | { 39 | $user = User::new($this->createdAt); 40 | 41 | $assert->false( 42 | $manager 43 | ->repository(User::class) 44 | ->contains($user->id()), 45 | ); 46 | 47 | $manager->transactional( 48 | static fn() => $manager 49 | ->repository(User::class) 50 | ->put($user) 51 | ->either(), 52 | ); 53 | 54 | $assert->true( 55 | $manager 56 | ->repository(User::class) 57 | ->contains($user->id()), 58 | ); 59 | 60 | return $manager; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /properties/DroppingMoreElementsThanWasTakenReturnsNothing.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class DroppingMoreElementsThanWasTakenReturnsNothing implements Property 19 | { 20 | private $createdAt; 21 | private string $name; 22 | private int $take; 23 | private int $drop; 24 | 25 | private function __construct( 26 | $createdAt, 27 | string $name, 28 | int $take, 29 | int $drop, 30 | ) { 31 | $this->createdAt = $createdAt; 32 | $this->name = $name; 33 | $this->take = $take; 34 | $this->drop = $drop; 35 | } 36 | 37 | public static function any(): Set\Provider 38 | { 39 | return Set::compose( 40 | static fn(...$args) => new self(...$args), 41 | PointInTime::any(), 42 | Set::strings() 43 | ->madeOf(Set::strings()->chars()->alphanumerical()) 44 | ->between(0, 100), 45 | Set::integers()->between(1, 1_000_000), // upper limit to avoid PHP switching the type to float 46 | Set::integers()->between(0, 1_000_000), // upper limit to avoid PHP switching the type to float 47 | ); 48 | } 49 | 50 | public function applicableTo(object $manager): bool 51 | { 52 | return true; 53 | } 54 | 55 | public function ensureHeldBy(Assert $assert, object $manager): object 56 | { 57 | $user = User::new($this->createdAt, $this->name); 58 | 59 | $repository = $manager->repository(User::class); 60 | $manager->transactional( 61 | static fn() => $repository->put($user)->either(), 62 | ); 63 | 64 | $found = $repository 65 | ->all() 66 | ->take($this->take) 67 | ->drop($this->take + $this->drop) 68 | ->sequence(); 69 | 70 | $assert 71 | ->expected(0) 72 | ->same($found->size()); 73 | 74 | return $manager; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /properties/EffectOptionalNothingOnAllAggregates.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | final class EffectOptionalNothingOnAllAggregates implements Property 22 | { 23 | private function __construct( 24 | private ?string $name, 25 | private string $address, 26 | private $createdAt, 27 | ) { 28 | } 29 | 30 | public static function any(): Set\Provider 31 | { 32 | return Set::compose( 33 | static fn(...$args) => new self(...$args), 34 | Set::strings() 35 | ->madeOf(Set::strings()->chars()->alphanumerical()) 36 | ->atLeast(10) // to limit collisions 37 | ->nullable(), 38 | Set::strings() 39 | ->madeOf(Set::strings()->chars()->alphanumerical()) 40 | ->atLeast(10), // to limit collisions 41 | PointInTime::any(), 42 | ); 43 | } 44 | 45 | public function applicableTo(object $manager): bool 46 | { 47 | return true; 48 | } 49 | 50 | public function ensureHeldBy(Assert $assert, object $manager): object 51 | { 52 | $user = User::new($this->createdAt, $this->name)->changeBillingAddress( 53 | $this->address, 54 | ); 55 | $manager->transactional( 56 | static fn() => $manager 57 | ->repository(User::class) 58 | ->put($user) 59 | ->either(), 60 | ); 61 | $id = $user->id()->toString(); 62 | unset($user); // to make sure there is no in memory cache somewhere 63 | 64 | $manager->transactional( 65 | static fn() => $manager 66 | ->repository(User::class) 67 | ->effect(Effect::optional('billingAddress')->nothing()) 68 | ->either(), 69 | ); 70 | 71 | $manager 72 | ->repository(User::class) 73 | ->all() 74 | ->foreach( 75 | static fn($user) => $assert->null( 76 | $user->billingAddress()->match( 77 | static fn($address) => $address, 78 | static fn() => null, 79 | ), 80 | ), 81 | ); 82 | 83 | return $manager; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /properties/EffectPropertyOnAllAggregates.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | final class EffectPropertyOnAllAggregates implements Property 22 | { 23 | private ?string $name; 24 | private ?string $newName; 25 | private $createdAt; 26 | 27 | private function __construct(?string $name, ?string $newName, $createdAt) 28 | { 29 | $this->name = $name; 30 | $this->newName = $newName; 31 | $this->createdAt = $createdAt; 32 | } 33 | 34 | public static function any(): Set\Provider 35 | { 36 | return Set::compose( 37 | static fn(...$args) => new self(...$args), 38 | Set::strings() 39 | ->madeOf(Set::strings()->chars()->alphanumerical()) 40 | ->atLeast(10) // to limit collisions 41 | ->nullable(), 42 | Set::strings() 43 | ->madeOf(Set::strings()->chars()->alphanumerical()) 44 | ->atLeast(10) // to limit collisions 45 | ->nullable(), 46 | PointInTime::any(), 47 | ); 48 | } 49 | 50 | public function applicableTo(object $manager): bool 51 | { 52 | return true; 53 | } 54 | 55 | public function ensureHeldBy(Assert $assert, object $manager): object 56 | { 57 | $user = User::new($this->createdAt, $this->name); 58 | $manager->transactional( 59 | static fn() => $manager 60 | ->repository(User::class) 61 | ->put($user) 62 | ->either(), 63 | ); 64 | unset($user); // to make sure there is no in memory cache somewhere 65 | 66 | $manager->transactional( 67 | fn() => $manager 68 | ->repository(User::class) 69 | ->effect(Effect::property('name')->assign( 70 | $this->newName, 71 | )) 72 | ->either(), 73 | ); 74 | 75 | $manager 76 | ->repository(User::class) 77 | ->all() 78 | ->foreach(fn($user) => $assert->same( 79 | $this->newName, 80 | $user->name(), 81 | )); 82 | 83 | return $manager; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /properties/IncrementallyAddElementsToACollection.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | final class IncrementallyAddElementsToACollection implements Property 22 | { 23 | private $createdAt; 24 | private array $addresses; 25 | 26 | private function __construct( 27 | $createdAt, 28 | array $addresses, 29 | ) { 30 | $this->createdAt = $createdAt; 31 | $this->addresses = $addresses; 32 | } 33 | 34 | public static function any(): Set\Provider 35 | { 36 | return Set::compose( 37 | static fn(...$args) => new self(...$args), 38 | PointInTime::any(), 39 | Set::sequence( 40 | Set::strings()->madeOf(Set::strings()->chars()->alphanumerical()), 41 | )->atLeast(1), 42 | ); 43 | } 44 | 45 | public function applicableTo(object $manager): bool 46 | { 47 | return true; 48 | } 49 | 50 | public function ensureHeldBy(Assert $assert, object $manager): object 51 | { 52 | $repository = $manager->repository(User::class); 53 | $user = User::new($this->createdAt); 54 | 55 | $manager->transactional( 56 | static fn() => $repository->put($user)->either(), 57 | ); 58 | $id = $user->id()->toString(); 59 | unset($user); // to make sure there is no in memory cache somewhere 60 | 61 | foreach ($this->addresses as $index => $address) { 62 | $manager->transactional( 63 | static function() use ($assert, $repository, $index, $address, $id) { 64 | $user = $repository->get(Id::of(User::class, $id))->match( 65 | static fn($user) => $user, 66 | static fn() => null, 67 | ); 68 | 69 | $assert->not()->null($user); 70 | $assert->count( 71 | $index, 72 | $user->addresses(), 73 | 'Previous addresses have been lost', 74 | ); 75 | 76 | return $repository 77 | ->put($user->addAddress($address)) 78 | ->either(); 79 | }, 80 | ); 81 | } 82 | 83 | return $manager; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /properties/ListingAggregatesUseConstantMemory.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class ListingAggregatesUseConstantMemory implements Property 19 | { 20 | private function __construct() 21 | { 22 | } 23 | 24 | public static function any(): Set 25 | { 26 | return Set::of(new self); 27 | } 28 | 29 | public function applicableTo(object $manager): bool 30 | { 31 | return true; 32 | } 33 | 34 | public function ensureHeldBy(Assert $assert, object $manager): object 35 | { 36 | $repository = $manager->repository(User::class); 37 | 38 | $assert 39 | ->memory( 40 | static function() use ($repository) { 41 | $_ = $repository 42 | ->all() 43 | ->foreach(static fn() => null); 44 | }, 45 | ) 46 | ->inLessThan() 47 | ->megaBytes(1); 48 | 49 | return $manager; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /properties/MatchingDropAndTake.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | final class MatchingDropAndTake implements Property 25 | { 26 | private $createdAt; 27 | private string $name; 28 | 29 | private function __construct( 30 | $createdAt, 31 | string $name, 32 | ) { 33 | $this->createdAt = $createdAt; 34 | $this->name = $name; 35 | } 36 | 37 | public static function any(): Set\Provider 38 | { 39 | return Set::compose( 40 | static fn(...$args) => new self(...$args), 41 | PointInTime::any(), 42 | Set::strings() 43 | ->madeOf(Set::strings()->chars()->alphanumerical()) 44 | ->between(10, 100), 45 | ); 46 | } 47 | 48 | public function applicableTo(object $manager): bool 49 | { 50 | return true; 51 | } 52 | 53 | public function ensureHeldBy(Assert $assert, object $manager): object 54 | { 55 | $user1 = User::new($this->createdAt, $this->name); 56 | $user2 = User::new($this->createdAt, $this->name); 57 | $user3 = User::new($this->createdAt, $this->name); 58 | 59 | $repository = $manager->repository(User::class); 60 | $manager->transactional( 61 | static function() use ($repository, $user1, $user2, $user3) { 62 | $repository->put($user1)->unwrap(); 63 | $repository->put($user2)->unwrap(); 64 | $repository->put($user3)->unwrap(); 65 | 66 | return Either::right(null); 67 | }, 68 | ); 69 | 70 | $found = $repository 71 | ->matching(Username::of( 72 | Sign::equality, 73 | Str::of($this->name), 74 | )) 75 | ->drop(1) 76 | ->take(1) 77 | ->sequence(); 78 | 79 | $assert 80 | ->expected(1) 81 | ->same($found->size()); 82 | 83 | return $manager; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /properties/MatchingExclusion.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | final class MatchingExclusion implements Property 24 | { 25 | private $createdAt; 26 | private string $name; 27 | 28 | private function __construct( 29 | $createdAt, 30 | string $name, 31 | ) { 32 | $this->createdAt = $createdAt; 33 | $this->name = $name; 34 | } 35 | 36 | public static function any(): Set\Provider 37 | { 38 | return Set::compose( 39 | static fn(...$args) => new self(...$args), 40 | PointInTime::any(), 41 | Set::strings() 42 | ->madeOf(Set::strings()->chars()->alphanumerical()) 43 | ->between(10, 100), 44 | ); 45 | } 46 | 47 | public function applicableTo(object $manager): bool 48 | { 49 | return true; 50 | } 51 | 52 | public function ensureHeldBy(Assert $assert, object $manager): object 53 | { 54 | $user = User::new($this->createdAt, $this->name); 55 | 56 | $repository = $manager->repository(User::class); 57 | $manager->transactional( 58 | static fn() => $repository 59 | ->put($user) 60 | ->either(), 61 | ); 62 | 63 | $found = $repository 64 | ->matching( 65 | Username::of(Sign::equality, Str::of($this->name))->not(), 66 | ) 67 | ->map(static fn($user) => $user->id()->toString()) 68 | ->toList(); 69 | 70 | $assert 71 | ->expected($user->id()->toString()) 72 | ->not() 73 | ->in($found); 74 | 75 | return $manager; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /properties/RemoveAggregate.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class RemoveAggregate implements Property 19 | { 20 | private $createdAt; 21 | 22 | private function __construct($createdAt) 23 | { 24 | $this->createdAt = $createdAt; 25 | } 26 | 27 | public static function any(): Set 28 | { 29 | return PointInTime::any()->map(static fn($createdAt) => new self($createdAt)); 30 | } 31 | 32 | public function applicableTo(object $manager): bool 33 | { 34 | return true; 35 | } 36 | 37 | public function ensureHeldBy(Assert $assert, object $manager): object 38 | { 39 | $current = $manager 40 | ->repository(User::class) 41 | ->size(); 42 | 43 | $user = User::new($this->createdAt); 44 | $manager->transactional( 45 | static fn() => $manager 46 | ->repository(User::class) 47 | ->put($user) 48 | ->either(), 49 | ); 50 | 51 | $manager->transactional( 52 | static fn() => $manager 53 | ->repository(User::class) 54 | ->remove($user->id()) 55 | ->either(), 56 | ); 57 | 58 | $assert->false( 59 | $manager 60 | ->repository(User::class) 61 | ->contains($user->id()), 62 | ); 63 | $assert->null( 64 | $manager 65 | ->repository(User::class) 66 | ->get($user->id()) 67 | ->match( 68 | static fn($user) => $user, 69 | static fn() => null, 70 | ), 71 | ); 72 | $assert 73 | ->expected($current) 74 | ->same( 75 | $manager 76 | ->repository(User::class) 77 | ->size(), 78 | $user->id()->toString(), 79 | ); 80 | 81 | return $manager; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /properties/RemoveUnknownAggregateDoesNothing.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | final class RemoveUnknownAggregateDoesNothing implements Property 22 | { 23 | private string $uuid; 24 | 25 | private function __construct(string $uuid) 26 | { 27 | $this->uuid = $uuid; 28 | } 29 | 30 | public static function any(): Set 31 | { 32 | return Set::uuid()->map(static fn($uuid) => new self($uuid)); 33 | } 34 | 35 | public function applicableTo(object $manager): bool 36 | { 37 | return true; 38 | } 39 | 40 | public function ensureHeldBy(Assert $assert, object $manager): object 41 | { 42 | $assert 43 | ->not() 44 | ->throws( 45 | fn() => $manager->transactional( 46 | fn() => Either::right( 47 | $manager 48 | ->repository(User::class) 49 | ->remove(Id::of(User::class, $this->uuid)) 50 | ->unwrap(), 51 | ), 52 | ), 53 | ); 54 | 55 | return $manager; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /properties/RemovingOutsideOfTransactionIsNotAllowed.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class RemovingOutsideOfTransactionIsNotAllowed implements Property 18 | { 19 | private function __construct() 20 | { 21 | } 22 | 23 | public static function any(): Set 24 | { 25 | return Set::of(new self); 26 | } 27 | 28 | public function applicableTo(object $manager): bool 29 | { 30 | return $manager->repository(User::class)->any(); 31 | } 32 | 33 | public function ensureHeldBy(Assert $assert, object $manager): object 34 | { 35 | $user = $manager 36 | ->repository(User::class) 37 | ->all() 38 | ->take(1) 39 | ->first() 40 | ->match( 41 | static fn($user) => $user, 42 | static fn() => null, 43 | ); 44 | 45 | $assert->throws( 46 | static fn() => $manager 47 | ->repository(User::class) 48 | ->remove($user->id()) 49 | ->unwrap(), 50 | \LogicException::class, 51 | 'Mutation outside of a transaction', 52 | ); 53 | 54 | return $manager; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /properties/SavingAggregateTwiceAddsItOnce.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class SavingAggregateTwiceAddsItOnce implements Property 20 | { 21 | private $createdAt; 22 | 23 | private function __construct($createdAt) 24 | { 25 | $this->createdAt = $createdAt; 26 | } 27 | 28 | public static function any(): Set 29 | { 30 | return PointInTime::any()->map(static fn($createdAt) => new self($createdAt)); 31 | } 32 | 33 | public function applicableTo(object $manager): bool 34 | { 35 | return true; 36 | } 37 | 38 | public function ensureHeldBy(Assert $assert, object $manager): object 39 | { 40 | $current = $manager 41 | ->repository(User::class) 42 | ->size(); 43 | 44 | $user = User::new($this->createdAt); 45 | 46 | $manager->transactional( 47 | static function() use ($manager, $user) { 48 | $manager 49 | ->repository(User::class) 50 | ->put($user) 51 | ->unwrap(); 52 | $manager 53 | ->repository(User::class) 54 | ->put($user) 55 | ->unwrap(); 56 | 57 | return Either::right(null); 58 | }, 59 | ); 60 | 61 | $assert 62 | ->expected($current + 1) 63 | ->same( 64 | $manager 65 | ->repository(User::class) 66 | ->size(), 67 | ); 68 | 69 | return $manager; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /properties/Size.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class Size implements Property 18 | { 19 | private function __construct() 20 | { 21 | } 22 | 23 | public static function any(): Set 24 | { 25 | return Set::of(new self); 26 | } 27 | 28 | public function applicableTo(object $manager): bool 29 | { 30 | return true; 31 | } 32 | 33 | public function ensureHeldBy(Assert $assert, object $manager): object 34 | { 35 | $repository = $manager->repository(User::class); 36 | 37 | $assert 38 | ->expected($repository->all()->sequence()->size()) 39 | ->same($repository->size()); 40 | 41 | return $manager; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /properties/StreamUpdate.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class StreamUpdate implements Property 18 | { 19 | private string $name; 20 | 21 | private function __construct(string $name) 22 | { 23 | $this->name = $name; 24 | } 25 | 26 | public static function any(): Set 27 | { 28 | return Set::strings() 29 | ->madeOf(Set::strings()->chars()->alphanumerical()) 30 | ->map(static fn($name) => new self($name)); 31 | } 32 | 33 | public function applicableTo(object $manager): bool 34 | { 35 | return $manager->repository(User::class)->any(); 36 | } 37 | 38 | public function ensureHeldBy(Assert $assert, object $manager): object 39 | { 40 | $manager->transactional( 41 | fn() => $manager 42 | ->repository(User::class) 43 | ->all() 44 | ->map(fn($user) => $user->rename($this->name)) 45 | ->sink(null) 46 | ->attempt( 47 | static fn($_, $user) => $manager 48 | ->repository(User::class) 49 | ->put($user), 50 | ) 51 | ->either(), 52 | ); 53 | 54 | $_ = $manager 55 | ->repository(User::class) 56 | ->all() 57 | ->foreach( 58 | fn($user) => $assert 59 | ->expected($this->name) 60 | ->same($user->name()), 61 | ); 62 | 63 | return $manager; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /properties/UpdateOptionalWithoutChangingInnerProperties.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class UpdateOptionalWithoutChangingInnerProperties implements Property 20 | { 21 | private $createdAt; 22 | private string $name; 23 | 24 | private function __construct( 25 | $createdAt, 26 | string $name, 27 | ) { 28 | $this->createdAt = $createdAt; 29 | $this->name = $name; 30 | } 31 | 32 | public static function any(): Set\Provider 33 | { 34 | return Set::compose( 35 | static fn(...$args) => new self(...$args), 36 | PointInTime::any(), 37 | Set::strings() 38 | ->madeOf(Set::strings()->chars()->alphanumerical()) 39 | ->between(10, 100), 40 | ); 41 | } 42 | 43 | public function applicableTo(object $manager): bool 44 | { 45 | return true; 46 | } 47 | 48 | public function ensureHeldBy(Assert $assert, object $manager): object 49 | { 50 | $user = User::new($this->createdAt)->changeBillingAddress($this->name); 51 | 52 | $repository = $manager->repository(User::class); 53 | $manager->transactional( 54 | static function() use ($repository, $user) { 55 | $repository->put($user)->unwrap(); 56 | 57 | return Either::right(null); 58 | }, 59 | ); 60 | 61 | $user = $user->mapBillingAddress(static fn($address) => clone $address); 62 | 63 | $assert->not()->throws( 64 | static fn() => $manager->transactional( 65 | static function() use ($repository, $user) { 66 | $repository->put($user)->unwrap(); 67 | 68 | return Either::right(null); 69 | }, 70 | ), 71 | ); 72 | 73 | return $manager; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /properties/UpdatingOutsideOfTransactionIsNotAllowed.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class UpdatingOutsideOfTransactionIsNotAllowed implements Property 18 | { 19 | private function __construct() 20 | { 21 | } 22 | 23 | public static function any(): Set 24 | { 25 | return Set::of(new self); 26 | } 27 | 28 | public function applicableTo(object $manager): bool 29 | { 30 | return $manager->repository(User::class)->any(); 31 | } 32 | 33 | public function ensureHeldBy(Assert $assert, object $manager): object 34 | { 35 | $user = $manager 36 | ->repository(User::class) 37 | ->all() 38 | ->take(1) 39 | ->first() 40 | ->match( 41 | static fn($user) => $user, 42 | static fn() => null, 43 | ); 44 | 45 | $assert->throws( 46 | static fn() => $manager 47 | ->repository(User::class) 48 | ->put($user) 49 | ->unwrap(), 50 | \LogicException::class, 51 | 'Mutation outside of a transaction', 52 | ); 53 | 54 | return $manager; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Adapter.php: -------------------------------------------------------------------------------- 1 | $definition 18 | * 19 | * @return Repository 20 | */ 21 | public function repository(Aggregate $definition): Repository; 22 | 23 | public function transaction(): Transaction; 24 | } 25 | -------------------------------------------------------------------------------- /src/Adapter/Elasticsearch.php: -------------------------------------------------------------------------------- 1 | transport = $transport; 22 | $this->url = $url; 23 | $this->transaction = Elasticsearch\Transaction::of(); 24 | } 25 | 26 | public static function of(Transport $transport, ?Url $url = null): self 27 | { 28 | return new self($transport, $url ?? Url::of('http://localhost:9200/')); 29 | } 30 | 31 | #[\Override] 32 | public function repository(Aggregate $definition): Repository 33 | { 34 | return Elasticsearch\Repository::of( 35 | $this->transport, 36 | $definition, 37 | $this->url, 38 | ); 39 | } 40 | 41 | #[\Override] 42 | public function transaction(): Transaction 43 | { 44 | return $this->transaction; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Adapter/Elasticsearch/CreateIndex.php: -------------------------------------------------------------------------------- 1 | http = $http; 40 | $this->aggregates = $aggregates; 41 | $this->mapping = Mapping::new(); 42 | $this->url = $url; 43 | } 44 | 45 | /** 46 | * @param class-string $class 47 | * 48 | * @return Maybe 49 | */ 50 | public function __invoke(string $class): Maybe 51 | { 52 | $definition = $this->aggregates->get($class); 53 | 54 | return ($this->http)(Request::of( 55 | $this->url->withPath( 56 | Path::of('/'.$definition->name()), 57 | ), 58 | Method::put, 59 | ProtocolVersion::v11, 60 | Headers::of( 61 | ContentType::of(new MediaType('application', 'json')), 62 | ), 63 | Content::ofString(Json::encode([ 64 | 'mappings' => ($this->mapping)($definition), 65 | ])), 66 | )) 67 | ->maybe() 68 | ->map(static fn() => new SideEffect); 69 | } 70 | 71 | public static function of( 72 | Transport $transport, 73 | Aggregates $aggregates, 74 | Url $url, 75 | ): self { 76 | return new self($transport, $aggregates, $url); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Adapter/Elasticsearch/DropIndex.php: -------------------------------------------------------------------------------- 1 | http = $http; 35 | $this->aggregates = $aggregates; 36 | $this->url = $url; 37 | } 38 | 39 | /** 40 | * @param class-string $class 41 | * 42 | * @return Maybe 43 | */ 44 | public function __invoke(string $class): Maybe 45 | { 46 | $definition = $this->aggregates->get($class); 47 | 48 | return ($this->http)(Request::of( 49 | $this 50 | ->url 51 | ->withPath(Path::of('/'.$definition->name())) 52 | ->withQuery(Query::of('ignore_unavailable=true')), 53 | Method::delete, 54 | ProtocolVersion::v11, 55 | )) 56 | ->maybe() 57 | ->map(static fn() => new SideEffect); 58 | } 59 | 60 | public static function of( 61 | Transport $transport, 62 | Aggregates $aggregates, 63 | Url $url, 64 | ): self { 65 | return new self($transport, $aggregates, $url); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Adapter/Elasticsearch/ElasticsearchType.php: -------------------------------------------------------------------------------- 1 | $type->elasticsearchType(), 22 | $type instanceof Type\NullableType, 23 | $type instanceof Type\MaybeType => $this($type->inner()), 24 | $type instanceof Type\BoolType => ['type' => 'boolean'], 25 | $type instanceof Type\IdType => ['type' => 'keyword'], 26 | $type instanceof Type\IntType => ['type' => 'long'], 27 | default => ['type' => 'text'], 28 | }; 29 | } 30 | 31 | /** 32 | * @psalm-pure 33 | */ 34 | public static function new(): self 35 | { 36 | return new self; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Adapter/Elasticsearch/Mapping.php: -------------------------------------------------------------------------------- 1 | mapType = MapType::new(); 19 | } 20 | 21 | public function __invoke(Definition $definition): array 22 | { 23 | $properties = $this->properties($definition->properties()); 24 | $entities = $definition 25 | ->entities() 26 | ->map(fn($entity) => [ 27 | $entity->name() => [ 28 | 'properties' => $this->properties($entity->properties()), 29 | ], 30 | ]) 31 | ->toList(); 32 | $optionals = $definition 33 | ->optionals() 34 | ->map(fn($optional) => [ 35 | $optional->name() => [ 36 | 'properties' => $this->properties($optional->properties()), 37 | ], 38 | ]) 39 | ->toList(); 40 | $collections = $definition 41 | ->collections() 42 | ->map(fn($collection) => [ 43 | $collection->name() => [ 44 | 'type' => 'nested', 45 | 'properties' => $this->properties($collection->properties()), 46 | ], 47 | ]) 48 | ->toList(); 49 | 50 | return ['properties' => \array_merge( 51 | [$definition->id()->property() => ['type' => 'keyword']], 52 | $properties, 53 | ...$entities, 54 | ...$optionals, 55 | ...$collections, 56 | )]; 57 | } 58 | 59 | /** 60 | * @psalm-pure 61 | */ 62 | public static function new(): self 63 | { 64 | return new self; 65 | } 66 | 67 | /** 68 | * @param Sequence $properties 69 | */ 70 | private function properties(Sequence $properties): array 71 | { 72 | return \array_merge( 73 | ...$properties 74 | ->map(fn($property) => [ 75 | $property->name() => ($this->mapType)($property->type()), 76 | ]) 77 | ->toList(), 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Adapter/Elasticsearch/Refresh.php: -------------------------------------------------------------------------------- 1 | transport = $transport; 21 | } 22 | 23 | #[\Override] 24 | public function __invoke(Request $request): Either 25 | { 26 | $path = Str::of($request->url()->path()->toString()); 27 | 28 | if ( 29 | !$request->method()->safe() && 30 | ( 31 | $path->matches('~[a-zA-Z0-9]{8}(-[a-zA-Z0-9]{4}){3}-[a-zA-Z0-9]{12}$~') || 32 | $path->endsWith('_delete_by_query') || 33 | $path->endsWith('_update_by_query') 34 | ) 35 | ) { 36 | $request = Request::of( 37 | $request->url()->withQuery(Query::of('refresh=true')), 38 | $request->method(), 39 | $request->protocolVersion(), 40 | $request->headers(), 41 | $request->body(), 42 | ); 43 | } 44 | 45 | return ($this->transport)($request); 46 | } 47 | 48 | public static function of(Transport $transport): self 49 | { 50 | return new self($transport); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Adapter/Elasticsearch/Transaction.php: -------------------------------------------------------------------------------- 1 | 41 | */ 42 | #[\Override] 43 | public function commit(mixed $value): Attempt 44 | { 45 | return Attempt::result($value); 46 | } 47 | 48 | /** 49 | * @template R 50 | * 51 | * @param R $value 52 | * 53 | * @return Attempt 54 | */ 55 | #[\Override] 56 | public function rollback(mixed $value): Attempt 57 | { 58 | return Attempt::result($value); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Adapter/Filesystem.php: -------------------------------------------------------------------------------- 1 | transaction = Filesystem\Transaction::of($adapter); 19 | } 20 | 21 | public static function of(Storage $adapter): self 22 | { 23 | return new self($adapter); 24 | } 25 | 26 | #[\Override] 27 | public function repository(Aggregate $definition): Repository 28 | { 29 | return Filesystem\Repository::of( 30 | $this->transaction, 31 | $definition, 32 | ); 33 | } 34 | 35 | #[\Override] 36 | public function transaction(): Transaction 37 | { 38 | return $this->transaction; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Adapter/Repository.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | public function get(Aggregate\Id $id): Maybe; 28 | public function contains(Aggregate\Id $id): bool; 29 | 30 | /** 31 | * @return Attempt 32 | */ 33 | public function add(Aggregate $data): Attempt; 34 | 35 | /** 36 | * @return Attempt 37 | */ 38 | public function update(Diff $data): Attempt; 39 | 40 | /** 41 | * @return Attempt 42 | */ 43 | public function remove(Aggregate\Id $id): Attempt; 44 | 45 | /** 46 | * @return Attempt 47 | */ 48 | public function removeAll(Specification $specification): Attempt; 49 | 50 | /** 51 | * @param ?positive-int $drop 52 | * @param ?positive-int $take 53 | * 54 | * @return Sequence 55 | */ 56 | public function fetch( 57 | ?Specification $specification, 58 | null|Sort\Property|Sort\Entity $sort, 59 | ?int $drop, 60 | ?int $take, 61 | ): Sequence; 62 | 63 | /** 64 | * @return 0|positive-int 65 | */ 66 | public function size(?Specification $specification = null): int; 67 | 68 | public function any(?Specification $specification = null): bool; 69 | } 70 | -------------------------------------------------------------------------------- /src/Adapter/Repository/CrossAggregateMatching.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public function crossAggregateMatching( 21 | ?Specification $specification, 22 | null|Sort\Property|Sort\Entity $sort, 23 | ?int $drop, 24 | ?int $take, 25 | ): Maybe; 26 | } 27 | -------------------------------------------------------------------------------- /src/Adapter/Repository/Effectful.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | public function effect( 22 | Normalized $effect, 23 | ?Specification $specification, 24 | ): Attempt; 25 | } 26 | -------------------------------------------------------------------------------- /src/Adapter/Repository/SubMatch.php: -------------------------------------------------------------------------------- 1 | value = $value; 17 | } 18 | 19 | /** 20 | * @psalm-pure 21 | */ 22 | public static function of(mixed $value): self 23 | { 24 | return new self($value); 25 | } 26 | 27 | public function unwrap(): mixed 28 | { 29 | return $this->value; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Adapter/SQL.php: -------------------------------------------------------------------------------- 1 | */ 17 | private \WeakReference $established; 18 | 19 | /** 20 | * @param callable(): Connection $establish 21 | */ 22 | private function __construct(callable $establish) 23 | { 24 | $this->establish = $establish; 25 | /** 26 | * Using an unassigned object creates an empty WeakReference 27 | * @var \WeakReference 28 | */ 29 | $this->established = \WeakReference::create(new \stdClass); 30 | } 31 | 32 | public static function of(Connection $connection): self 33 | { 34 | return new self(static fn() => $connection); 35 | } 36 | 37 | /** 38 | * @param callable(): Connection $establish 39 | */ 40 | public static function lazy(callable $establish): self 41 | { 42 | return new self($establish); 43 | } 44 | 45 | #[\Override] 46 | public function repository(Aggregate $definition): Repository 47 | { 48 | return SQL\Repository::of( 49 | $this->connection(), 50 | $definition, 51 | ); 52 | } 53 | 54 | #[\Override] 55 | public function transaction(): Transaction 56 | { 57 | return SQL\Transaction::of($this->connection()); 58 | } 59 | 60 | private function connection(): Connection 61 | { 62 | $connection = $this->established->get(); 63 | 64 | if ($connection) { 65 | return $connection; 66 | } 67 | 68 | $connection = ($this->establish)(); 69 | $this->established = \WeakReference::create($connection); 70 | 71 | return $connection; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Adapter/SQL/MapType.php: -------------------------------------------------------------------------------- 1 | $type->sqlType(), 22 | $type instanceof Type\NullableType, 23 | $type instanceof Type\MaybeType => $this($type->inner())->nullable(), 24 | $type instanceof Type\BoolType => Table\Column\Type::bool() 25 | ->comment('Boolean'), 26 | $type instanceof Type\IdType => Table\Column\Type::uuid() 27 | ->comment('UUID'), 28 | $type instanceof Type\IntType => Table\Column\Type::bigint() 29 | ->comment('TODO Adjust the size depending on your use case'), 30 | $type instanceof Type\PointInTimeType => Table\Column\Type::char(32) 31 | ->comment('Date with timezone down to the microsecond'), 32 | default => Table\Column\Type::longtext() 33 | ->comment('TODO adjust the type depending on your use case'), 34 | }; 35 | } 36 | 37 | /** 38 | * @psalm-pure 39 | */ 40 | public static function new(): self 41 | { 42 | return new self; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Adapter/SQL/SQLType.php: -------------------------------------------------------------------------------- 1 | property = $property; 33 | $this->query = $query; 34 | } 35 | 36 | /** 37 | * @internal 38 | * @psalm-pure 39 | * 40 | * @param non-empty-string $property 41 | */ 42 | public static function of(string $property, Query $query): self 43 | { 44 | return new self($property, $query); 45 | } 46 | 47 | #[\Override] 48 | public function property(): string 49 | { 50 | return $this->property; 51 | } 52 | 53 | #[\Override] 54 | public function sign(): Sign 55 | { 56 | return Sign::in; 57 | } 58 | 59 | #[\Override] 60 | public function value(): Query 61 | { 62 | return $this->query; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Adapter/SQL/Transaction.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 28 | } 29 | 30 | /** 31 | * @internal 32 | */ 33 | public static function of(Connection $connection): self 34 | { 35 | return new self($connection); 36 | } 37 | 38 | #[\Override] 39 | public function start(): Attempt 40 | { 41 | return Attempt::of( 42 | // memoize to force unwrap the monad 43 | fn() => ($this->connection)(new StartTransaction)->memoize(), 44 | )->map(static fn() => SideEffect::identity()); 45 | } 46 | 47 | /** 48 | * @template R 49 | * 50 | * @param R $value 51 | * 52 | * @return Attempt 53 | */ 54 | #[\Override] 55 | public function commit(mixed $value): Attempt 56 | { 57 | $connection = $this->connection; 58 | 59 | return Attempt::of( 60 | // memoize to force unwrap the monad 61 | static fn() => $connection(new Commit)->memoize(), 62 | )->map(static fn() => $value); 63 | } 64 | 65 | /** 66 | * @template R 67 | * 68 | * @param R $value 69 | * 70 | * @return Attempt 71 | */ 72 | #[\Override] 73 | public function rollback(mixed $value): Attempt 74 | { 75 | $connection = $this->connection; 76 | 77 | return Attempt::of( 78 | // memoize to force unwrap the monad 79 | static fn() => $connection(new Rollback)->memoize(), 80 | )->map(static fn() => $value); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Adapter/Transaction.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public function start(): Attempt; 17 | 18 | /** 19 | * @template R 20 | * 21 | * @param R $value 22 | * 23 | * @return Attempt 24 | */ 25 | public function commit(mixed $value): Attempt; 26 | 27 | /** 28 | * @template R 29 | * 30 | * @param R $value 31 | * 32 | * @return Attempt 33 | */ 34 | public function rollback(mixed $value): Attempt; 35 | } 36 | -------------------------------------------------------------------------------- /src/Adapter/Transaction/Failure.php: -------------------------------------------------------------------------------- 1 | e; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Definition/Aggregate/Entity.php: -------------------------------------------------------------------------------- 1 | */ 15 | private string $class; 16 | /** @var non-empty-string */ 17 | private string $name; 18 | /** @var Sequence> */ 19 | private Sequence $properties; 20 | 21 | /** 22 | * @param class-string $class 23 | * @param non-empty-string $name 24 | * @param Sequence> $properties 25 | */ 26 | private function __construct( 27 | string $class, 28 | string $name, 29 | Sequence $properties, 30 | ) { 31 | $this->class = $class; 32 | $this->name = $name; 33 | $this->properties = $properties; 34 | } 35 | 36 | /** 37 | * @internal 38 | * @psalm-pure 39 | * @template A of object 40 | * 41 | * @param class-string $class 42 | * @param non-empty-string $name 43 | * @param Sequence> $properties 44 | * 45 | * @return self 46 | */ 47 | public static function of( 48 | string $class, 49 | string $name, 50 | Sequence $properties, 51 | ): self { 52 | return new self($class, $name, $properties); 53 | } 54 | 55 | /** 56 | * @return class-string 57 | */ 58 | public function class(): string 59 | { 60 | return $this->class; 61 | } 62 | 63 | /** 64 | * @return non-empty-string 65 | */ 66 | public function name(): string 67 | { 68 | return $this->name; 69 | } 70 | 71 | /** 72 | * @return Sequence> 73 | */ 74 | public function properties(): Sequence 75 | { 76 | return $this->properties; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Definition/Aggregate/Identity.php: -------------------------------------------------------------------------------- 1 | */ 24 | private string $class; 25 | 26 | /** 27 | * @psalm-mutation-free 28 | * 29 | * @param non-empty-string $property 30 | * @param class-string $class 31 | */ 32 | private function __construct(string $property, string $class) 33 | { 34 | $this->property = $property; 35 | $this->class = $class; 36 | } 37 | 38 | /** 39 | * @internal 40 | * @psalm-pure 41 | * @template A 42 | * 43 | * @param non-empty-string $property 44 | * @param class-string $class 45 | * 46 | * @return self 47 | */ 48 | public static function of(string $property, string $class): self 49 | { 50 | return new self($property, $class); 51 | } 52 | 53 | /** 54 | * @psalm-mutation-free 55 | * 56 | * @return non-empty-string 57 | */ 58 | public function property(): string 59 | { 60 | return $this->property; 61 | } 62 | 63 | /** 64 | * @param T $aggregate 65 | * 66 | * @return Id 67 | */ 68 | public function extract(object $aggregate): Id 69 | { 70 | /** @var Id */ 71 | return (new Extract)($aggregate, Set::of($this->property)) 72 | ->flatMap(fn($properties) => $properties->get($this->property)) 73 | ->keep(Instance::of(Id::class)) 74 | ->match( 75 | static fn($id) => $id, 76 | fn() => throw new \LogicException("Unable to extract id on {$this->class}"), 77 | ); 78 | } 79 | 80 | /** 81 | * @psalm-mutation-free 82 | * 83 | * @param Id $id 84 | */ 85 | public function normalize(Id $id): Raw\Aggregate\Id 86 | { 87 | return Raw\Aggregate\Id::of($this->property, $id->toString()); 88 | } 89 | 90 | /** 91 | * @psalm-mutation-free 92 | * 93 | * @return Id 94 | */ 95 | public function denormalize(Raw\Aggregate\Id $id): Id 96 | { 97 | return Id::of($this->class, $id->value()); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Definition/Aggregate/Optional.php: -------------------------------------------------------------------------------- 1 | */ 15 | private string $class; 16 | /** @var non-empty-string */ 17 | private string $name; 18 | /** @var Sequence> */ 19 | private Sequence $properties; 20 | 21 | /** 22 | * @param class-string $class 23 | * @param non-empty-string $name 24 | * @param Sequence> $properties 25 | */ 26 | private function __construct( 27 | string $class, 28 | string $name, 29 | Sequence $properties, 30 | ) { 31 | $this->class = $class; 32 | $this->name = $name; 33 | $this->properties = $properties; 34 | } 35 | 36 | /** 37 | * @internal 38 | * @psalm-pure 39 | * @template A of object 40 | * 41 | * @param class-string $class 42 | * @param non-empty-string $name 43 | * @param Sequence> $properties 44 | * 45 | * @return self 46 | */ 47 | public static function of( 48 | string $class, 49 | string $name, 50 | Sequence $properties, 51 | ): self { 52 | return new self($class, $name, $properties); 53 | } 54 | 55 | /** 56 | * @return class-string 57 | */ 58 | public function class(): string 59 | { 60 | return $this->class; 61 | } 62 | 63 | /** 64 | * @return non-empty-string 65 | */ 66 | public function name(): string 67 | { 68 | return $this->name; 69 | } 70 | 71 | /** 72 | * @return Sequence> 73 | */ 74 | public function properties(): Sequence 75 | { 76 | return $this->properties; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Definition/Aggregate/Property.php: -------------------------------------------------------------------------------- 1 | */ 18 | private Type $type; 19 | 20 | /** 21 | * @param class-string $class 22 | * @param non-empty-string $name 23 | * @param Type $type 24 | */ 25 | private function __construct(string $class, string $name, Type $type) 26 | { 27 | $this->name = $name; 28 | $this->type = $type; 29 | } 30 | 31 | /** 32 | * @internal 33 | * @psalm-pure 34 | * 35 | * @template A of object 36 | * @template B 37 | * 38 | * @param class-string $class 39 | * @param non-empty-string $name 40 | * @param Type $type 41 | * 42 | * @return self 43 | */ 44 | public static function of(string $class, string $name, Type $type): self 45 | { 46 | return new self($class, $name, $type); 47 | } 48 | 49 | /** 50 | * @return non-empty-string 51 | */ 52 | public function name(): string 53 | { 54 | return $this->name; 55 | } 56 | 57 | /** 58 | * @return Type 59 | */ 60 | public function type(): Type 61 | { 62 | return $this->type; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Definition/Aggregates.php: -------------------------------------------------------------------------------- 1 | types = $types; 18 | $this->mapName = $mapName; 19 | } 20 | 21 | public static function of(Types $types): self 22 | { 23 | return new self($types, null); 24 | } 25 | 26 | /** 27 | * @psalm-mutation-free 28 | * 29 | * @param callable(class-string): non-empty-string $map 30 | */ 31 | public function mapName(callable $map): self 32 | { 33 | return new self($this->types, $map); 34 | } 35 | 36 | /** 37 | * @template T of object 38 | * 39 | * @param class-string $class 40 | * 41 | * @return Aggregate 42 | */ 43 | public function get(string $class): Aggregate 44 | { 45 | return Aggregate::of( 46 | $this->types, 47 | $this->mapName, 48 | $class, 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Definition/Contains.php: -------------------------------------------------------------------------------- 1 | type = $type; 23 | } 24 | 25 | /** 26 | * @internal 27 | */ 28 | public function type(): ClassName 29 | { 30 | return match ((new \ReflectionClass($this->type))->isEnum()) { 31 | true => ClassName::ofEnum($this->type), 32 | false => ClassName::of($this->type), 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Definition/Contains/Primitive.php: -------------------------------------------------------------------------------- 1 | type) { 28 | 'string' => Type::string(), 29 | 'int' => Type::int(), 30 | 'float' => Type::float(), 31 | 'bool' => Type::bool(), 32 | }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Definition/Type.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | enum BoolType implements Type 21 | { 22 | case instance; 23 | 24 | /** 25 | * @psalm-pure 26 | * 27 | * @return Maybe 28 | */ 29 | public static function of(Types $types, Concrete $type): Maybe 30 | { 31 | return Maybe::just($type) 32 | ->filter(static fn($type) => $type->accepts(Primitive::bool())) 33 | ->map(static fn() => self::instance); 34 | } 35 | 36 | #[\Override] 37 | public function normalize(mixed $value): null|string|int|float|bool 38 | { 39 | return $value; 40 | } 41 | 42 | #[\Override] 43 | public function denormalize(null|string|int|float|bool $value): mixed 44 | { 45 | return match ($value) { 46 | 0 => false, 47 | 1 => true, 48 | true => true, 49 | false => false, 50 | default => throw new \LogicException("'$value' is not a boolean"), 51 | }; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Definition/Type/EnumType.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | final class EnumType implements Type 25 | { 26 | /** @var class-string */ 27 | private string $class; 28 | 29 | /** 30 | * @param class-string $class 31 | */ 32 | private function __construct(string $class) 33 | { 34 | $this->class = $class; 35 | } 36 | 37 | /** 38 | * @psalm-pure 39 | * 40 | * @return Maybe 41 | */ 42 | public static function of(Types $types, Concrete $type): Maybe 43 | { 44 | /** 45 | * @psalm-suppress InvalidTemplateParam 46 | * @psalm-suppress ArgumentTypeCoercion 47 | */ 48 | return Maybe::just($type) 49 | ->keep(Instance::of(ClassName::class)) 50 | ->filter(static fn($type) => $type->enum()) 51 | ->map(static fn($type) => new self($type->toString())); 52 | } 53 | 54 | #[\Override] 55 | public function normalize(mixed $value): null|string|int|float|bool 56 | { 57 | return $value->name; 58 | } 59 | 60 | #[\Override] 61 | public function denormalize(null|string|int|float|bool $value): mixed 62 | { 63 | foreach ($this->class::cases() as $case) { 64 | if ($case->name === $value) { 65 | return $case; 66 | } 67 | } 68 | 69 | throw new \LogicException("'$value' is not a case of the enum '{$this->class}'"); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Definition/Type/IdType.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | enum IdType implements Type 22 | { 23 | case instance; 24 | 25 | /** 26 | * @psalm-pure 27 | * 28 | * @return Maybe 29 | */ 30 | public static function of(Types $types, Concrete $type): Maybe 31 | { 32 | return Maybe::just($type) 33 | ->filter(static fn($type) => $type->accepts(ClassName::of(Id::class))) 34 | ->map(static fn() => self::instance); 35 | } 36 | 37 | #[\Override] 38 | public function normalize(mixed $value): null|string|int|float|bool 39 | { 40 | return $value->toString(); 41 | } 42 | 43 | #[\Override] 44 | public function denormalize(null|string|int|float|bool $value): mixed 45 | { 46 | if (!\is_string($value)) { 47 | throw new \LogicException("'$value' is not a string"); 48 | } 49 | 50 | /** 51 | * Using a fake class here but it doesn't matter as it's only used to 52 | * type the id in userland code, the concrete value is not stored. 53 | * And this avoids the necessity to specify the class as an attribute on 54 | * the property. 55 | * @psalm-suppress ArgumentTypeCoercion 56 | */ 57 | return Id::of('stdClass', $value); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Definition/Type/IntType.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | enum IntType implements Type 21 | { 22 | case instance; 23 | 24 | /** 25 | * @psalm-pure 26 | * 27 | * @return Maybe 28 | */ 29 | public static function of(Types $types, Concrete $type): Maybe 30 | { 31 | return Maybe::just($type) 32 | ->filter(static fn($type) => $type->accepts(Primitive::int())) 33 | ->map(static fn() => self::instance); 34 | } 35 | 36 | #[\Override] 37 | public function normalize(mixed $value): null|string|int|float|bool 38 | { 39 | return $value; 40 | } 41 | 42 | #[\Override] 43 | public function denormalize(null|string|int|float|bool $value): mixed 44 | { 45 | if (!\is_int($value)) { 46 | throw new \LogicException("'$value' is not an integer"); 47 | } 48 | 49 | return $value; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Definition/Type/MaybeType.php: -------------------------------------------------------------------------------- 1 | > 21 | */ 22 | final class MaybeType implements Type 23 | { 24 | /** @var Type */ 25 | private Type $inner; 26 | 27 | /** 28 | * @param Type $inner 29 | */ 30 | private function __construct(Type $inner) 31 | { 32 | $this->inner = $inner; 33 | } 34 | 35 | /** 36 | * @psalm-pure 37 | * 38 | * @return Maybe 39 | */ 40 | public static function of( 41 | Types $types, 42 | Concrete $type, 43 | Contains|Contains\Primitive|null $contains = null, 44 | ): Maybe { 45 | return Maybe::just($type) 46 | ->filter(static fn($type) => $type->accepts(ClassName::of(Maybe::class))) 47 | ->flatMap(static fn() => Maybe::of($contains)) 48 | ->flatMap(static fn($contains) => $types($contains->type())) 49 | ->map(static fn($inner) => new self($inner)); 50 | } 51 | 52 | /** 53 | * @return Type 54 | */ 55 | public function inner(): Type 56 | { 57 | return $this->inner; 58 | } 59 | 60 | #[\Override] 61 | public function normalize(mixed $value): null|string|int|float|bool 62 | { 63 | return $value->match( 64 | $this->inner->normalize(...), 65 | static fn() => null, 66 | ); 67 | } 68 | 69 | #[\Override] 70 | public function denormalize(null|string|int|float|bool $value): mixed 71 | { 72 | return Maybe::of($value)->map($this->inner->denormalize(...)); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Definition/Type/NullableType.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | final class NullableType implements Type 25 | { 26 | /** @var Type */ 27 | private Type $inner; 28 | 29 | /** 30 | * @param Type $inner 31 | */ 32 | private function __construct(Type $inner) 33 | { 34 | $this->inner = $inner; 35 | } 36 | 37 | /** 38 | * @psalm-pure 39 | * 40 | * @return Maybe 41 | */ 42 | public static function of(Types $types, Concrete $type): Maybe 43 | { 44 | return Maybe::just($type) 45 | ->keep(Instance::of(Nullable::class)) 46 | ->flatMap(static fn($type) => $types($type->type())) 47 | ->map(static fn($inner) => new self($inner)); 48 | } 49 | 50 | /** 51 | * @return Type 52 | */ 53 | public function inner(): Type 54 | { 55 | return $this->inner; 56 | } 57 | 58 | #[\Override] 59 | public function normalize(mixed $value): null|string|int|float|bool 60 | { 61 | if (\is_null($value)) { 62 | return null; 63 | } 64 | 65 | return $this->inner->normalize($value); 66 | } 67 | 68 | #[\Override] 69 | public function denormalize(null|string|int|float|bool $value): mixed 70 | { 71 | if (\is_null($value)) { 72 | return null; 73 | } 74 | 75 | return $this->inner->denormalize($value); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Definition/Type/PointInTimeType.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class PointInTimeType implements Type 20 | { 21 | private Clock $clock; 22 | private Formats $format; 23 | 24 | private function __construct(Clock $clock, Formats $format) 25 | { 26 | $this->clock = $clock; 27 | $this->format = $format; 28 | } 29 | 30 | /** 31 | * @psalm-pure 32 | */ 33 | public static function new(Clock $clock): self 34 | { 35 | return new self($clock, Formats::default); 36 | } 37 | 38 | #[\Override] 39 | public function normalize(mixed $value): null|string|int|float|bool 40 | { 41 | return $value->format($this->format); 42 | } 43 | 44 | #[\Override] 45 | public function denormalize(null|string|int|float|bool $value): mixed 46 | { 47 | if (!\is_string($value)) { 48 | throw new \LogicException("'$value' is not a string"); 49 | } 50 | 51 | if ($value === '') { 52 | throw new \LogicException('Date cannot be empty'); 53 | } 54 | 55 | return $this 56 | ->clock 57 | ->at($value, $this->format) 58 | ->match( 59 | static fn($point) => $point, 60 | static fn() => throw new \LogicException("'$value' is not a date"), 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Definition/Type/PointInTimeType/Formats.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | enum StrType implements Type 14 | { 15 | case instance; 16 | 17 | /** 18 | * @psalm-pure 19 | */ 20 | public static function new(): self 21 | { 22 | return self::instance; 23 | } 24 | 25 | #[\Override] 26 | public function normalize(mixed $value): null|string|int|float|bool 27 | { 28 | return $value->toString(); 29 | } 30 | 31 | #[\Override] 32 | public function denormalize(null|string|int|float|bool $value): mixed 33 | { 34 | if (!\is_string($value)) { 35 | throw new \LogicException("'$value' is not a string"); 36 | } 37 | 38 | return Str::of($value); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Definition/Type/StringType.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | enum StringType implements Type 21 | { 22 | case instance; 23 | 24 | /** 25 | * @psalm-pure 26 | */ 27 | public static function new(): self 28 | { 29 | return self::instance; 30 | } 31 | 32 | /** 33 | * @psalm-pure 34 | * 35 | * @return Maybe 36 | */ 37 | public static function of(Types $types, Concrete $type): Maybe 38 | { 39 | return Maybe::just($type) 40 | ->filter(static fn($type) => $type->accepts(Primitive::string())) 41 | ->map(static fn() => self::instance); 42 | } 43 | 44 | #[\Override] 45 | public function normalize(mixed $value): null|string|int|float|bool 46 | { 47 | return $value; 48 | } 49 | 50 | #[\Override] 51 | public function denormalize(null|string|int|float|bool $value): mixed 52 | { 53 | if (!\is_string($value)) { 54 | throw new \LogicException("'$value' is not a string"); 55 | } 56 | 57 | return $value; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Definition/Type/Support.php: -------------------------------------------------------------------------------- 1 | class = $class; 32 | $this->via = $via; 33 | } 34 | 35 | /** 36 | * @return Maybe 37 | */ 38 | public function __invoke( 39 | Types $types, 40 | Concrete $type, 41 | Contains|Contains\Primitive|null $contains = null, 42 | ): Maybe { 43 | return Maybe::just($type) 44 | ->filter(fn($type) => $type->accepts(ClassName::of($this->class))) 45 | ->map(fn() => $this->via); 46 | } 47 | 48 | /** 49 | * @psalm-pure 50 | * 51 | * @param class-string $class 52 | */ 53 | public static function class(string $class, Type $via): self 54 | { 55 | return new self($class, $via); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Definition/Types.php: -------------------------------------------------------------------------------- 1 | > */ 18 | private array $builders; 19 | 20 | /** 21 | * @no-named-arguments 22 | * 23 | * @param callable(self, Concrete, Contains|Contains\Primitive|null): Maybe $builders 24 | */ 25 | private function __construct(callable ...$builders) 26 | { 27 | $this->builders = $builders; 28 | } 29 | 30 | /** 31 | * @return Maybe 32 | */ 33 | public function __invoke( 34 | Concrete $type, 35 | Contains|Contains\Primitive|null $contains = null, 36 | ): Maybe { 37 | /** @var Maybe */ 38 | $found = Maybe::nothing(); 39 | 40 | foreach ($this->builders as $build) { 41 | $found = $found->otherwise(fn() => $build($this, $type, $contains)); 42 | } 43 | 44 | return $found; 45 | } 46 | 47 | /** 48 | * @no-named-arguments 49 | * @psalm-pure 50 | * 51 | * @param callable(self, Concrete, Contains|Contains\Primitive|null): Maybe $builders 52 | */ 53 | public static function of(callable ...$builders): self 54 | { 55 | return new self( 56 | Type\NullableType::of(...), 57 | Type\MaybeType::of(...), 58 | Type\StringType::of(...), 59 | Type\Support::class( 60 | Str::class, 61 | Type\StrType::new(), 62 | ), 63 | Type\IntType::of(...), 64 | Type\BoolType::of(...), 65 | Type\IdType::of(...), 66 | Type\EnumType::of(...), 67 | ...$builders, 68 | ); 69 | } 70 | 71 | /** 72 | * @psalm-pure 73 | */ 74 | public static function default(): self 75 | { 76 | return new self( 77 | Type\NullableType::of(...), 78 | Type\MaybeType::of(...), 79 | Type\StringType::of(...), 80 | Type\Support::class( 81 | Str::class, 82 | Type\StrType::new(), 83 | ), 84 | Type\IntType::of(...), 85 | Type\BoolType::of(...), 86 | Type\IdType::of(...), 87 | Type\EnumType::of(...), 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Effect/Collection/Add.php: -------------------------------------------------------------------------------- 1 | $entities 17 | */ 18 | private function __construct( 19 | private string $property, 20 | private Sequence $entities, 21 | ) { 22 | } 23 | 24 | /** 25 | * @internal 26 | * @psalm-pure 27 | * 28 | * @param non-empty-string $property 29 | */ 30 | public static function of(string $property, object $entity): self 31 | { 32 | return new self($property, Sequence::of($entity)); 33 | } 34 | 35 | /** 36 | * @return non-empty-string 37 | */ 38 | public function property(): string 39 | { 40 | return $this->property; 41 | } 42 | 43 | /** 44 | * @return Sequence 45 | */ 46 | public function entities(): Sequence 47 | { 48 | // It's not currently possible to specify multiple entities to add at 49 | // once because of the SQL adapter. To insert multiple entities it 50 | // requires to run multiple "INSERT" queries. And if a specification is 51 | // passed to condition to which aggregate add the children then it uses 52 | // the "INSERT INTO SELECT" strategy. The problem is that the condition 53 | // on the "SELECT" may depend on the collections being modified. This 54 | // means that between 2 "INSERT"s it may not affect the same aggregates. 55 | // This is an implicit behaviour that may lead to bugs. 56 | // A possible solution would be to use a CTE to make sure the list of 57 | // aggregates for all "INSERT"s. But this needs quite some work to 58 | // achieve. 59 | // For now this method returns a Sequence to allow to add this feature 60 | // in the future without introducing a BC break. 61 | return $this->entities; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Effect/Collection/Remove.php: -------------------------------------------------------------------------------- 1 | property; 40 | } 41 | 42 | public function specification(): Comparator 43 | { 44 | return $this->specification; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Effect/Entity.php: -------------------------------------------------------------------------------- 1 | property; 42 | } 43 | 44 | /** 45 | * @return Sequence 46 | */ 47 | public function effects(): Sequence 48 | { 49 | return $this->effects->effects(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Effect/Normalized/Collection/Add.php: -------------------------------------------------------------------------------- 1 | $entities 18 | */ 19 | private function __construct( 20 | private string $property, 21 | private Sequence $entities, 22 | ) { 23 | } 24 | 25 | /** 26 | * @internal 27 | * @psalm-pure 28 | * 29 | * @param non-empty-string $property 30 | * @param Sequence $entities 31 | */ 32 | public static function of(string $property, Sequence $entities): self 33 | { 34 | return new self($property, $entities); 35 | } 36 | 37 | /** 38 | * @return non-empty-string 39 | */ 40 | public function property(): string 41 | { 42 | return $this->property; 43 | } 44 | 45 | /** 46 | * @return Sequence 47 | */ 48 | public function entities(): Sequence 49 | { 50 | return $this->entities; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Effect/Normalized/Collection/Remove.php: -------------------------------------------------------------------------------- 1 | property; 40 | } 41 | 42 | public function specification(): Property 43 | { 44 | return $this->specification; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Effect/Normalized/Entity.php: -------------------------------------------------------------------------------- 1 | property; 42 | } 43 | 44 | /** 45 | * @return Sequence 46 | */ 47 | public function effects(): Sequence 48 | { 49 | return $this->effects->effects(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Effect/Normalized/Optional.php: -------------------------------------------------------------------------------- 1 | property; 42 | } 43 | 44 | /** 45 | * @return Sequence 46 | */ 47 | public function effects(): Sequence 48 | { 49 | return $this->effects->effects(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Effect/Normalized/Optional/Nothing.php: -------------------------------------------------------------------------------- 1 | property; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Effect/Normalized/Properties.php: -------------------------------------------------------------------------------- 1 | $effects 16 | */ 17 | private function __construct( 18 | private Sequence $effects, 19 | ) { 20 | } 21 | 22 | /** 23 | * @internal 24 | * @psalm-pure 25 | * 26 | * @param Sequence $effects 27 | */ 28 | public static function of(Sequence $effects): self 29 | { 30 | return new self($effects); 31 | } 32 | 33 | /** 34 | * @return Sequence 35 | */ 36 | public function effects(): Sequence 37 | { 38 | return $this->effects; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Effect/Normalized/Property.php: -------------------------------------------------------------------------------- 1 | property; 40 | } 41 | 42 | public function value(): null|string|int|float|bool 43 | { 44 | return $this->value; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Effect/Optional.php: -------------------------------------------------------------------------------- 1 | property; 42 | } 43 | 44 | /** 45 | * @return Sequence 46 | */ 47 | public function effects(): Sequence 48 | { 49 | return $this->effects->effects(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Effect/Optional/Nothing.php: -------------------------------------------------------------------------------- 1 | property; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Effect/Properties.php: -------------------------------------------------------------------------------- 1 | $effects 16 | */ 17 | private function __construct( 18 | private Sequence $effects, 19 | ) { 20 | } 21 | 22 | /** 23 | * @internal 24 | * @psalm-pure 25 | */ 26 | public static function of(Property $effect): self 27 | { 28 | return new self(Sequence::of($effect)); 29 | } 30 | 31 | public function and(Property $effect): self 32 | { 33 | return new self(($this->effects)($effect)); 34 | } 35 | 36 | /** 37 | * @return Sequence 38 | */ 39 | public function effects(): Sequence 40 | { 41 | return $this->effects; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Effect/Property.php: -------------------------------------------------------------------------------- 1 | property; 40 | } 41 | 42 | public function value(): mixed 43 | { 44 | return $this->value; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Effect/Provider.php: -------------------------------------------------------------------------------- 1 | build)(Add::of( 43 | $this->property, 44 | $entity, 45 | )); 46 | } 47 | 48 | public function remove(Comparator $specification): Effect 49 | { 50 | return ($this->build)(Remove::of( 51 | $this->property, 52 | $specification, 53 | )); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Effect/Provider/Entity.php: -------------------------------------------------------------------------------- 1 | build)(Effect\Entity::of( 38 | $this->property, 39 | $effects->collection(), 40 | )); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Effect/Provider/Optional.php: -------------------------------------------------------------------------------- 1 | build)(Nothing::of($this->property)); 42 | } 43 | 44 | public function properties(Properties $effects): Effect 45 | { 46 | return ($this->build)(Opt::of( 47 | $this->property, 48 | $effects->collection(), 49 | )); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Effect/Provider/Properties.php: -------------------------------------------------------------------------------- 1 | $properties 23 | */ 24 | private function __construct( 25 | private \Closure $build, 26 | private string $property, 27 | private mixed $value, 28 | private Map $properties, 29 | ) { 30 | } 31 | 32 | /** 33 | * @internal 34 | * @psalm-pure 35 | * 36 | * @param pure-Closure(Collection): Effect $build 37 | * @param non-empty-string $property 38 | */ 39 | public static function of( 40 | \Closure $build, 41 | string $property, 42 | mixed $value, 43 | ): self { 44 | return new self( 45 | $build, 46 | $property, 47 | $value, 48 | Map::of(), 49 | ); 50 | } 51 | 52 | public function and(self $other): self 53 | { 54 | return new self( 55 | $this->build, 56 | $this->property, 57 | $this->value, 58 | $this 59 | ->properties 60 | ->put($other->property, $other->value) 61 | ->merge($other->properties), 62 | ); 63 | } 64 | 65 | #[\Override] 66 | public function toEffect(): Effect 67 | { 68 | return ($this->build)($this->collection()); 69 | } 70 | 71 | /** 72 | * @internal 73 | */ 74 | public function collection(): Collection 75 | { 76 | return $this->properties->reduce( 77 | Collection::of(Property::assign($this->property, $this->value)), 78 | static fn(Collection $properties, $property, $value) => $properties->and( 79 | Property::assign($property, $value), 80 | ), 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Effect/Provider/Property.php: -------------------------------------------------------------------------------- 1 | build, $this->property, $value); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Id.php: -------------------------------------------------------------------------------- 1 | $class 19 | * @param non-empty-string $value 20 | */ 21 | private function __construct(string $class, string $value) 22 | { 23 | $this->value = $value; 24 | } 25 | 26 | public function __clone() 27 | { 28 | // This is not allowed to make sure users always use the same instance 29 | // of an id as it is this instance that is used to keep data in memory 30 | // as long as the user needs it. 31 | // If a user where to duplicate an instance for a same underlying value 32 | // it may result in trying to insert the same aggregate twice to the 33 | // storage. 34 | throw new \LogicException('Cloning is not allowed'); 35 | } 36 | 37 | /** 38 | * @template A of object 39 | * 40 | * @param class-string $class 41 | * 42 | * @return self 43 | */ 44 | public static function new(string $class): self 45 | { 46 | return new self($class, Uuid::uuid4()->toString()); 47 | } 48 | 49 | /** 50 | * @template A of object 51 | * @psalm-pure 52 | * 53 | * @param class-string $class 54 | * @param non-empty-string $value 55 | * 56 | * @return self 57 | */ 58 | public static function of(string $class, string $value): self 59 | { 60 | /** @psalm-suppress ImpureMethodCall */ 61 | if (!Uuid::isValid($value)) { 62 | throw new \LogicException("Invalid id value '$value'"); 63 | } 64 | 65 | return new self($class, $value); 66 | } 67 | 68 | /** 69 | * @template A of object 70 | * @psalm-pure 71 | * 72 | * @param class-string $class 73 | * 74 | * @return pure-callable(non-empty-string): self 75 | */ 76 | public static function for(string $class): callable 77 | { 78 | return static fn(string $value) => self::of($class, $value); 79 | } 80 | 81 | /** 82 | * @param self $other 83 | */ 84 | public function equals(self $other): bool 85 | { 86 | return $this->value === $other->value; 87 | } 88 | 89 | /** 90 | * @return non-empty-string 91 | */ 92 | public function toString(): string 93 | { 94 | return $this->value; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Raw/Aggregate/Collection.php: -------------------------------------------------------------------------------- 1 | */ 17 | private Set $entities; 18 | 19 | /** 20 | * @param non-empty-string $name 21 | * @param Set $entities 22 | */ 23 | private function __construct( 24 | string $name, 25 | Set $entities, 26 | ) { 27 | $this->name = $name; 28 | $this->entities = $entities; 29 | } 30 | 31 | /** 32 | * @psalm-pure 33 | * 34 | * @param non-empty-string $name 35 | * @param Set $entities 36 | */ 37 | public static function of(string $name, Set $entities): self 38 | { 39 | return new self($name, $entities); 40 | } 41 | 42 | /** 43 | * @return non-empty-string 44 | */ 45 | public function name(): string 46 | { 47 | return $this->name; 48 | } 49 | 50 | /** 51 | * @return Set 52 | */ 53 | public function entities(): Set 54 | { 55 | return $this->entities; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Raw/Aggregate/Collection/Entity.php: -------------------------------------------------------------------------------- 1 | */ 15 | private Sequence $properties; 16 | 17 | /** 18 | * @param Sequence $properties 19 | */ 20 | private function __construct(Sequence $properties) 21 | { 22 | $this->properties = $properties; 23 | } 24 | 25 | /** 26 | * @psalm-pure 27 | * 28 | * @param Sequence $properties 29 | */ 30 | public static function of(Sequence $properties): self 31 | { 32 | return new self($properties); 33 | } 34 | 35 | /** 36 | * @return Sequence 37 | */ 38 | public function properties(): Sequence 39 | { 40 | return $this->properties; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Raw/Aggregate/Entity.php: -------------------------------------------------------------------------------- 1 | */ 16 | private Sequence $properties; 17 | 18 | /** 19 | * @param non-empty-string $name 20 | * @param Sequence $properties 21 | */ 22 | private function __construct(string $name, Sequence $properties) 23 | { 24 | $this->name = $name; 25 | $this->properties = $properties; 26 | } 27 | 28 | /** 29 | * @psalm-pure 30 | * 31 | * @param non-empty-string $name 32 | * @param Sequence $properties 33 | */ 34 | public static function of(string $name, Sequence $properties): self 35 | { 36 | return new self($name, $properties); 37 | } 38 | 39 | /** 40 | * @return non-empty-string 41 | */ 42 | public function name(): string 43 | { 44 | return $this->name; 45 | } 46 | 47 | /** 48 | * @return Sequence 49 | */ 50 | public function properties(): Sequence 51 | { 52 | return $this->properties; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Raw/Aggregate/Id.php: -------------------------------------------------------------------------------- 1 | name = $name; 23 | $this->value = $value; 24 | } 25 | 26 | /** 27 | * @psalm-pure 28 | * 29 | * @param non-empty-string $name 30 | * @param non-empty-string $value 31 | */ 32 | public static function of(string $name, string $value): self 33 | { 34 | return new self($name, $value); 35 | } 36 | 37 | /** 38 | * @return non-empty-string 39 | */ 40 | public function name(): string 41 | { 42 | return $this->name; 43 | } 44 | 45 | /** 46 | * @return non-empty-string 47 | */ 48 | public function value(): string 49 | { 50 | return $this->value; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Raw/Aggregate/Optional.php: -------------------------------------------------------------------------------- 1 | > */ 19 | private Maybe $properties; 20 | 21 | /** 22 | * @param non-empty-string $name 23 | * @param Maybe> $properties 24 | */ 25 | private function __construct(string $name, Maybe $properties) 26 | { 27 | $this->name = $name; 28 | $this->properties = $properties; 29 | } 30 | 31 | /** 32 | * @psalm-pure 33 | * 34 | * @param non-empty-string $name 35 | * @param Maybe> $properties 36 | */ 37 | public static function of(string $name, Maybe $properties): self 38 | { 39 | return new self($name, $properties); 40 | } 41 | 42 | /** 43 | * @return non-empty-string 44 | */ 45 | public function name(): string 46 | { 47 | return $this->name; 48 | } 49 | 50 | /** 51 | * @return Maybe> 52 | */ 53 | public function properties(): Maybe 54 | { 55 | return $this->properties; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Raw/Aggregate/Optional/BrandNew.php: -------------------------------------------------------------------------------- 1 | optional = $optional; 28 | } 29 | 30 | /** 31 | * @internal 32 | * @psalm-pure 33 | */ 34 | public static function of(Optional $optional): self 35 | { 36 | return new self($optional); 37 | } 38 | 39 | /** 40 | * @return non-empty-string 41 | */ 42 | public function name(): string 43 | { 44 | return $this->optional->name(); 45 | } 46 | 47 | /** 48 | * @return Maybe> 49 | */ 50 | public function properties(): Maybe 51 | { 52 | return $this->optional->properties(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Raw/Aggregate/Property.php: -------------------------------------------------------------------------------- 1 | name = $name; 21 | $this->value = $value; 22 | } 23 | 24 | /** 25 | * @psalm-pure 26 | * 27 | * @param non-empty-string $name 28 | */ 29 | public static function of(string $name, null|string|int|float|bool $value): self 30 | { 31 | return new self($name, $value); 32 | } 33 | 34 | /** 35 | * @return non-empty-string 36 | */ 37 | public function name(): string 38 | { 39 | return $this->name; 40 | } 41 | 42 | public function value(): null|string|int|float|bool 43 | { 44 | return $this->value; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Repository/Active.php: -------------------------------------------------------------------------------- 1 | */ 18 | private \WeakMap $repositories; 19 | /** @var \WeakMap */ 20 | private \WeakMap $active; 21 | 22 | private function __construct() 23 | { 24 | /** @var \WeakMap */ 25 | $this->repositories = new \WeakMap; 26 | /** @var \WeakMap */ 27 | $this->active = new \WeakMap; 28 | } 29 | 30 | /** 31 | * @internal 32 | */ 33 | public static function new(): self 34 | { 35 | return new self; 36 | } 37 | 38 | /** 39 | * @param class-string $class 40 | */ 41 | public function register(string $class, Repository $repository): void 42 | { 43 | $this->repositories[$repository] = $class; 44 | } 45 | 46 | public function active(Repository $repository, Id $id): void 47 | { 48 | $this->active[$id] = $repository; 49 | } 50 | 51 | public function forget(Id $id): void 52 | { 53 | $this->active->offsetUnset($id); 54 | } 55 | 56 | /** 57 | * @template T of object 58 | * 59 | * @param class-string $class 60 | * 61 | * @return Maybe> 62 | */ 63 | public function get(string $class): Maybe 64 | { 65 | /** 66 | * @var Repository $repository 67 | * @var class-string $kind 68 | */ 69 | foreach ($this->repositories as $repository => $kind) { 70 | if ($kind === $class) { 71 | /** @var Maybe> */ 72 | return Maybe::just($repository); 73 | } 74 | } 75 | 76 | /** @var Maybe> */ 77 | return Maybe::nothing(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Repository/Context.php: -------------------------------------------------------------------------------- 1 | */ 21 | private Definition $definition; 22 | private Instanciate $instanciate; 23 | /** @var Map> */ 24 | private Map $properties; 25 | 26 | /** 27 | * @param Definition $definition 28 | */ 29 | private function __construct(Definition $definition, Instanciate $instanciate) 30 | { 31 | $this->definition = $definition; 32 | $this->instanciate = $instanciate; 33 | $this->properties = Map::of( 34 | ...$definition 35 | ->properties() 36 | ->map(static fn($property) => [$property->name(), $property]) 37 | ->toList(), 38 | ); 39 | } 40 | 41 | /** 42 | * @return T 43 | */ 44 | public function __invoke(Raw $entity): object 45 | { 46 | $properties = Map::of( 47 | ...$entity 48 | ->properties() 49 | ->flatMap( 50 | fn($property) => $this 51 | ->properties 52 | ->get($property->name()) 53 | ->map(static fn($definition): mixed => $definition->type()->denormalize($property->value())) 54 | ->map(static fn($value) => [$property->name(), $value]) 55 | ->toSequence(), 56 | ) 57 | ->toList(), 58 | ); 59 | $class = $this->definition->class(); 60 | 61 | /** @var T */ 62 | return ($this->instanciate)($class, $properties)->match( 63 | static fn($entity) => $entity, 64 | static fn() => throw new \RuntimeException("Unable to denormalize entity of type '$class'"), 65 | ); 66 | } 67 | 68 | /** 69 | * @internal 70 | * @template A of object 71 | * 72 | * @param Definition $definition 73 | * 74 | * @return self 75 | */ 76 | public static function of(Definition $definition, Instanciate $instanciate): self 77 | { 78 | return new self($definition, $instanciate); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Repository/Denormalized.php: -------------------------------------------------------------------------------- 1 | */ 17 | private Id $id; 18 | /** @var Map */ 19 | private Map $properties; 20 | 21 | /** 22 | * @param Id $id 23 | * @param Map $properties 24 | */ 25 | private function __construct(Id $id, Map $properties) 26 | { 27 | $this->id = $id; 28 | $this->properties = $properties; 29 | } 30 | 31 | /** 32 | * @internal 33 | * @psalm-pure 34 | * @template A of object 35 | * 36 | * @param Id $id 37 | * @param Map $properties 38 | * 39 | * @return self 40 | */ 41 | public static function of(Id $id, Map $properties): self 42 | { 43 | return new self($id, $properties); 44 | } 45 | 46 | /** 47 | * @return Id 48 | */ 49 | public function id(): Id 50 | { 51 | return $this->id; 52 | } 53 | 54 | /** 55 | * @return Map 56 | */ 57 | public function properties(): Map 58 | { 59 | return $this->properties; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Repository/Diff/Property.php: -------------------------------------------------------------------------------- 1 | name = $name; 22 | $this->then = $then; 23 | $this->now = $now; 24 | } 25 | 26 | /** 27 | * @internal 28 | * 29 | * @param non-empty-string $name 30 | */ 31 | public static function of(string $name, mixed $then, mixed $now): self 32 | { 33 | return new self($name, $then, $now); 34 | } 35 | 36 | /** 37 | * @return non-empty-string $name 38 | */ 39 | public function name(): string 40 | { 41 | return $this->name; 42 | } 43 | 44 | public function then(): mixed 45 | { 46 | return $this->then; 47 | } 48 | 49 | public function now(): mixed 50 | { 51 | return $this->now; 52 | } 53 | 54 | public function changed(): bool 55 | { 56 | return $this->then !== $this->now; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Repository/Instanciate.php: -------------------------------------------------------------------------------- 1 | */ 17 | private Definition $definition; 18 | /** @var class-string */ 19 | private string $class; 20 | 21 | /** 22 | * @param Definition $definition 23 | */ 24 | private function __construct(Definition $definition) 25 | { 26 | $this->new = new Reflection\Instanciate; 27 | $this->definition = $definition; 28 | $this->class = $definition->class(); 29 | } 30 | 31 | /** 32 | * @param Denormalized $denormalized 33 | * 34 | * @return T 35 | */ 36 | public function __invoke(Denormalized $denormalized): object 37 | { 38 | $properties = $denormalized 39 | ->properties() 40 | ->put( 41 | $this->definition->id()->property(), 42 | $denormalized->id(), 43 | ); 44 | 45 | /** @var T */ 46 | return ($this->new)($this->class, $properties)->match( 47 | static fn($aggregate) => $aggregate, 48 | fn() => throw new \RuntimeException("Unable to denormalize aggregate of type '{$this->class}'"), 49 | ); 50 | } 51 | 52 | /** 53 | * @internal 54 | * @template A of object 55 | * 56 | * @param Definition $definition 57 | * 58 | * @return self 59 | */ 60 | public static function of(Definition $definition): self 61 | { 62 | return new self($definition); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Repository/Loaded.php: -------------------------------------------------------------------------------- 1 | */ 24 | private Aggregate $definition; 25 | /** @var \WeakMap, Map> */ 26 | private \WeakMap $loaded; 27 | 28 | /** 29 | * @param Aggregate $definition 30 | */ 31 | private function __construct( 32 | Active $repositories, 33 | Aggregate $definition, 34 | ) { 35 | $this->repositories = $repositories; 36 | $this->definition = $definition; 37 | /** @var \WeakMap, Map> */ 38 | $this->loaded = new \WeakMap; 39 | } 40 | 41 | /** 42 | * @internal 43 | * @template A of object 44 | * 45 | * @param Aggregate $definition 46 | * 47 | * @return self 48 | */ 49 | public static function of( 50 | Active $repositories, 51 | Aggregate $definition, 52 | ): self { 53 | return new self($repositories, $definition); 54 | } 55 | 56 | /** 57 | * @param Repository $repository 58 | * @param Denormalized $denormalized 59 | * 60 | * @return Denormalized 61 | */ 62 | public function add( 63 | Repository $repository, 64 | Denormalized $denormalized, 65 | ): Denormalized { 66 | $this->loaded[$denormalized->id()] = $denormalized->properties(); 67 | $this->repositories->active($repository, $denormalized->id()); 68 | 69 | return $denormalized; 70 | } 71 | 72 | /** 73 | * @param Id $id 74 | * 75 | * @return Maybe> 76 | */ 77 | public function get(Id $id): Maybe 78 | { 79 | return Maybe::of($this->loaded[$id] ?? null)->map( 80 | static fn($properties) => Denormalized::of($id, $properties), 81 | ); 82 | } 83 | 84 | /** 85 | * @param Id $id 86 | */ 87 | public function remove(Id $id): void 88 | { 89 | $this->loaded->offsetUnset($id); 90 | $this->repositories->forget($id); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Repository/Normalize/Entity.php: -------------------------------------------------------------------------------- 1 | */ 21 | private Definition $definition; 22 | private Extract $extract; 23 | /** @var Set */ 24 | private Set $properties; 25 | 26 | /** 27 | * @param Definition $definition 28 | */ 29 | private function __construct(Definition $definition, Extract $extract) 30 | { 31 | $this->definition = $definition; 32 | $this->extract = $extract; 33 | $this->properties = $definition 34 | ->properties() 35 | ->map(static fn($property) => $property->name()) 36 | ->toSet(); 37 | } 38 | 39 | /** 40 | * @param T $entity 41 | */ 42 | public function __invoke(object $entity): Raw 43 | { 44 | $class = $this->definition->class(); 45 | $properties = ($this->extract)($entity, $this->properties)->match( 46 | static fn($properties) => $properties, 47 | static fn() => throw new \LogicException("Failed to extract properties from '$class'"), 48 | ); 49 | 50 | return Raw::of( 51 | $this->definition->name(), 52 | $this 53 | ->definition 54 | ->properties() 55 | ->flatMap( 56 | static fn($property) => $properties 57 | ->get($property->name()) 58 | ->map(static fn($value) => Property::of( 59 | $property->name(), 60 | $property->type()->normalize($value), 61 | )) 62 | ->toSequence(), 63 | ), 64 | ); 65 | } 66 | 67 | /** 68 | * @internal 69 | * @template A of object 70 | * 71 | * @param Definition $definition 72 | * 73 | * @return self 74 | */ 75 | public static function of(Definition $definition, Extract $extract): self 76 | { 77 | return new self($definition, $extract); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Sort.php: -------------------------------------------------------------------------------- 1 | name = $name; 25 | $this->property = $property; 26 | } 27 | 28 | /** 29 | * @internal 30 | * @psalm-pure 31 | * 32 | * @param non-empty-string $name 33 | */ 34 | public static function of(string $name, Property $property): self 35 | { 36 | return new self($name, $property); 37 | } 38 | 39 | /** 40 | * @return non-empty-string 41 | */ 42 | public function name(): string 43 | { 44 | return $this->name; 45 | } 46 | 47 | public function property(): Property 48 | { 49 | return $this->property; 50 | } 51 | 52 | public function direction(): Sort 53 | { 54 | return $this->property->direction(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Sort/Property.php: -------------------------------------------------------------------------------- 1 | name = $name; 23 | $this->direction = $direction; 24 | } 25 | 26 | /** 27 | * @internal 28 | * @psalm-pure 29 | * 30 | * @param non-empty-string $name 31 | */ 32 | public static function of(string $name, Direction $direction): self 33 | { 34 | return new self($name, $direction); 35 | } 36 | 37 | /** 38 | * @return non-empty-string 39 | */ 40 | public function name(): string 41 | { 42 | return $this->name; 43 | } 44 | 45 | public function direction(): Direction 46 | { 47 | return $this->direction; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Specification/Child.php: -------------------------------------------------------------------------------- 1 | collection = $collection; 28 | $this->specification = $specification; 29 | } 30 | 31 | /** 32 | * Use this specification to find an aggregate where at least one entity of 33 | * the specified collection matches the given specification. 34 | * 35 | * @psalm-pure 36 | * 37 | * @param non-empty-string $collection 38 | */ 39 | public static function of(string $collection, Specification $specification): self 40 | { 41 | return new self($collection, $specification); 42 | } 43 | 44 | /** 45 | * @return non-empty-string 46 | */ 47 | public function collection(): string 48 | { 49 | return $this->collection; 50 | } 51 | 52 | public function specification(): Specification 53 | { 54 | return $this->specification; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Specification/Child/Enum.php: -------------------------------------------------------------------------------- 1 | name, 27 | )); 28 | } 29 | 30 | /** 31 | * @psalm-pure 32 | * @no-named-arguments 33 | * 34 | * @param non-empty-string $collection 35 | */ 36 | public static function in( 37 | string $collection, 38 | \UnitEnum $first, 39 | \UnitEnum ...$rest, 40 | ): Child { 41 | return Child::of($collection, Property::of( 42 | 'name', 43 | Sign::in, 44 | Set::of($first, ...$rest)->map(static fn($case) => $case->name), 45 | )); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Specification/CrossMatch.php: -------------------------------------------------------------------------------- 1 | property = $property; 33 | $this->value = $value; 34 | } 35 | 36 | /** 37 | * @internal 38 | * @psalm-pure 39 | * 40 | * @param non-empty-string $property 41 | */ 42 | public static function of( 43 | string $property, 44 | SubMatch $value, 45 | ): self { 46 | return new self($property, $value); 47 | } 48 | 49 | #[\Override] 50 | public function property(): string 51 | { 52 | return $this->property; 53 | } 54 | 55 | #[\Override] 56 | public function sign(): Sign 57 | { 58 | return Sign::in; 59 | } 60 | 61 | #[\Override] 62 | public function value(): mixed 63 | { 64 | return $this->value->unwrap(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Specification/Entity.php: -------------------------------------------------------------------------------- 1 | entity = $entity; 28 | $this->specification = $specification; 29 | } 30 | 31 | /** 32 | * Use this specification to find an aggregate where the specified entity 33 | * matches the given specification. 34 | * 35 | * @psalm-pure 36 | * 37 | * @param non-empty-string $entity 38 | */ 39 | public static function of(string $entity, Specification $specification): self 40 | { 41 | return new self($entity, $specification); 42 | } 43 | 44 | /** 45 | * @return non-empty-string 46 | */ 47 | public function entity(): string 48 | { 49 | return $this->entity; 50 | } 51 | 52 | public function specification(): Specification 53 | { 54 | return $this->specification; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Specification/Has.php: -------------------------------------------------------------------------------- 1 | optional = $optional; 27 | } 28 | 29 | /** 30 | * Use this specification to find an aggregate where the entity of the 31 | * specified optional has a value. If no entity exists for the optional then 32 | * the aggregate won't be matched. 33 | * 34 | * @psalm-pure 35 | * 36 | * @param non-empty-string $optional 37 | */ 38 | public static function a(string $optional): self 39 | { 40 | return new self($optional); 41 | } 42 | 43 | /** 44 | * Use this specification to find an aggregate where the entity of the 45 | * specified optional has a value. If no entity exists for the optional then 46 | * the aggregate won't be matched. 47 | * 48 | * @psalm-pure 49 | * 50 | * @param non-empty-string $optional 51 | */ 52 | public static function an(string $optional): self 53 | { 54 | return new self($optional); 55 | } 56 | 57 | /** 58 | * @return non-empty-string 59 | */ 60 | public function optional(): string 61 | { 62 | return $this->optional; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Specification/Just.php: -------------------------------------------------------------------------------- 1 | optional = $optional; 28 | $this->specification = $specification; 29 | } 30 | 31 | /** 32 | * Use this specification to find an aggregate where the entity of the 33 | * specified optional matches the given specification. If no entity exists 34 | * for the optional then the aggregate won't be matched. 35 | * 36 | * @psalm-pure 37 | * 38 | * @param non-empty-string $optional 39 | */ 40 | public static function of(string $optional, Specification $specification): self 41 | { 42 | return new self($optional, $specification); 43 | } 44 | 45 | /** 46 | * @return non-empty-string 47 | */ 48 | public function optional(): string 49 | { 50 | return $this->optional; 51 | } 52 | 53 | public function specification(): Specification 54 | { 55 | return $this->specification; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Specification/Property.php: -------------------------------------------------------------------------------- 1 | */ 26 | private null|string|int|float|bool|array $value; 27 | 28 | /** 29 | * @param non-empty-string $property 30 | * @param null|string|int|float|bool|list $value 31 | */ 32 | private function __construct( 33 | string $property, 34 | Sign $sign, 35 | null|string|int|float|bool|array $value, 36 | ) { 37 | $this->property = $property; 38 | $this->sign = $sign; 39 | $this->value = $value; 40 | } 41 | 42 | /** 43 | * @internal 44 | * @psalm-pure 45 | * 46 | * @param non-empty-string $property 47 | * @param null|string|int|float|bool|list $value 48 | */ 49 | public static function of( 50 | string $property, 51 | Sign $sign, 52 | null|string|int|float|bool|array $value, 53 | ): self { 54 | return new self($property, $sign, $value); 55 | } 56 | 57 | #[\Override] 58 | public function property(): string 59 | { 60 | return $this->property; 61 | } 62 | 63 | #[\Override] 64 | public function sign(): Sign 65 | { 66 | return $this->sign; 67 | } 68 | 69 | /** 70 | * @return null|string|int|float|bool|list 71 | */ 72 | #[\Override] 73 | public function value(): null|string|int|float|bool|array 74 | { 75 | return $this->value; 76 | } 77 | } 78 | --------------------------------------------------------------------------------