Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
47 changes: 47 additions & 0 deletions docs/documents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions docs/repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
22 changes: 20 additions & 2 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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\<array\<string, mixed\>\> but class MongoDB\\Collection is not generic\.$#'
identifier: generics.notGeneric
Expand All @@ -73,7 +79,7 @@ parameters:
path: src/Repository/MongoDBRepository.php

-
message: '#^Parameter \#1 \$operations of method MongoDB\\Collection\:\:bulkWrite\(\) expects list\<array\{deleteMany\: array\{0\: array\|object, 1\?\: array\}\}\|array\{deleteOne\: array\{0\: array\|object, 1\?\: array\}\}\|array\{insertOne\: array\{array\|object\}\}\|array\{replaceOne\: array\{0\: array\|object, 1\: array\|object, 2\?\: array\}\}\|array\{updateMany\: array\{0\: array\|object, 1\: array\|object, 2\?\: array\}\}\|array\{updateOne\: array\{0\: array\|object, 1\: array\|object, 2\?\: array\}\}\>, non\-empty\-array\<int\|string, array\{updateOne\: array\{array\{_id\: mixed\}, array\{''\$set''\: array\<string, mixed\>\}\}\}\> given\.$#'
message: '#^Parameter \#1 \$operations of method MongoDB\\Collection\:\:bulkWrite\(\) expects list\<array\{deleteMany\: array\{0\: array\|object, 1\?\: array\}\}\|array\{deleteOne\: array\{0\: array\|object, 1\?\: array\}\}\|array\{insertOne\: array\{array\|object\}\}\|array\{replaceOne\: array\{0\: array\|object, 1\: array\|object, 2\?\: array\}\}\|array\{updateMany\: array\{0\: array\|object, 1\: array\|object, 2\?\: array\}\}\|array\{updateOne\: array\{0\: array\|object, 1\: array\|object, 2\?\: array\}\}\>, non\-empty\-array\<int\|string, array\{updateOne\: array\{array\<string, mixed\>, array\{''\$set''\: array\<string, mixed\>\}\}\}\> given\.$#'
identifier: argument.type
count: 1
path: src/Repository/MongoDBRepository.php
Expand Down Expand Up @@ -102,14 +108,20 @@ 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\<array\<string, mixed\>\>\:\:insertMany\(\) expects list\<array\<string, mixed\>\>, array\<int\|string, array\<string, mixed\>\> given\.$#'
identifier: argument.type
count: 1
path: src/Repository/RangoRepository.php

-
message: '#^Parameter \#1 \$operations of method Patchlevel\\Rango\\Collection\<array\<string, mixed\>\>\:\:bulkWrite\(\) expects list\<array\<string, list\<array\<string, mixed\>\>\>\>, non\-empty\-array\<int\|string, array\{updateOne\: array\{array\{_id\: mixed\}, array\{''\$set''\: array\<string, mixed\>\}\}\}\> given\.$#'
message: '#^Parameter \#1 \$operations of method Patchlevel\\Rango\\Collection\<array\<string, mixed\>\>\:\:bulkWrite\(\) expects list\<array\<string, list\<array\<string, mixed\>\>\>\>, non\-empty\-array\<int\|string, array\{updateOne\: array\{array\<string, mixed\>, array\{''\$set''\: array\<string, mixed\>\}\}\}\> given\.$#'
identifier: argument.type
count: 1
path: src/Repository/RangoRepository.php
Expand Down Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions src/Attribute/Version.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Patchlevel\ODM\Attribute;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Version
{
}
35 changes: 35 additions & 0 deletions src/Metadata/AttributeDocumentMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Patchlevel\ODM\Attribute\Document;
use Patchlevel\ODM\Attribute\Id;
use Patchlevel\ODM\Attribute\Index as IndexAttribute;
use Patchlevel\ODM\Attribute\Version;
use Patchlevel\ODM\Index;
use ReflectionClass;

Expand Down Expand Up @@ -47,6 +48,7 @@
$database = $attribute->database;
$fields = [];
$idProperty = $this->getIdProperty($reflection);
$versionProperty = $this->getVersionProperty($reflection);

foreach ($reflection->getProperties() as $reflectionProperty) {
$field = $this->fieldResolver?->resolve($reflectionProperty);
Expand All @@ -57,6 +59,12 @@
continue;
}

if ($versionProperty === $reflectionProperty->getName()) {
$fields[$reflectionProperty->getName()] = $field ?? new FieldMapping($reflectionProperty->getName());

continue;

Check warning on line 65 in src/Metadata/AttributeDocumentMetadataFactory.php

View workflow job for this annotation

GitHub Actions / Mutation tests (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "Continue_": @@ @@ if ($versionProperty === $reflectionProperty->getName()) { $fields[$reflectionProperty->getName()] = $field ?? new FieldMapping($reflectionProperty->getName()); - continue; + break; } if (!$field) {

Check warning on line 65 in src/Metadata/AttributeDocumentMetadataFactory.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "Continue_": @@ @@ if ($versionProperty === $reflectionProperty->getName()) { $fields[$reflectionProperty->getName()] = $field ?? new FieldMapping($reflectionProperty->getName()); - continue; + break; } if (!$field) {
}

if (!$field) {
continue;
}
Expand All @@ -71,6 +79,7 @@
$idProperty,
$this->indexes($reflection),
$fields,
$versionProperty,
);
}

Expand Down Expand Up @@ -123,4 +132,30 @@

return $idProperty;
}

/** @param ReflectionClass<object> $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;
}
}
49 changes: 49 additions & 0 deletions src/Metadata/DocumentMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> $className
* @param list<Index> $indexes
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/Metadata/MultipleVersionPropertiesFound.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Patchlevel\ODM\Metadata;

use RuntimeException;

use function sprintf;

class MultipleVersionPropertiesFound extends RuntimeException
{
/** @param class-string $className */
public function __construct(string $className)
{
parent::__construct(sprintf('Multiple version properties found in class %s.', $className));
}
}
23 changes: 23 additions & 0 deletions src/Metadata/VersionPropertyIsReadonly.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Patchlevel\ODM\Metadata;

use RuntimeException;

use function sprintf;

class VersionPropertyIsReadonly extends RuntimeException
{
/** @param class-string $className */
public function __construct(string $className, string $propertyName)
{
parent::__construct(sprintf(
'Version property "%s" in class %s must not be readonly, because the repository writes the '

Check warning on line 17 in src/Metadata/VersionPropertyIsReadonly.php

View workflow job for this annotation

GitHub Actions / Mutation tests (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "ConcatOperandRemoval": @@ @@ public function __construct(string $className, string $propertyName) { parent::__construct(sprintf( - 'Version property "%s" in class %s must not be readonly, because the repository writes the ' - . 'incremented version back onto the document after a successful update.', + 'Version property "%s" in class %s must not be readonly, because the repository writes the ', $propertyName, $className, ));

Check warning on line 17 in src/Metadata/VersionPropertyIsReadonly.php

View workflow job for this annotation

GitHub Actions / Mutation tests (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "Concat": @@ @@ public function __construct(string $className, string $propertyName) { parent::__construct(sprintf( - 'Version property "%s" in class %s must not be readonly, because the repository writes the ' - . 'incremented version back onto the document after a successful update.', + 'incremented version back onto the document after a successful update.' . 'Version property "%s" in class %s must not be readonly, because the repository writes the ', $propertyName, $className, ));

Check warning on line 17 in src/Metadata/VersionPropertyIsReadonly.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "ConcatOperandRemoval": @@ @@ public function __construct(string $className, string $propertyName) { parent::__construct(sprintf( - 'Version property "%s" in class %s must not be readonly, because the repository writes the ' - . 'incremented version back onto the document after a successful update.', + 'Version property "%s" in class %s must not be readonly, because the repository writes the ', $propertyName, $className, ));

Check warning on line 17 in src/Metadata/VersionPropertyIsReadonly.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "Concat": @@ @@ public function __construct(string $className, string $propertyName) { parent::__construct(sprintf( - 'Version property "%s" in class %s must not be readonly, because the repository writes the ' - . 'incremented version back onto the document after a successful update.', + 'incremented version back onto the document after a successful update.' . 'Version property "%s" in class %s must not be readonly, because the repository writes the ', $propertyName, $className, ));
. 'incremented version back onto the document after a successful update.',
$propertyName,
$className,
));
}
}
Loading
Loading