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
119 changes: 119 additions & 0 deletions docs/inheritance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Inheritance

Sometimes several document types are variations of the same thing: an `Image` and a `Video` are both
`Media`. Patchlevel ODM can store a whole class hierarchy in a single collection and reconstruct the
right concrete class on load. This is single-collection inheritance, driven by a discriminator field.

## Defining a hierarchy

Put `#[Document]` and `#[DiscriminatorMap]` on the root class and let the concrete classes extend it.
The map assigns a short, stable string to every concrete class. The subclasses inherit the collection
and the `#[Id]` property from the root, so they do not repeat the `#[Document]` attribute.

```php
use Patchlevel\ODM\Attribute\DiscriminatorMap;
use Patchlevel\ODM\Attribute\Document;
use Patchlevel\ODM\Attribute\Id;

#[Document('media')]
#[DiscriminatorMap([
'image' => Image::class,
'video' => Video::class,
])]
abstract class Media
{
public function __construct(
#[Id]
public readonly string $id,
public string $title,
) {
}
}

final class Image extends Media
{
public function __construct(string $id, string $title, public int $width)
{
parent::__construct($id, $title);
}
}

final class Video extends Media
{
public function __construct(string $id, string $title, public int $duration)
{
parent::__construct($id, $title);
}
}
```

Every stored document gets an extra field, `_type`, holding the discriminator value (`image` or
`video`). Change the field name with the second argument if `_type` clashes with a property:
`#[DiscriminatorMap([...], field: '_kind')]`.

## Working with the root repository

The repository for the root class is polymorphic. It accepts any subclass on write and returns the
concrete class on read.

```php
$repository = $manager->get(Media::class);

$repository->insert(
new Image('m-1', 'Landscape', 1920),
new Video('m-2', 'Trailer', 90),
);

$repository->find('m-1'); // Image
$repository->find('m-2'); // Video

foreach ($repository->findAll() as $media) {
// Image and Video mixed together
}
```

You can filter by properties declared on the root and by properties that only exist on a subclass.
Documents that do not have the field simply do not match.

```php
$repository->findBy(['title' => 'Landscape']); // inherited field
$repository->findOneBy(['width' => 1920]); // Image-only field
```

## Working with a subclass repository

The repository for a concrete class is scoped to that type. Every query, count and delete is
restricted to its discriminator value, and writes reject documents of a sibling type.

```php
$images = $manager->get(Image::class);

$images->count(); // only images
$images->findAll(); // only images
$images->find('m-2'); // null, m-2 is a video

$images->insert(new Video('m-3', 'Clip', 30)); // throws WrongClass
```

## Constraints

* All classes in the hierarchy live in one collection and share a single `_id` space.
* A property that appears on more than one subclass must map to the same stored field name in each of
them. Otherwise the metadata factory throws `DiscriminatorFieldConflict`.
* `#[Index]` attributes are read from the root class, because the index belongs to the shared
collection. Declare hierarchy-wide indexes there.
* Discriminator values are stored in every document, so keep them short and never change one once data
exists.

:::warning
The discriminator map is validated when the metadata is built. Mapping a value to a class that does
not extend the root, or leaving the map empty, throws `InvalidDiscriminatorMap`. Loading a document
whose `_type` is missing or not in the map throws `UnknownDiscriminatorValue`, and persisting a
subclass that was left out of the map throws `ClassNotInDiscriminatorMap`.
:::

## Learn more

* [How to store and load documents](repository.md)
* [How to control field names and normalization](field-mapping.md)
* [How indexes are declared and synchronized](documents.md#indexes)
3 changes: 2 additions & 1 deletion docs/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"title": "Basics",
"subEntries": [
{ "title": "Documents", "file": "documents.md" },
{ "title": "Repository", "file": "repository.md" }
{ "title": "Repository", "file": "repository.md" },
{ "title": "Inheritance", "file": "inheritance.md" }
]
},
{
Expand Down
6 changes: 6 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ parameters:
count: 1
path: tests/Integration/RepositoryTestCase.php

-
message: '#^Cannot access offset ''_type'' on array\|object\.$#'
identifier: offsetAccess.nonOffsetAccessible
count: 1
path: tests/Integration/RepositoryTestCase.php

-
message: '#^Cannot access offset ''name'' on array\|object\.$#'
identifier: offsetAccess.nonOffsetAccessible
Expand Down
18 changes: 18 additions & 0 deletions src/Attribute/DiscriminatorMap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Patchlevel\ODM\Attribute;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
final readonly class DiscriminatorMap
{
/** @param array<string, class-string> $map */
public function __construct(
public array $map,
public string $field = '_type',
) {
}
}
26 changes: 26 additions & 0 deletions src/Hydrator/ClassNotInDiscriminatorMap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Patchlevel\ODM\Hydrator;

use RuntimeException;

use function implode;
use function sprintf;

final class ClassNotInDiscriminatorMap extends RuntimeException
{
/**
* @param class-string $class
* @param list<class-string> $knownClasses
*/
public function __construct(string $class, array $knownClasses)
{
parent::__construct(sprintf(
'Class "%s" is not part of the discriminator map. Mapped classes: %s.',
$class,
$knownClasses !== [] ? implode(', ', $knownClasses) : '<none>',
));
}
}
43 changes: 43 additions & 0 deletions src/Hydrator/DocumentHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
use Patchlevel\Hydrator\HydratorWithContext;
use Patchlevel\ODM\Metadata\DocumentMetadata;

use function array_keys;
use function array_values;
use function is_string;

final class DocumentHydrator implements HydratorWithContext
{
private const ID_FIELD_NAME = '_id';
Expand Down Expand Up @@ -39,6 +43,30 @@ public function hydrate(string $class, array $data, array $context = []): object
unset($data[self::ID_FIELD_NAME]);
}

$discriminatorField = $this->documentMetadata->discriminatorField;

if ($discriminatorField !== null) {
$value = $data[$discriminatorField] ?? null;

if (!is_string($value)) {
throw UnknownDiscriminatorValue::missing($this->documentMetadata->className, $discriminatorField);
}

$concreteClass = $this->documentMetadata->classForDiscriminator($value);

if ($concreteClass === null) {
throw UnknownDiscriminatorValue::notMapped(
$this->documentMetadata->className,
$discriminatorField,
$value,
array_keys($this->documentMetadata->discriminatorMap),
);
}

/** @var class-string<T> $class */
$class = $concreteClass;
}

return $this->hydrator->hydrate($class, $data, $context);
}

Expand All @@ -56,6 +84,21 @@ public function extract(object $object, array $context = []): array
unset($data[$this->fieldNameOverride]);
}

$discriminatorField = $this->documentMetadata->discriminatorField;

if ($discriminatorField !== null) {
$value = $this->documentMetadata->discriminatorForClass($object::class);

if ($value === null) {
throw new ClassNotInDiscriminatorMap(
$object::class,
array_values($this->documentMetadata->discriminatorMap),
);
}

$data[$discriminatorField] = $value;
}

return $data;
}
}
39 changes: 39 additions & 0 deletions src/Hydrator/UnknownDiscriminatorValue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Patchlevel\ODM\Hydrator;

use RuntimeException;

use function implode;
use function sprintf;

final class UnknownDiscriminatorValue extends RuntimeException
{
/** @param class-string $rootClass */
public static function missing(string $rootClass, string $field): self
{
return new self(sprintf(
'The document for "%s" has no value in its discriminator field "%s".',
$rootClass,
$field,
));
}

/**
* @param class-string $rootClass
* @param list<string> $knownValues
*/
public static function notMapped(string $rootClass, string $field, string $value, array $knownValues): self
{
return new self(sprintf(
'The discriminator field "%s" of "%s" holds value "%s", which is not part of its discriminator map. '

Check warning on line 31 in src/Hydrator/UnknownDiscriminatorValue.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "Concat": @@ @@ public static function notMapped(string $rootClass, string $field, string $value, array $knownValues): self { return new self(sprintf( - 'The discriminator field "%s" of "%s" holds value "%s", which is not part of its discriminator map. ' - . 'Known values: %s.', + 'Known values: %s.' . 'The discriminator field "%s" of "%s" holds value "%s", which is not part of its discriminator map. ', $field, $rootClass, $value,

Check warning on line 31 in src/Hydrator/UnknownDiscriminatorValue.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "Concat": @@ @@ public static function notMapped(string $rootClass, string $field, string $value, array $knownValues): self { return new self(sprintf( - 'The discriminator field "%s" of "%s" holds value "%s", which is not part of its discriminator map. ' - . 'Known values: %s.', + 'Known values: %s.' . 'The discriminator field "%s" of "%s" holds value "%s", which is not part of its discriminator map. ', $field, $rootClass, $value,
. 'Known values: %s.',
$field,
$rootClass,
$value,
$knownValues !== [] ? implode(', ', $knownValues) : '<none>',

Check warning on line 36 in src/Hydrator/UnknownDiscriminatorValue.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "Ternary": @@ @@ $field, $rootClass, $value, - $knownValues !== [] ? implode(', ', $knownValues) : '<none>', + $knownValues !== [] ? '<none>' : implode(', ', $knownValues), )); } }

Check warning on line 36 in src/Hydrator/UnknownDiscriminatorValue.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "Ternary": @@ @@ $field, $rootClass, $value, - $knownValues !== [] ? implode(', ', $knownValues) : '<none>', + $knownValues !== [] ? '<none>' : implode(', ', $knownValues), )); } }
));
}
}
Loading
Loading