From 5eff6728c634f47b4bd0e6d264b57b5bd3574de2 Mon Sep 17 00:00:00 2001 From: Adro Morelos Date: Sat, 22 Aug 2026 18:34:41 +0200 Subject: [PATCH] Split the readme into docs/ and keep it out of the dist --- .gitattributes | 8 +- README.md | 480 +----------------------------- docs/errors.md | 34 +++ docs/extending.md | 83 ++++++ docs/getting-started.md | 127 ++++++++ docs/queries.md | 96 ++++++ UPGRADING.md => docs/upgrading.md | 0 docs/usage.md | 126 ++++++++ 8 files changed, 480 insertions(+), 474 deletions(-) create mode 100644 docs/errors.md create mode 100644 docs/extending.md create mode 100644 docs/getting-started.md create mode 100644 docs/queries.md rename UPGRADING.md => docs/upgrading.md (100%) create mode 100644 docs/usage.md diff --git a/.gitattributes b/.gitattributes index cbe3c5d..3efc8e7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,12 +1,14 @@ # Enforce Unix newlines * text=lf -# Keep development files out of the installed package -/.github export-ignore +# Keep development files out of the installed package. README.md stays, because +# Packagist and GitHub read it; everything else here is for people working on +# the package, not people depending on it. +/docs export-ignore /tests export-ignore +/.github export-ignore /.gitattributes export-ignore /.gitignore export-ignore /.php-cs-fixer.dist.php export-ignore /phpstan.neon.dist export-ignore /phpunit.xml.dist export-ignore -/UPGRADING.md export-ignore diff --git a/README.md b/README.md index b8bbf38..64a592d 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ $admins = $users->query() ``` > **Version 1.0 is a rewrite** and breaks with 0.x deliberately. If you are -> coming from an older version, read [UPGRADING.md](UPGRADING.md), and -> [the short history](#a-short-history) for why. +> coming from an older version, read [the upgrade guide](docs/upgrading.md), +> and [the short history](#a-short-history) for why. ## Installation @@ -90,476 +90,14 @@ which already handles enums, dates, nested objects and union types better than anything worth hand-rolling. Writing is not: this library leaves private properties and nulls out of the payload, and those are not valinor's defaults. -## Modelling for Firebase, not against it +## Documentation -Repositories and entities come from the relational world, where you model the -domain and then persist it. Firebase is the other way round: you model for the -reads you intend to do, and you denormalise on purpose. - -So resist the pull toward one repository per noun with references between them. -Fetching an order, then its customer, then each of its line items is three round -trips for something Firebase would rather you stored as one document. **Entities -nest** - an `Order` holding `LineItem` objects and a `Money` value object is a -single record, and that is usually the right shape: - -```php -$order = $orders->get('order-1'); // one read, whole aggregate -$order->lines[0]->price->amount; -``` - -Two consequences worth planning for: - -- **A query cannot span collections.** If you need data together, store it - together. Duplicating a customer's name onto the order is normal here, not a - modelling failure. -- **Reads are cheap and writes are the constraint.** Denormalised copies have to - be kept in step, which is what [transactions](#transactions) and batched writes - are for. - -Use the typed objects for what they are good at: making the record you already -decided to store safe to work with. Not for pretending it is a table. - -## Getting started - -### 1. Describe your entity - -Public and protected properties are persisted. Private ones never are, which is -how the base class keeps its own `$id` out of the stored record: the key **is** -the id, so storing it twice only lets the two drift apart. - -```php -use PhpFirebase\Entity; - -enum Role: string -{ - case Admin = 'admin'; - case Member = 'member'; -} - -final class User extends Entity -{ - public string $firstName = ''; - public string $lastName = ''; - public ?int $age = null; - public Role $role = Role::Member; // backed enums are converted - public ?DateTimeImmutable $joinedAt = null; // as are dates -} -``` - -Unknown keys in the stored record are ignored rather than fatal, which keeps old -records readable after you add a field. - -Entities are yours; `User` here is only an example. Any class works, including -aggregates holding plain value objects that know nothing about persistence: - -```php -final readonly class Money -{ - public function __construct(public int $amount, public string $currency) {} -} - -final class Order extends Entity -{ - public OrderStatus $status = OrderStatus::Pending; - public ?Money $total = null; - /** @var list */ - public array $lines = []; -} -``` - -#### Constructors - -There are two shapes, and both work: - -- **No constructor** - properties are filled directly. Simplest, and the kinder - choice for records that gain fields over time. -- **Every persisted property promoted into the constructor** - the object can - never exist half-built, which is what you want for an aggregate with - invariants to protect. - -A constructor covering only *some* persisted properties is **refused with an -error**, because the rest would silently keep their defaults on read and then -overwrite the stored values on the next save: - -```php -final class Broken extends Entity -{ - public string $note = ''; // never filled - public function __construct(public string $title = '') {} -} -// InvalidEntity: Broken declares a constructor that does not cover $note ... -``` - -### 2. Describe the collection - -```php -use PhpFirebase\Repository; - -/** @extends Repository */ -final class UserRepository extends Repository -{ - protected function collection(): string - { - return 'users'; - } - - protected function entityClass(): string - { - return User::class; - } -} -``` - -For the simple case, skip the subclass entirely: - -```php -$users = new EntityRepository($connection, 'users', User::class); -``` - -### 3. Connect it - -**Realtime Database**, through `kreait/firebase-php`: - -```php -use Kreait\Firebase\Factory; -use PhpFirebase\Database\RealtimeDatabaseConnection; - -$database = (new Factory()) - ->withServiceAccount('/path/to/service-account.json') - ->withDatabaseUri('https://your-project.firebaseio.com') - ->createDatabase(); - -$users = new UserRepository(new RealtimeDatabaseConnection($database)); -``` - -**Firestore**, over its REST API: - -```php -use PhpFirebase\Database\Firestore\FirestoreConnectionFactory; - -$connection = FirestoreConnectionFactory::fromServiceAccount('/path/to/service-account.json'); -// or, on Cloud Run / App Engine / GCE / after `gcloud auth application-default login`: -$connection = FirestoreConnectionFactory::fromApplicationDefaultCredentials('your-project-id'); - -$users = new UserRepository($connection); -``` - -Either way credentials come from a service account or Application Default -Credentials - not the legacy database secret 0.x used. - -## Using it - -```php -// Create: no id yet, so the backend allocates one -$ada = $users->save($ada); -$ada->id(); // '-NxAbc123...' - -// Read -$users->find('-NxAbc123...'); // ?User -$users->get('-NxAbc123...'); // User, or throws EntityNotFound -$users->all(); // array, keyed by id -$users->count(); - -// Update the whole record, or just part of it -$ada->lastName = 'King'; -$users->save($ada); -$users->update($ada, ['lastName' => 'King']); - -// Delete -$users->delete($ada); -$users->deleteAll(); -``` - -Entities are treated as immutable where it matters: `save()` and `withId()` return -a copy rather than mutating the instance you passed in. - -### Saving in batches - -`saveMany()` writes entities that already have an id in **one** round trip, using -a multi-path update on the Realtime Database and a batched commit on Firestore: - -```php -$users->saveMany([ - $ada->withId('user-1'), - $grace->withId('user-2'), -]); // one request, not two -``` - -Entities without an id are created one at a time, because only the backend can -allocate their ids. Mixing both in a single call is fine. - -### Subcollections and nested paths - -A collection is a path, so nesting works on both backends: - -```php -final class OrderRepository extends Repository -{ - public function __construct(Connection $connection, private readonly string $userId) - { - parent::__construct($connection); - } - - protected function collection(): string - { - return sprintf('users/%s/orders', $this->userId); - } - - protected function entityClass(): string - { - return Order::class; - } -} -``` - -On Firestore that is a real subcollection, queried against its parent document. -On the Realtime Database it is simply a deeper node in the tree. - -## Transactions - -Reading a value, changing it and writing it back is unsafe if anything else -might be doing the same. `modify()` does it properly: - -```php -$accounts->modify('ada', function (Account $account): Account { - $account->balance -= 10; - - return $account; -}); -``` - -The work runs against a consistent snapshot and is committed only if nothing it -read has changed. If another writer got there first, it runs **again** with the -newer data - so keep it repeatable, and send the receipt email after it returns -rather than inside it. After five losing attempts it gives up with -`TransactionConflict`. - -For more than one record, take the transaction itself: - -```php -$orders->transaction(function (EntityTransaction $orders): void { - $order = $orders->get('order-1'); - $order->status = OrderStatus::Shipped; - $orders->save($order); -}); -``` - -Read through the handle you are given, not through the repository: only reads -made there are watched for concurrent changes. - -**The guarantee differs by backend, and the difference is worth knowing.** -Firestore commits every write together or none of them. The Realtime Database -has no such thing - it checks each write individually against the version it -read, so a transaction touching two records can leave the first written and the -second refused. For the single-record case both give you exactly what you want. - -## Server timestamps and removing fields - -Some values are decided by the server, not by you: - -```php -use PhpFirebase\Database\Sentinel; - -$order->updatedAt = Sentinel::ServerTimestamp; // the server's clock, not yours -$orders->save($order); - -$orders->update($order, ['couponCode' => Sentinel::Remove]); // gone, not null -``` - -`Remove` is worth the distinction: storing `null` and having no field at all are -different states, and only the second one stops the field existing. - -`save()` returns the entity you handed it plus its id - it does not read back, -so that copy still holds the sentinel rather than the time the server chose. It -encodes as `null` until you re-read, which keeps an entity holding one safe to -`json_encode`. Read the record again when you need the resolved value. - -## Querying - -Queries are immutable, so a partly built one is safe to keep and branch from: - -```php -use PhpFirebase\Database\Direction; -use PhpFirebase\Database\Operator; - -$adults = $users->query()->where('age', Operator::GreaterThanOrEqual, 18); - -$firstTen = $adults->limit(10)->fetch(); -$oldest = $adults->orderBy('age', Direction::Descending)->first(); -``` - -`fetch()` returns entities keyed by id, `first()` returns one or `null`, and -`count()` returns how many matched. - -### What each backend can actually run - -This is where the two backends genuinely differ, and the library does not -pretend otherwise. - -| | Realtime Database | Firestore | -| --- | --- | --- | -| Filter on several fields | No - throws `UnsupportedQuery` | Yes, needs a composite index | -| Sort by a field other than the filtered one | No - throws `UnsupportedQuery` | Yes | -| Sort descending | Emulated client side | Native | -| `Operator::In`, `Operator::Contains` | No - throws `UnsupportedQuery` | Yes | -| `count()` | Reads the matching records | Native aggregation query | - -#### The Realtime Database - -The Realtime Database is not a query engine. It filters and sorts on **one field -per query**, and that field must be indexed in your security rules. Rather than -quietly returning the wrong rows, the adapter throws `UnsupportedQuery`: - -```php -$users->query() - ->whereEquals('role', 'admin') - ->whereEquals('age', 36) - ->fetch(); -// UnsupportedQuery: The Realtime Database cannot run this query: -// it can filter on one field only, and this query filters on role, age. -``` - -Three details it handles for you. Filtering implies ordering on the same field, -so you do not have to say it twice. Because the database only ever sorts -ascending, a descending query is served by reading the matching window and -reversing it. And a `limit()` with nothing to order by is given one - the -database rejects a bare limit with *"orderBy must be defined when other query -parameters are defined"*, and taking the first n of a keyed collection means in -key order. - -#### Firestore - -Firestore can express everything `Criteria` describes, so nothing is refused. It -will instead ask for an index the first time you run a compound query, and the -error it returns contains a link that creates it. - -Two things worth knowing about this adapter: - -- **Dates are stored as ISO-8601 strings**, not Firestore timestamps. They sort - and compare correctly because ISO-8601 is lexicographically ordered, and they - are read back into `DateTimeImmutable` either way. -- **`deleteAll()` is not atomic.** Firestore has no truncate - a collection is - only the documents in it - so it reads the collection and deletes each - document. Empty a large collection from a batched job instead. - -## When something goes wrong - -Everything this package throws implements -`PhpFirebase\Exception\PhpFirebaseException`, so it can all be caught together: - -| Exception | Means | -| --- | --- | -| `EntityNotFound` | `get()` found nothing under that id | -| `InvalidEntity` | a record could not be mapped, or a value could not be stored | -| `UnsupportedQuery` | this backend cannot express the query, and will not guess | -| `TransactionConflict` | a transaction lost the race on every attempt | -| `BackendError` | the backend refused the request | - -`BackendError` carries the HTTP `status` and Google's own `googleStatus` -(`FAILED_PRECONDITION`, `PERMISSION_DENIED`, ...), plus the message the API sent - -which matters, because the useful ones are specific: - -```php -try { - $tasks->query()->whereEquals('status', 'open')->orderBy('due')->fetch(); -} catch (BackendError $e) { - $e->googleStatus; // FAILED_PRECONDITION - $e->reason; // "The query requires an index. You can create it here: https://..." -} -``` - -That link creates the index. Firestore reports failures on its streaming -endpoints inside a JSON *array* rather than an object, and missing that shape -turns the most useful error either database produces into "Bad Request" - so -both shapes are unwrapped. - -A `TransactionConflict` says what the final attempt actually failed with, rather -than only that it gave up. Failures that are not conflicts are raised -immediately instead of being retried, since retrying them cannot help. - -## Extending it - -The package is deliberately small, and the parts it leaves out are reachable -from outside rather than only by forking. - -### A different backend - -Implement `Connection` - nine methods over a collection of records, plus -`transaction()`. Nothing above it knows what Firebase is, so the same entities, -repositories and queries run against whatever you write. The test suite does -exactly this with an in-memory implementation, and a parity test asserts the -same entity comes back identically from each. - -### A constraint this package does not model - -Cursors are the honest example. Firestore has them; the Realtime Database -cannot express one, so a neutral API for them would be a Firestore feature in a -backend-neutral coat. Carry it as an extra instead: - -```php -$criteria = $criteria->orderBy('age')->withExtra('startAfter', [42]); -``` - -A connection declares the extras it honours, and **must refuse any it does not** -- otherwise a query silently means something different depending on where it -runs: - -```php -final class CursorFirestoreConnection extends FirestoreConnection -{ - protected function supportedExtras(): array - { - return ['startAfter']; - } -} - -final readonly class CursorQueryTranslator extends QueryTranslator -{ - public function translate(string $collection, Criteria $criteria): array - { - $query = parent::translate($collection, $criteria); - - if (is_array($cursor = $criteria->extra('startAfter'))) { - $query['startAt'] = ['values' => ..., 'before' => false]; - } - - return $query; - } -} -``` - -`FirestoreConnection` takes its `QueryTranslator` and `ValueEncoder` as -constructor arguments, so swapping either needs no changes here. There is a -working version of the above in `tests/ExtensibilityTest.php`. - -### Query methods of your own - -Subclass `Query`, and have the repository hand out your type: - -```php -/** @extends Query */ -final readonly class UserQuery extends Query -{ - public function adults(): static - { - return $this->where('age', Operator::GreaterThanOrEqual, 18); - } -} - -final class UserRepository extends Repository -{ - // A narrower return type is allowed, and is what callers see. - public function query(): UserQuery - { - return new UserQuery($this->connection, $this->collection(), $this->entityClass()); - } -} - -$users->query()->adults()->limit(10)->fetch(); -``` - -Override `with()` in the subclass if you add state of your own; the chain keeps -returning your type either way. +- [Getting started](docs/getting-started.md) - entities, repositories, connecting +- [Reading and writing](docs/usage.md) - saving, batches, transactions, server values +- [Queries and modelling](docs/queries.md) - the query builder, and what each backend can run +- [Errors](docs/errors.md) - what gets thrown and what it carries +- [Extending it](docs/extending.md) - other backends, other constraints, your own query methods +- [Upgrading from 0.x](docs/upgrading.md) ## A short history diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..22b9c32 --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,34 @@ +# When something goes wrong + +Everything this package throws implements +`PhpFirebase\Exception\PhpFirebaseException`, so it can all be caught together: + +| Exception | Means | +| --- | --- | +| `EntityNotFound` | `get()` found nothing under that id | +| `InvalidEntity` | a record could not be mapped, or a value could not be stored | +| `UnsupportedQuery` | this backend cannot express the query, and will not guess | +| `TransactionConflict` | a transaction lost the race on every attempt | +| `BackendError` | the backend refused the request | + +`BackendError` carries the HTTP `status` and Google's own `googleStatus` +(`FAILED_PRECONDITION`, `PERMISSION_DENIED`, ...), plus the message the API sent - +which matters, because the useful ones are specific: + +```php +try { + $tasks->query()->whereEquals('status', 'open')->orderBy('due')->fetch(); +} catch (BackendError $e) { + $e->googleStatus; // FAILED_PRECONDITION + $e->reason; // "The query requires an index. You can create it here: https://..." +} +``` + +That link creates the index. Firestore reports failures on its streaming +endpoints inside a JSON *array* rather than an object, and missing that shape +turns the most useful error either database produces into "Bad Request" - so +both shapes are unwrapped. + +A `TransactionConflict` says what the final attempt actually failed with, rather +than only that it gave up. Failures that are not conflicts are raised +immediately instead of being retried, since retrying them cannot help. diff --git a/docs/extending.md b/docs/extending.md new file mode 100644 index 0000000..242884b --- /dev/null +++ b/docs/extending.md @@ -0,0 +1,83 @@ +# Extending it + +The package is deliberately small, and the parts it leaves out are reachable +from outside rather than only by forking. + +### A different backend + +Implement `Connection` - nine methods over a collection of records, plus +`transaction()`. Nothing above it knows what Firebase is, so the same entities, +repositories and queries run against whatever you write. The test suite does +exactly this with an in-memory implementation, and a parity test asserts the +same entity comes back identically from each. + +### A constraint this package does not model + +Cursors are the honest example. Firestore has them; the Realtime Database +cannot express one, so a neutral API for them would be a Firestore feature in a +backend-neutral coat. Carry it as an extra instead: + +```php +$criteria = $criteria->orderBy('age')->withExtra('startAfter', [42]); +``` + +A connection declares the extras it honours, and **must refuse any it does not** +- otherwise a query silently means something different depending on where it +runs: + +```php +final class CursorFirestoreConnection extends FirestoreConnection +{ + protected function supportedExtras(): array + { + return ['startAfter']; + } +} + +final readonly class CursorQueryTranslator extends QueryTranslator +{ + public function translate(string $collection, Criteria $criteria): array + { + $query = parent::translate($collection, $criteria); + + if (is_array($cursor = $criteria->extra('startAfter'))) { + $query['startAt'] = ['values' => ..., 'before' => false]; + } + + return $query; + } +} +``` + +`FirestoreConnection` takes its `QueryTranslator` and `ValueEncoder` as +constructor arguments, so swapping either needs no changes here. There is a +working version of the above in `tests/ExtensibilityTest.php`. + +### Query methods of your own + +Subclass `Query`, and have the repository hand out your type: + +```php +/** @extends Query */ +final readonly class UserQuery extends Query +{ + public function adults(): static + { + return $this->where('age', Operator::GreaterThanOrEqual, 18); + } +} + +final class UserRepository extends Repository +{ + // A narrower return type is allowed, and is what callers see. + public function query(): UserQuery + { + return new UserQuery($this->connection, $this->collection(), $this->entityClass()); + } +} + +$users->query()->adults()->limit(10)->fetch(); +``` + +Override `with()` in the subclass if you add state of your own; the chain keeps +returning your type either way. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..cd05996 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,127 @@ +# Getting started + +### 1. Describe your entity + +Public and protected properties are persisted. Private ones never are, which is +how the base class keeps its own `$id` out of the stored record: the key **is** +the id, so storing it twice only lets the two drift apart. + +```php +use PhpFirebase\Entity; + +enum Role: string +{ + case Admin = 'admin'; + case Member = 'member'; +} + +final class User extends Entity +{ + public string $firstName = ''; + public string $lastName = ''; + public ?int $age = null; + public Role $role = Role::Member; // backed enums are converted + public ?DateTimeImmutable $joinedAt = null; // as are dates +} +``` + +Unknown keys in the stored record are ignored rather than fatal, which keeps old +records readable after you add a field. + +Entities are yours; `User` here is only an example. Any class works, including +aggregates holding plain value objects that know nothing about persistence: + +```php +final readonly class Money +{ + public function __construct(public int $amount, public string $currency) {} +} + +final class Order extends Entity +{ + public OrderStatus $status = OrderStatus::Pending; + public ?Money $total = null; + /** @var list */ + public array $lines = []; +} +``` + +#### Constructors + +There are two shapes, and both work: + +- **No constructor** - properties are filled directly. Simplest, and the kinder + choice for records that gain fields over time. +- **Every persisted property promoted into the constructor** - the object can + never exist half-built, which is what you want for an aggregate with + invariants to protect. + +A constructor covering only *some* persisted properties is **refused with an +error**, because the rest would silently keep their defaults on read and then +overwrite the stored values on the next save: + +```php +final class Broken extends Entity +{ + public string $note = ''; // never filled + public function __construct(public string $title = '') {} +} +// InvalidEntity: Broken declares a constructor that does not cover $note ... +``` + +### 2. Describe the collection + +```php +use PhpFirebase\Repository; + +/** @extends Repository */ +final class UserRepository extends Repository +{ + protected function collection(): string + { + return 'users'; + } + + protected function entityClass(): string + { + return User::class; + } +} +``` + +For the simple case, skip the subclass entirely: + +```php +$users = new EntityRepository($connection, 'users', User::class); +``` + +### 3. Connect it + +**Realtime Database**, through `kreait/firebase-php`: + +```php +use Kreait\Firebase\Factory; +use PhpFirebase\Database\RealtimeDatabaseConnection; + +$database = (new Factory()) + ->withServiceAccount('/path/to/service-account.json') + ->withDatabaseUri('https://your-project.firebaseio.com') + ->createDatabase(); + +$users = new UserRepository(new RealtimeDatabaseConnection($database)); +``` + +**Firestore**, over its REST API: + +```php +use PhpFirebase\Database\Firestore\FirestoreConnectionFactory; + +$connection = FirestoreConnectionFactory::fromServiceAccount('/path/to/service-account.json'); +// or, on Cloud Run / App Engine / GCE / after `gcloud auth application-default login`: +$connection = FirestoreConnectionFactory::fromApplicationDefaultCredentials('your-project-id'); + +$users = new UserRepository($connection); +``` + +Either way credentials come from a service account or Application Default +Credentials - not the legacy database secret 0.x used. diff --git a/docs/queries.md b/docs/queries.md new file mode 100644 index 0000000..0ab3aca --- /dev/null +++ b/docs/queries.md @@ -0,0 +1,96 @@ +# Querying + +Queries are immutable, so a partly built one is safe to keep and branch from: + +```php +use PhpFirebase\Database\Direction; +use PhpFirebase\Database\Operator; + +$adults = $users->query()->where('age', Operator::GreaterThanOrEqual, 18); + +$firstTen = $adults->limit(10)->fetch(); +$oldest = $adults->orderBy('age', Direction::Descending)->first(); +``` + +`fetch()` returns entities keyed by id, `first()` returns one or `null`, and +`count()` returns how many matched. + +### What each backend can actually run + +This is where the two backends genuinely differ, and the library does not +pretend otherwise. + +| | Realtime Database | Firestore | +| --- | --- | --- | +| Filter on several fields | No - throws `UnsupportedQuery` | Yes, needs a composite index | +| Sort by a field other than the filtered one | No - throws `UnsupportedQuery` | Yes | +| Sort descending | Emulated client side | Native | +| `Operator::In`, `Operator::Contains` | No - throws `UnsupportedQuery` | Yes | +| `count()` | Reads the matching records | Native aggregation query | + +#### The Realtime Database + +The Realtime Database is not a query engine. It filters and sorts on **one field +per query**, and that field must be indexed in your security rules. Rather than +quietly returning the wrong rows, the adapter throws `UnsupportedQuery`: + +```php +$users->query() + ->whereEquals('role', 'admin') + ->whereEquals('age', 36) + ->fetch(); +// UnsupportedQuery: The Realtime Database cannot run this query: +// it can filter on one field only, and this query filters on role, age. +``` + +Three details it handles for you. Filtering implies ordering on the same field, +so you do not have to say it twice. Because the database only ever sorts +ascending, a descending query is served by reading the matching window and +reversing it. And a `limit()` with nothing to order by is given one - the +database rejects a bare limit with *"orderBy must be defined when other query +parameters are defined"*, and taking the first n of a keyed collection means in +key order. + +#### Firestore + +Firestore can express everything `Criteria` describes, so nothing is refused. It +will instead ask for an index the first time you run a compound query, and the +error it returns contains a link that creates it. + +Two things worth knowing about this adapter: + +- **Dates are stored as ISO-8601 strings**, not Firestore timestamps. They sort + and compare correctly because ISO-8601 is lexicographically ordered, and they + are read back into `DateTimeImmutable` either way. +- **`deleteAll()` is not atomic.** Firestore has no truncate - a collection is + only the documents in it - so it reads the collection and deletes each + document. Empty a large collection from a batched job instead. + +## Modelling for Firebase, not against it + +Repositories and entities come from the relational world, where you model the +domain and then persist it. Firebase is the other way round: you model for the +reads you intend to do, and you denormalise on purpose. + +So resist the pull toward one repository per noun with references between them. +Fetching an order, then its customer, then each of its line items is three round +trips for something Firebase would rather you stored as one document. **Entities +nest** - an `Order` holding `LineItem` objects and a `Money` value object is a +single record, and that is usually the right shape: + +```php +$order = $orders->get('order-1'); // one read, whole aggregate +$order->lines[0]->price->amount; +``` + +Two consequences worth planning for: + +- **A query cannot span collections.** If you need data together, store it + together. Duplicating a customer's name onto the order is normal here, not a + modelling failure. +- **Reads are cheap and writes are the constraint.** Denormalised copies have to + be kept in step, which is what [transactions](usage.md#transactions) and batched writes + are for. + +Use the typed objects for what they are good at: making the record you already +decided to store safe to work with. Not for pretending it is a table. diff --git a/UPGRADING.md b/docs/upgrading.md similarity index 100% rename from UPGRADING.md rename to docs/upgrading.md diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..a5dce33 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,126 @@ +# Using it + +```php +// Create: no id yet, so the backend allocates one +$ada = $users->save($ada); +$ada->id(); // '-NxAbc123...' + +// Read +$users->find('-NxAbc123...'); // ?User +$users->get('-NxAbc123...'); // User, or throws EntityNotFound +$users->all(); // array, keyed by id +$users->count(); + +// Update the whole record, or just part of it +$ada->lastName = 'King'; +$users->save($ada); +$users->update($ada, ['lastName' => 'King']); + +// Delete +$users->delete($ada); +$users->deleteAll(); +``` + +Entities are treated as immutable where it matters: `save()` and `withId()` return +a copy rather than mutating the instance you passed in. + +### Saving in batches + +`saveMany()` writes entities that already have an id in **one** round trip, using +a multi-path update on the Realtime Database and a batched commit on Firestore: + +```php +$users->saveMany([ + $ada->withId('user-1'), + $grace->withId('user-2'), +]); // one request, not two +``` + +Entities without an id are created one at a time, because only the backend can +allocate their ids. Mixing both in a single call is fine. + +### Subcollections and nested paths + +A collection is a path, so nesting works on both backends: + +```php +final class OrderRepository extends Repository +{ + public function __construct(Connection $connection, private readonly string $userId) + { + parent::__construct($connection); + } + + protected function collection(): string + { + return sprintf('users/%s/orders', $this->userId); + } + + protected function entityClass(): string + { + return Order::class; + } +} +``` + +On Firestore that is a real subcollection, queried against its parent document. +On the Realtime Database it is simply a deeper node in the tree. + +## Transactions + +Reading a value, changing it and writing it back is unsafe if anything else +might be doing the same. `modify()` does it properly: + +```php +$accounts->modify('ada', function (Account $account): Account { + $account->balance -= 10; + + return $account; +}); +``` + +The work runs against a consistent snapshot and is committed only if nothing it +read has changed. If another writer got there first, it runs **again** with the +newer data - so keep it repeatable, and send the receipt email after it returns +rather than inside it. After five losing attempts it gives up with +`TransactionConflict`. + +For more than one record, take the transaction itself: + +```php +$orders->transaction(function (EntityTransaction $orders): void { + $order = $orders->get('order-1'); + $order->status = OrderStatus::Shipped; + $orders->save($order); +}); +``` + +Read through the handle you are given, not through the repository: only reads +made there are watched for concurrent changes. + +**The guarantee differs by backend, and the difference is worth knowing.** +Firestore commits every write together or none of them. The Realtime Database +has no such thing - it checks each write individually against the version it +read, so a transaction touching two records can leave the first written and the +second refused. For the single-record case both give you exactly what you want. + +## Server timestamps and removing fields + +Some values are decided by the server, not by you: + +```php +use PhpFirebase\Database\Sentinel; + +$order->updatedAt = Sentinel::ServerTimestamp; // the server's clock, not yours +$orders->save($order); + +$orders->update($order, ['couponCode' => Sentinel::Remove]); // gone, not null +``` + +`Remove` is worth the distinction: storing `null` and having no field at all are +different states, and only the second one stops the field existing. + +`save()` returns the entity you handed it plus its id - it does not read back, +so that copy still holds the sentinel rather than the time the server chose. It +encodes as `null` until you re-read, which keeps an entity holding one safe to +`json_encode`. Read the record again when you need the resolved value.