├── .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 |
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 |