From 7c62daca0b00d2675423d10b0ab32a99f78b6fe8 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 29 Aug 2026 10:35:36 +0200 Subject: [PATCH] Add optimistic locking --- README.md | 1 + docs/documents.md | 47 ++++++++ docs/introduction.md | 1 + docs/repository.md | 8 ++ phpstan-baseline.neon | 22 +++- src/Attribute/Version.php | 12 ++ .../AttributeDocumentMetadataFactory.php | 35 ++++++ src/Metadata/DocumentMetadata.php | 49 ++++++++ .../MultipleVersionPropertiesFound.php | 18 +++ src/Metadata/VersionPropertyIsReadonly.php | 23 ++++ src/Repository/MongoDBRepository.php | 83 ++++++++++---- src/Repository/OptimisticLockFailed.php | 36 ++++++ src/Repository/RangoRepository.php | 78 ++++++++++--- .../Integration/Fixtures/VersionedProfile.php | 22 ++++ tests/Integration/RepositoryTestCase.php | 103 +++++++++++++++++ .../AttributeDocumentMetadataFactoryTest.php | 107 ++++++++++++++++++ tests/Unit/Metadata/DocumentMetadataTest.php | 41 +++++++ 17 files changed, 644 insertions(+), 42 deletions(-) create mode 100644 src/Attribute/Version.php create mode 100644 src/Metadata/MultipleVersionPropertiesFound.php create mode 100644 src/Metadata/VersionPropertyIsReadonly.php create mode 100644 src/Repository/OptimisticLockFailed.php create mode 100644 tests/Integration/Fixtures/VersionedProfile.php diff --git a/README.md b/README.md index 17e3583..cd10c6e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Unlike Doctrine ODM, Patchlevel ODM has **no Unit of Work**. Repositories contro * [Repositories without a Unit of Work](https://patchlevel.dev/docs/odm/latest/repository) for predictable writes * [Querying](https://patchlevel.dev/docs/odm/latest/repository#querying) with filters, sorting and pagination * [Indexes](https://patchlevel.dev/docs/odm/latest/documents#indexes) defined with `#[Index]`, including unique constraints +* [Optimistic locking](https://patchlevel.dev/docs/odm/latest/documents#versioning) with `#[Version]` to catch concurrent writes * [Field mapping and normalization](https://patchlevel.dev/docs/odm/latest/field-mapping) for nested objects and custom field names * [Encryption and crypto shredding](https://patchlevel.dev/docs/odm/latest/encryption) for sensitive data diff --git a/docs/documents.md b/docs/documents.md index 7687d31..10b9f10 100644 --- a/docs/documents.md +++ b/docs/documents.md @@ -142,6 +142,53 @@ Run index synchronization as part of a deployment or migration step rather than your collections stay in sync with the document definitions. ::: +## Versioning + +Mark an integer property with `#[Version]` to enable optimistic locking. The repository then guards +every `update()` against concurrent writes: it only touches the document while the stored version +still matches the one you loaded, and raises `OptimisticLockFailed` when another process changed or +removed the document in the meantime. + +```php +use Patchlevel\ODM\Attribute\Document; +use Patchlevel\ODM\Attribute\Id; +use Patchlevel\ODM\Attribute\Version; + +#[Document('profiles')] +final class Profile +{ + public function __construct( + #[Id] + public readonly string $id, + public string $name, + #[Version] + public int $version = 0, + ) { + } +} +``` +A new document starts at whatever value the object carries, so give the property a default of `0`. +On every successful `update()` the repository increments the stored version and writes the new value +back onto the object, so you can keep updating the same instance in a long-running worker. + +:::warning +The version property must not be `readonly`, because the repository writes the incremented version +back after each update. A `readonly` version property throws `VersionPropertyIsReadonly` when the +document metadata is built. Only one property may carry `#[Version]`; a second one throws +`MultipleVersionPropertiesFound`. +::: + +:::note +The version field goes through the [field mapping](field-mapping.md) like any other property, so you +can rename it with a `#[NormalizedName]` attribute. +::: + +:::tip +Adding `#[Version]` to a document that already has stored data? Backfill the version field on every +existing document first, otherwise the first `update()` fails because the filter never matches a +document without the field. +::: + ## Learn more * [How to store and load documents](repository.md) diff --git a/docs/introduction.md b/docs/introduction.md index c97c846..e6d7990 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -17,6 +17,7 @@ fit for long-running worker processes. * [Repositories without a Unit of Work](repository.md) for predictable writes * [Querying](repository.md#querying) with filters, sorting and pagination * [Indexes](documents.md#indexes) defined with `#[Index]`, including unique constraints +* [Optimistic locking](documents.md#versioning) with `#[Version]` to catch concurrent writes * [Field mapping and normalization](field-mapping.md) for nested objects and custom field names * [Encryption and crypto shredding](encryption.md) for sensitive data diff --git a/docs/repository.md b/docs/repository.md index f73bb3a..7745012 100644 --- a/docs/repository.md +++ b/docs/repository.md @@ -59,6 +59,14 @@ $profile->name = 'Rango Updated'; $repository->update($profile); ``` +:::note +When the document has a [`#[Version]` property](documents.md#versioning), `update()` throws +`OptimisticLockFailed` if another process changed or removed it since you loaded it. Reload the +document and reapply your change. In a batch `update()` the exception is raised when any document in +the batch is stale, and documents earlier in the batch may already be written because there is no +surrounding transaction. +::: + ## Loading by id `find()` returns the document or `null`. `get()` returns the document or throws `DocumentNotFound` diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3d11f5c..6209b4b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -60,6 +60,12 @@ parameters: count: 1 path: src/Metadata/StackHydratorFieldMappingResolver.php + - + message: '#^Cannot cast mixed to string\.$#' + identifier: cast.string + count: 1 + path: src/Repository/MongoDBRepository.php + - message: '#^PHPDoc tag @return contains generic type MongoDB\\Collection\\> but class MongoDB\\Collection is not generic\.$#' identifier: generics.notGeneric @@ -73,7 +79,7 @@ parameters: path: src/Repository/MongoDBRepository.php - - message: '#^Parameter \#1 \$operations of method MongoDB\\Collection\:\:bulkWrite\(\) expects list\, non\-empty\-array\\}\}\}\> given\.$#' + message: '#^Parameter \#1 \$operations of method MongoDB\\Collection\:\:bulkWrite\(\) expects list\, non\-empty\-array\, array\{''\$set''\: array\\}\}\}\> given\.$#' identifier: argument.type count: 1 path: src/Repository/MongoDBRepository.php @@ -102,6 +108,12 @@ parameters: count: 1 path: src/Repository/MongoDBRepositoryManager.php + - + message: '#^Cannot cast mixed to string\.$#' + identifier: cast.string + count: 1 + path: src/Repository/RangoRepository.php + - message: '#^Parameter \#1 \$documents of method Patchlevel\\Rango\\Collection\\>\:\:insertMany\(\) expects list\\>, array\\> given\.$#' identifier: argument.type @@ -109,7 +121,7 @@ parameters: path: src/Repository/RangoRepository.php - - message: '#^Parameter \#1 \$operations of method Patchlevel\\Rango\\Collection\\>\:\:bulkWrite\(\) expects list\\>\>\>, non\-empty\-array\\}\}\}\> given\.$#' + message: '#^Parameter \#1 \$operations of method Patchlevel\\Rango\\Collection\\>\:\:bulkWrite\(\) expects list\\>\>\>, non\-empty\-array\, array\{''\$set''\: array\\}\}\}\> given\.$#' identifier: argument.type count: 1 path: src/Repository/RangoRepository.php @@ -141,6 +153,12 @@ parameters: - message: '#^Cannot access offset ''name'' on array\|object\.$#' identifier: offsetAccess.nonOffsetAccessible + count: 4 + path: tests/Integration/RepositoryTestCase.php + + - + message: '#^Cannot access offset ''version'' on array\|object\.$#' + identifier: offsetAccess.nonOffsetAccessible count: 3 path: tests/Integration/RepositoryTestCase.php diff --git a/src/Attribute/Version.php b/src/Attribute/Version.php new file mode 100644 index 0000000..16d9535 --- /dev/null +++ b/src/Attribute/Version.php @@ -0,0 +1,12 @@ +database; $fields = []; $idProperty = $this->getIdProperty($reflection); + $versionProperty = $this->getVersionProperty($reflection); foreach ($reflection->getProperties() as $reflectionProperty) { $field = $this->fieldResolver?->resolve($reflectionProperty); @@ -57,6 +59,12 @@ public function metadata(string $className): DocumentMetadata continue; } + if ($versionProperty === $reflectionProperty->getName()) { + $fields[$reflectionProperty->getName()] = $field ?? new FieldMapping($reflectionProperty->getName()); + + continue; + } + if (!$field) { continue; } @@ -71,6 +79,7 @@ public function metadata(string $className): DocumentMetadata $idProperty, $this->indexes($reflection), $fields, + $versionProperty, ); } @@ -123,4 +132,30 @@ private function getIdProperty(ReflectionClass $reflection): string return $idProperty; } + + /** @param ReflectionClass $reflection */ + private function getVersionProperty(ReflectionClass $reflection): string|null + { + $versionProperty = null; + + foreach ($reflection->getProperties() as $reflectionProperty) { + $attributes = $reflectionProperty->getAttributes(Version::class); + + if ($attributes === []) { + continue; + } + + if ($versionProperty !== null) { + throw new MultipleVersionPropertiesFound($reflection->name); + } + + if ($reflectionProperty->isReadOnly()) { + throw new VersionPropertyIsReadonly($reflection->name, $reflectionProperty->getName()); + } + + $versionProperty = $reflectionProperty->getName(); + } + + return $versionProperty; + } } diff --git a/src/Metadata/DocumentMetadata.php b/src/Metadata/DocumentMetadata.php index 6634bcc..a2977e3 100644 --- a/src/Metadata/DocumentMetadata.php +++ b/src/Metadata/DocumentMetadata.php @@ -5,18 +5,23 @@ namespace Patchlevel\ODM\Metadata; use Patchlevel\ODM\Index; +use ReflectionProperty; use function array_is_list; use function array_keys; use function array_map; +use function assert; use function explode; use function implode; use function is_array; +use function is_int; use function str_starts_with; /** @template T of object */ final readonly class DocumentMetadata { + private ReflectionProperty|null $versionReflection; + /** * @param class-string $className * @param list $indexes @@ -29,7 +34,51 @@ public function __construct( public string $idProperty, public array $indexes = [], public array $fields = [], + public string|null $versionProperty = null, ) { + $this->versionReflection = $versionProperty !== null + ? new ReflectionProperty($className, $versionProperty) + : null; + } + + /** + * Storage field name of the version property, or null when the document is not versioned. + */ + public function versionField(): string|null + { + if ($this->versionProperty === null) { + return null; + } + + return $this->fields[$this->versionProperty]->fieldName; + } + + /** + * Read the current version from the document, or null when it is not versioned. + * + * @param T $document + */ + public function readVersion(object $document): int|null + { + if ($this->versionReflection === null) { + return null; + } + + $version = $this->versionReflection->getValue($document); + assert(is_int($version)); + + return $version; + } + + /** + * Write the incremented version back onto the document after a successful update. Does nothing + * when the document is not versioned. + * + * @param T $document + */ + public function writeVersion(object $document, int $version): void + { + $this->versionReflection?->setValue($document, $version); } public function propertyPathToFieldPath(string $propertyPath): string diff --git a/src/Metadata/MultipleVersionPropertiesFound.php b/src/Metadata/MultipleVersionPropertiesFound.php new file mode 100644 index 0000000..7f72f91 --- /dev/null +++ b/src/Metadata/MultipleVersionPropertiesFound.php @@ -0,0 +1,18 @@ + ...$objects */ + /** @param T ...$objects */ public function update(object ...$objects): void { if (count($objects) === 0) { @@ -74,36 +74,75 @@ public function update(object ...$objects): void } if (count($objects) === 1) { - $object = $objects[0]; + $update = $this->prepareUpdate($objects[0]); - if ($object::class !== $this->metadata->className) { - throw new WrongClass($this->metadata->className, $object::class); - } + $result = $this->collection->updateOne($update['filter'], ['$set' => $update['set']]); - $data = $this->hydrator->extract($object); + if ($update['newVersion'] !== null && $result->getMatchedCount() === 0) { + throw OptimisticLockFailed::forDocument( + $this->metadata->className, + (string)$update['set']['_id'], + $update['newVersion'] - 1, + ); + } - $this->collection->updateOne(['_id' => $data['_id']], ['$set' => $data]); + if ($update['newVersion'] !== null) { + $this->metadata->writeVersion($objects[0], $update['newVersion']); + } return; } - $this->collection->bulkWrite(array_map( - function (object $object): array { - if ($object::class !== $this->metadata->className) { - throw new WrongClass($this->metadata->className, $object::class); - } - - $data = $this->hydrator->extract($object); + $updates = array_map(fn (object $object): array => $this->prepareUpdate($object), $objects); - return [ - 'updateOne' => [ - ['_id' => $data['_id']], - ['$set' => $data], - ], - ]; - }, - $objects, + $result = $this->collection->bulkWrite(array_map( + static fn (array $update): array => ['updateOne' => [$update['filter'], ['$set' => $update['set']]]], + $updates, )); + + if ($this->metadata->versionField() !== null && $result->getMatchedCount() !== count($objects)) { + throw OptimisticLockFailed::forBatch( + $this->metadata->className, + count($objects), + $result->getMatchedCount(), + ); + } + + foreach ($updates as $index => $update) { + if ($update['newVersion'] === null) { + continue; + } + + $this->metadata->writeVersion($objects[$index], $update['newVersion']); + } + } + + /** + * @param T $object + * + * @return array{filter: array, set: array, newVersion: int|null} + */ + private function prepareUpdate(object $object): array + { + if ($object::class !== $this->metadata->className) { + throw new WrongClass($this->metadata->className, $object::class); + } + + $data = $this->hydrator->extract($object); + $currentVersion = $this->metadata->readVersion($object); + + if ($currentVersion === null) { + return ['filter' => ['_id' => $data['_id']], 'set' => $data, 'newVersion' => null]; + } + + $versionField = (string)$this->metadata->versionField(); + $data[$versionField] = $currentVersion + 1; + + return [ + 'filter' => ['_id' => $data['_id'], $versionField => $currentVersion], + 'set' => $data, + 'newVersion' => $currentVersion + 1, + ]; } /** diff --git a/src/Repository/OptimisticLockFailed.php b/src/Repository/OptimisticLockFailed.php new file mode 100644 index 0000000..8283bbe --- /dev/null +++ b/src/Repository/OptimisticLockFailed.php @@ -0,0 +1,36 @@ + ...$objects */ + /** @param T ...$objects */ public function update(object ...$objects): void { if (count($objects) === 0) { @@ -79,33 +79,75 @@ public function update(object ...$objects): void } if (count($objects) === 1) { - $object = $objects[0]; + $update = $this->prepareUpdate($objects[0]); - if ($object::class !== $this->metadata->className) { - throw new WrongClass($this->metadata->className, $object::class); - } + $result = $this->collection->updateOne($update['filter'], ['$set' => $update['set']]); - $data = $this->hydrator->extract($object); + if ($update['newVersion'] !== null && $result->getMatchedCount() === 0) { + throw OptimisticLockFailed::forDocument( + $this->metadata->className, + (string)$update['set']['_id'], + $update['newVersion'] - 1, + ); + } - $this->collection->updateOne(['_id' => $data['_id']], ['$set' => $data]); + if ($update['newVersion'] !== null) { + $this->metadata->writeVersion($objects[0], $update['newVersion']); + } return; } - $this->collection->bulkWrite(array_map(function (object $object): array { - if ($object::class !== $this->metadata->className) { - throw new WrongClass($this->metadata->className, $object::class); + $updates = array_map(fn (object $object): array => $this->prepareUpdate($object), $objects); + + $result = $this->collection->bulkWrite(array_map( + static fn (array $update): array => ['updateOne' => [$update['filter'], ['$set' => $update['set']]]], + $updates, + )); + + if ($this->metadata->versionField() !== null && $result->getMatchedCount() !== count($objects)) { + throw OptimisticLockFailed::forBatch( + $this->metadata->className, + count($objects), + $result->getMatchedCount(), + ); + } + + foreach ($updates as $index => $update) { + if ($update['newVersion'] === null) { + continue; } - $data = $this->hydrator->extract($object); + $this->metadata->writeVersion($objects[$index], $update['newVersion']); + } + } + + /** + * @param T $object + * + * @return array{filter: array, set: array, newVersion: int|null} + */ + private function prepareUpdate(object $object): array + { + if ($object::class !== $this->metadata->className) { + throw new WrongClass($this->metadata->className, $object::class); + } + + $data = $this->hydrator->extract($object); + $currentVersion = $this->metadata->readVersion($object); + + if ($currentVersion === null) { + return ['filter' => ['_id' => $data['_id']], 'set' => $data, 'newVersion' => null]; + } + + $versionField = (string)$this->metadata->versionField(); + $data[$versionField] = $currentVersion + 1; - return [ - 'updateOne' => [ - ['_id' => $data['_id']], - ['$set' => $data], - ], - ]; - }, $objects)); + return [ + 'filter' => ['_id' => $data['_id'], $versionField => $currentVersion], + 'set' => $data, + 'newVersion' => $currentVersion + 1, + ]; } /** diff --git a/tests/Integration/Fixtures/VersionedProfile.php b/tests/Integration/Fixtures/VersionedProfile.php new file mode 100644 index 0000000..b6c4d96 --- /dev/null +++ b/tests/Integration/Fixtures/VersionedProfile.php @@ -0,0 +1,22 @@ +repositoryManager->get(VersionedProfile::class); + + $profile = new VersionedProfile('v-1', 'Rango'); + $repository->insert($profile); + + self::assertSame(0, $profile->version); + + $raw = $repository->collection()->findOne(['_id' => 'v-1']); + + self::assertNotNull($raw); + self::assertSame(0, $raw['version']); + } + + public function testUpdateIncrementsVersion(): void + { + $repository = $this->repositoryManager->get(VersionedProfile::class); + + $profile = new VersionedProfile('v-1', 'Rango'); + $repository->insert($profile); + + $profile->name = 'Rango Updated'; + $repository->update($profile); + + self::assertSame(1, $profile->version); + + $raw = $repository->collection()->findOne(['_id' => 'v-1']); + + self::assertNotNull($raw); + self::assertSame('Rango Updated', $raw['name']); + self::assertSame(1, $raw['version']); + + $repository->update($profile); + + self::assertSame(2, $profile->version); + } + + public function testUpdateWithStaleVersionThrows(): void + { + $repository = $this->repositoryManager->get(VersionedProfile::class); + + $repository->insert(new VersionedProfile('v-1', 'Rango')); + + $stale = $repository->get('v-1'); + $fresh = $repository->get('v-1'); + + $fresh->name = 'First writer wins'; + $repository->update($fresh); + + $stale->name = 'Second writer loses'; + + $this->expectException(OptimisticLockFailed::class); + + $repository->update($stale); + } + + public function testUpdateManyIncrementsVersions(): void + { + $repository = $this->repositoryManager->get(VersionedProfile::class); + + $first = new VersionedProfile('v-1', 'Rango'); + $second = new VersionedProfile('v-2', 'Beans'); + $repository->insert($first, $second); + + $first->name = 'Rango Updated'; + $second->name = 'Beans Updated'; + $repository->update($first, $second); + + self::assertSame(1, $first->version); + self::assertSame(1, $second->version); + + $r1 = $repository->collection()->findOne(['_id' => 'v-1']); + $r2 = $repository->collection()->findOne(['_id' => 'v-2']); + + self::assertNotNull($r1); + self::assertNotNull($r2); + self::assertSame(1, $r1['version']); + self::assertSame(1, $r2['version']); + } + + public function testUpdateManyWithStaleVersionThrows(): void + { + $repository = $this->repositoryManager->get(VersionedProfile::class); + + $first = new VersionedProfile('v-1', 'Rango'); + $second = new VersionedProfile('v-2', 'Beans'); + $repository->insert($first, $second); + + $concurrent = $repository->get('v-2'); + $concurrent->name = 'Changed elsewhere'; + $repository->update($concurrent); + + $first->name = 'Rango Updated'; + $second->name = 'Beans Updated'; + + $this->expectException(OptimisticLockFailed::class); + + $repository->update($first, $second); + } } diff --git a/tests/Unit/Metadata/AttributeDocumentMetadataFactoryTest.php b/tests/Unit/Metadata/AttributeDocumentMetadataFactoryTest.php index b9976de..3a35e20 100644 --- a/tests/Unit/Metadata/AttributeDocumentMetadataFactoryTest.php +++ b/tests/Unit/Metadata/AttributeDocumentMetadataFactoryTest.php @@ -6,6 +6,7 @@ use Patchlevel\ODM\Attribute\Document; use Patchlevel\ODM\Attribute\Id; +use Patchlevel\ODM\Attribute\Version; use Patchlevel\ODM\Index; use Patchlevel\ODM\Metadata\AttributeDocumentMetadataFactory; use Patchlevel\ODM\Metadata\ClassIsNotAnDocument; @@ -13,7 +14,9 @@ use Patchlevel\ODM\Metadata\FieldMapping; use Patchlevel\ODM\Metadata\FieldMappingResolver; use Patchlevel\ODM\Metadata\MultipleIdPropertiesFound; +use Patchlevel\ODM\Metadata\MultipleVersionPropertiesFound; use Patchlevel\ODM\Metadata\NoIdPropertyFound; +use Patchlevel\ODM\Metadata\VersionPropertyIsReadonly; use Patchlevel\ODM\Tests\Unit\Fixtures\Address; use Patchlevel\ODM\Tests\Unit\Fixtures\Profile; use PHPUnit\Framework\Attributes\CoversClass; @@ -24,6 +27,8 @@ #[CoversClass(ClassIsNotAnDocument::class)] #[CoversClass(NoIdPropertyFound::class)] #[CoversClass(MultipleIdPropertiesFound::class)] +#[CoversClass(MultipleVersionPropertiesFound::class)] +#[CoversClass(VersionPropertyIsReadonly::class)] #[CoversClass(FieldMapping::class)] final class AttributeDocumentMetadataFactoryTest extends TestCase { @@ -195,4 +200,106 @@ public function resolve(ReflectionProperty $reflectionProperty): FieldMapping|nu self::assertEquals($expected, $metadata); } + + public function testMetadataWithoutVersionProperty(): void + { + $factory = new AttributeDocumentMetadataFactory(); + $metadata = $factory->metadata(Profile::class); + + self::assertNull($metadata->versionProperty); + self::assertNull($metadata->versionField()); + } + + public function testMetadataWithVersionProperty(): void + { + $class = new #[Document('rango_documents')] + class ('a') { + public function __construct( + #[Id] + public string $id, + #[Version] + public int $version = 0, + ) { + } + }; + + $factory = new AttributeDocumentMetadataFactory(); + $metadata = $factory->metadata($class::class); + + self::assertSame('version', $metadata->versionProperty); + self::assertSame('version', $metadata->versionField()); + self::assertArrayHasKey('version', $metadata->fields); + } + + public function testMetadataVersionPropertyUsesFieldResolver(): void + { + $class = new #[Document('rango_documents')] + class ('a') { + public function __construct( + #[Id] + public string $id, + #[Version] + public int $version = 0, + ) { + } + }; + + $fieldResolver = new class implements FieldMappingResolver + { + public function resolve(ReflectionProperty $reflectionProperty): FieldMapping|null + { + if ($reflectionProperty->getName() === 'version') { + return new FieldMapping('_version', []); + } + + return null; + } + }; + + $factory = new AttributeDocumentMetadataFactory($fieldResolver); + $metadata = $factory->metadata($class::class); + + self::assertSame('version', $metadata->versionProperty); + self::assertSame('_version', $metadata->versionField()); + } + + public function testMetadataMultipleVersionProperties(): void + { + $class = new #[Document('rango_documents')] + class ('a') { + public function __construct( + #[Id] + public string $id, + #[Version] + public int $version = 0, + #[Version] + public int $revision = 0, + ) { + } + }; + + $factory = new AttributeDocumentMetadataFactory(); + $this->expectException(MultipleVersionPropertiesFound::class); + + $factory->metadata($class::class); + } + + public function testMetadataReadonlyVersionProperty(): void + { + $class = new #[Document('rango_documents')] + class ('a', 0) { + public function __construct( + #[Id] + public string $id, + #[Version] + public readonly int $version, + ) { + } + }; + + $factory = new AttributeDocumentMetadataFactory(); + $this->expectException(VersionPropertyIsReadonly::class); + + $factory->metadata($class::class); + } } diff --git a/tests/Unit/Metadata/DocumentMetadataTest.php b/tests/Unit/Metadata/DocumentMetadataTest.php index 06efaff..e73979e 100644 --- a/tests/Unit/Metadata/DocumentMetadataTest.php +++ b/tests/Unit/Metadata/DocumentMetadataTest.php @@ -437,4 +437,45 @@ className: stdClass::class, self::assertSame([], $metadata->mapSortingToFieldPaths([])); } + + public function testReadAndWriteVersionWithoutVersionProperty(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + ); + + $document = new stdClass(); + + self::assertNull($metadata->readVersion($document)); + + $metadata->writeVersion($document, 5); + + self::assertObjectNotHasProperty('version', $document); + } + + public function testReadAndWriteVersion(): void + { + $document = new class { + public int $version = 3; + }; + + $metadata = new DocumentMetadata( + className: $document::class, + database: null, + collection: 'test', + idProperty: 'id', + versionProperty: 'version', + fields: ['version' => new FieldMapping('version')], + ); + + self::assertSame(3, $metadata->readVersion($document)); + + $metadata->writeVersion($document, 4); + + self::assertSame(4, $document->version); + self::assertSame(4, $metadata->readVersion($document)); + } }