From 53f9b4690f32746f263cc1ca9d862ea22040c3cf Mon Sep 17 00:00:00 2001 From: David Badura Date: Mon, 31 Aug 2026 15:21:52 +0200 Subject: [PATCH] Add single-collection inheritance for documents --- docs/inheritance.md | 119 ++++++++++++ docs/project.json | 3 +- phpstan-baseline.neon | 6 + src/Attribute/DiscriminatorMap.php | 18 ++ src/Hydrator/ClassNotInDiscriminatorMap.php | 26 +++ src/Hydrator/DocumentHydrator.php | 43 +++++ src/Hydrator/UnknownDiscriminatorValue.php | 39 ++++ .../AttributeDocumentMetadataFactory.php | 175 +++++++++++++++--- src/Metadata/DiscriminatorFieldConflict.php | 25 +++ src/Metadata/DocumentMetadata.php | 41 ++++ src/Metadata/InvalidDiscriminatorMap.php | 44 +++++ src/Repository/MongoDBRepository.php | 59 ++++-- src/Repository/RangoRepository.php | 56 ++++-- tests/Integration/Fixtures/Image.php | 13 ++ tests/Integration/Fixtures/Media.php | 24 +++ tests/Integration/Fixtures/Video.php | 13 ++ tests/Integration/RepositoryTestCase.php | 107 +++++++++++ tests/Unit/Fixtures/Image.php | 13 ++ tests/Unit/Fixtures/Media.php | 26 +++ tests/Unit/Fixtures/Video.php | 13 ++ tests/Unit/Hydrator/DocumentHydratorTest.php | 66 +++++++ .../DiscriminatorMetadataFactoryTest.php | 144 ++++++++++++++ tests/Unit/Metadata/DocumentMetadataTest.php | 46 +++++ 23 files changed, 1064 insertions(+), 55 deletions(-) create mode 100644 docs/inheritance.md create mode 100644 src/Attribute/DiscriminatorMap.php create mode 100644 src/Hydrator/ClassNotInDiscriminatorMap.php create mode 100644 src/Hydrator/UnknownDiscriminatorValue.php create mode 100644 src/Metadata/DiscriminatorFieldConflict.php create mode 100644 src/Metadata/InvalidDiscriminatorMap.php create mode 100644 tests/Integration/Fixtures/Image.php create mode 100644 tests/Integration/Fixtures/Media.php create mode 100644 tests/Integration/Fixtures/Video.php create mode 100644 tests/Unit/Fixtures/Image.php create mode 100644 tests/Unit/Fixtures/Media.php create mode 100644 tests/Unit/Fixtures/Video.php create mode 100644 tests/Unit/Hydrator/DocumentHydratorTest.php create mode 100644 tests/Unit/Metadata/DiscriminatorMetadataFactoryTest.php diff --git a/docs/inheritance.md b/docs/inheritance.md new file mode 100644 index 0000000..5ee562b --- /dev/null +++ b/docs/inheritance.md @@ -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) diff --git a/docs/project.json b/docs/project.json index eda35cc..0cebeb5 100644 --- a/docs/project.json +++ b/docs/project.json @@ -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" } ] }, { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6209b4b..d71d325 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -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 diff --git a/src/Attribute/DiscriminatorMap.php b/src/Attribute/DiscriminatorMap.php new file mode 100644 index 0000000..069418f --- /dev/null +++ b/src/Attribute/DiscriminatorMap.php @@ -0,0 +1,18 @@ + $map */ + public function __construct( + public array $map, + public string $field = '_type', + ) { + } +} diff --git a/src/Hydrator/ClassNotInDiscriminatorMap.php b/src/Hydrator/ClassNotInDiscriminatorMap.php new file mode 100644 index 0000000..24c63a1 --- /dev/null +++ b/src/Hydrator/ClassNotInDiscriminatorMap.php @@ -0,0 +1,26 @@ + $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) : '', + )); + } +} diff --git a/src/Hydrator/DocumentHydrator.php b/src/Hydrator/DocumentHydrator.php index e59e077..ef8cae4 100644 --- a/src/Hydrator/DocumentHydrator.php +++ b/src/Hydrator/DocumentHydrator.php @@ -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'; @@ -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 $class */ + $class = $concreteClass; + } + return $this->hydrator->hydrate($class, $data, $context); } @@ -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; } } diff --git a/src/Hydrator/UnknownDiscriminatorValue.php b/src/Hydrator/UnknownDiscriminatorValue.php new file mode 100644 index 0000000..62c2bd8 --- /dev/null +++ b/src/Hydrator/UnknownDiscriminatorValue.php @@ -0,0 +1,39 @@ + $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. ' + . 'Known values: %s.', + $field, + $rootClass, + $value, + $knownValues !== [] ? implode(', ', $knownValues) : '', + )); + } +} diff --git a/src/Metadata/AttributeDocumentMetadataFactory.php b/src/Metadata/AttributeDocumentMetadataFactory.php index 5095918..7635496 100644 --- a/src/Metadata/AttributeDocumentMetadataFactory.php +++ b/src/Metadata/AttributeDocumentMetadataFactory.php @@ -4,6 +4,7 @@ namespace Patchlevel\ODM\Metadata; +use Patchlevel\ODM\Attribute\DiscriminatorMap; use Patchlevel\ODM\Attribute\Document; use Patchlevel\ODM\Attribute\Id; use Patchlevel\ODM\Attribute\Index as IndexAttribute; @@ -11,6 +12,12 @@ use Patchlevel\ODM\Index; use ReflectionClass; +use function array_keys; +use function array_unique; +use function array_values; +use function class_exists; +use function is_a; + final class AttributeDocumentMetadataFactory implements DocumentMetadataFactory { /** @var array, DocumentMetadata> */ @@ -35,54 +42,166 @@ public function metadata(string $className): DocumentMetadata } $reflection = new ReflectionClass($className); + $rootReflection = $this->documentReflection($reflection); - $attributes = $reflection->getAttributes(Document::class); - - if ($attributes === []) { - throw new ClassIsNotAnDocument($className); - } - - $attribute = $attributes[0]->newInstance(); + $attribute = $rootReflection->getAttributes(Document::class)[0]->newInstance(); - $collection = $attribute->collection; - $database = $attribute->database; - $fields = []; $idProperty = $this->getIdProperty($reflection); $versionProperty = $this->getVersionProperty($reflection); - foreach ($reflection->getProperties() as $reflectionProperty) { - $field = $this->fieldResolver?->resolve($reflectionProperty); - - if ($idProperty === $reflectionProperty->getName()) { - $fields[$reflectionProperty->getName()] = new FieldMapping('_id', [], $field?->fieldName); + [$discriminatorField, $discriminatorMap] = $this->discriminator($rootReflection); - continue; - } + $fieldClasses = $discriminatorMap === [] + ? [$className] + : array_values(array_unique([$rootReflection->getName(), ...array_values($discriminatorMap)])); - if ($versionProperty === $reflectionProperty->getName()) { - $fields[$reflectionProperty->getName()] = $field ?? new FieldMapping($reflectionProperty->getName()); - - continue; - } + $discriminatorValues = []; - if (!$field) { + foreach ($discriminatorMap as $value => $mappedClass) { + if (!is_a($mappedClass, $className, true)) { continue; } - $fields[$reflectionProperty->getName()] = $field; + $discriminatorValues[] = $value; } return $this->metadataCache[$className] = new DocumentMetadata( $className, - $database, - $collection, + $attribute->database, + $attribute->collection, $idProperty, - $this->indexes($reflection), - $fields, + $this->indexes($rootReflection), + $this->resolveFields($rootReflection->getName(), $fieldClasses, $idProperty, $versionProperty), $versionProperty, + $discriminatorField, + $discriminatorMap, + $discriminatorValues, ); } + /** + * @param ReflectionClass $reflection + * + * @return ReflectionClass + */ + private function documentReflection(ReflectionClass $reflection): ReflectionClass + { + $current = $reflection; + + while ($current !== false) { + if ($current->getAttributes(Document::class) !== []) { + return $current; + } + + $current = $current->getParentClass(); + } + + throw new ClassIsNotAnDocument($reflection->getName()); + } + + /** + * @param ReflectionClass $rootReflection + * + * @return array{0: string|null, 1: array} + */ + private function discriminator(ReflectionClass $rootReflection): array + { + $attributes = $rootReflection->getAttributes(DiscriminatorMap::class); + + if ($attributes === []) { + return [null, []]; + } + + $discriminator = $attributes[0]->newInstance(); + $rootClass = $rootReflection->getName(); + + if ($discriminator->map === []) { + throw InvalidDiscriminatorMap::emptyMap($rootClass); + } + + foreach ($discriminator->map as $value => $class) { + if (!class_exists($class)) { + throw InvalidDiscriminatorMap::classDoesNotExist($rootClass, $value, $class); + } + + if (!is_a($class, $rootClass, true)) { + throw InvalidDiscriminatorMap::classIsNotASubtype($rootClass, $value, $class); + } + } + + return [$discriminator->field, $discriminator->map]; + } + + /** + * @param class-string $rootClass + * @param list $classNames + * + * @return array + */ + private function resolveFields( + string $rootClass, + array $classNames, + string $idProperty, + string|null $versionProperty, + ): array { + $fields = []; + + foreach ($classNames as $className) { + $reflection = new ReflectionClass($className); + + foreach ($reflection->getProperties() as $reflectionProperty) { + $name = $reflectionProperty->getName(); + $field = $this->fieldResolver?->resolve($reflectionProperty); + + if ($idProperty === $name) { + $mapping = new FieldMapping('_id', [], $field?->fieldName); + } elseif ($versionProperty === $name) { + $mapping = $field ?? new FieldMapping($name); + } elseif ($field !== null) { + $mapping = $field; + } else { + continue; + } + + if (isset($fields[$name])) { + if (!$this->fieldMappingEquals($fields[$name], $mapping)) { + throw new DiscriminatorFieldConflict( + $rootClass, + $name, + $fields[$name]->fieldName, + $mapping->fieldName, + ); + } + + continue; + } + + $fields[$name] = $mapping; + } + } + + return $fields; + } + + private function fieldMappingEquals(FieldMapping $a, FieldMapping $b): bool + { + if ($a->fieldName !== $b->fieldName || $a->fieldNameOverride !== $b->fieldNameOverride) { + return false; + } + + if (array_keys($a->children) !== array_keys($b->children)) { + return false; + } + + foreach ($a->children as $key => $child) { + if (!$this->fieldMappingEquals($child, $b->children[$key])) { + return false; + } + } + + return true; + } + /** * @param ReflectionClass $reflection * diff --git a/src/Metadata/DiscriminatorFieldConflict.php b/src/Metadata/DiscriminatorFieldConflict.php new file mode 100644 index 0000000..7b833e7 --- /dev/null +++ b/src/Metadata/DiscriminatorFieldConflict.php @@ -0,0 +1,25 @@ + $className * @param list $indexes * @param array $fields + * @param array $discriminatorMap + * @param list $discriminatorValues */ public function __construct( public string $className, @@ -35,6 +39,9 @@ public function __construct( public array $indexes = [], public array $fields = [], public string|null $versionProperty = null, + public string|null $discriminatorField = null, + public array $discriminatorMap = [], + public array $discriminatorValues = [], ) { $this->versionReflection = $versionProperty !== null ? new ReflectionProperty($className, $versionProperty) @@ -81,6 +88,40 @@ public function writeVersion(object $document, int $version): void $this->versionReflection?->setValue($document, $version); } + /** @return class-string|null */ + public function classForDiscriminator(string $value): string|null + { + return $this->discriminatorMap[$value] ?? null; + } + + /** @param class-string $class */ + public function discriminatorForClass(string $class): string|null + { + $value = array_search($class, $this->discriminatorMap, true); + + return $value === false ? null : $value; + } + + /** + * Restricts a query to the discriminator values the current class covers. Returns an empty + * array for the hierarchy root (which spans every mapped value) and for documents without + * inheritance. + * + * @return array + */ + public function discriminatorFilter(): array + { + if ($this->discriminatorField === null) { + return []; + } + + if (count($this->discriminatorValues) === count($this->discriminatorMap)) { + return []; + } + + return [$this->discriminatorField => ['$in' => $this->discriminatorValues]]; + } + public function propertyPathToFieldPath(string $propertyPath): string { return $this->propertyPathToFieldPathWithChildren($propertyPath, $this->fields)[0]; diff --git a/src/Metadata/InvalidDiscriminatorMap.php b/src/Metadata/InvalidDiscriminatorMap.php new file mode 100644 index 0000000..c94a1de --- /dev/null +++ b/src/Metadata/InvalidDiscriminatorMap.php @@ -0,0 +1,44 @@ +metadata->className) { + if (!$object instanceof $this->metadata->className) { throw new WrongClass($this->metadata->className, $object::class); } @@ -55,7 +55,7 @@ public function insert(object ...$objects): void } $this->collection->insertMany(array_map(function (object $object): array { - if ($object::class !== $this->metadata->className) { + if (!$object instanceof $this->metadata->className) { throw new WrongClass($this->metadata->className, $object::class); } @@ -124,7 +124,7 @@ public function update(object ...$objects): void */ private function prepareUpdate(object $object): array { - if ($object::class !== $this->metadata->className) { + if (!$object instanceof $this->metadata->className) { throw new WrongClass($this->metadata->className, $object::class); } @@ -132,14 +132,18 @@ private function prepareUpdate(object $object): array $currentVersion = $this->metadata->readVersion($object); if ($currentVersion === null) { - return ['filter' => ['_id' => $data['_id']], 'set' => $data, 'newVersion' => null]; + return [ + 'filter' => $this->withDiscriminator(['_id' => $data['_id']]), + 'set' => $data, + 'newVersion' => null, + ]; } $versionField = (string)$this->metadata->versionField(); $data[$versionField] = $currentVersion + 1; return [ - 'filter' => ['_id' => $data['_id'], $versionField => $currentVersion], + 'filter' => $this->withDiscriminator(['_id' => $data['_id'], $versionField => $currentVersion]), 'set' => $data, 'newVersion' => $currentVersion + 1, ]; @@ -169,7 +173,7 @@ public function get(string $id): object /** @return T|null */ public function find(string $id): object|null { - $data = $this->collection->findOne(['_id' => $id], [ + $data = $this->collection->findOne($this->withDiscriminator(['_id' => $id]), [ 'typeMap' => ['root' => 'array', 'document' => 'array'], ]); @@ -183,18 +187,18 @@ public function find(string $id): object|null public function remove(string ...$id): void { if (count($id) === 1) { - $this->collection->deleteOne(['_id' => $id[0]]); + $this->collection->deleteOne($this->withDiscriminator(['_id' => $id[0]])); return; } - $this->collection->deleteMany(['_id' => ['$in' => $id]]); + $this->collection->deleteMany($this->withDiscriminator(['_id' => ['$in' => $id]])); } /** @return iterable */ public function findAll(): iterable { - $cursor = $this->collection->find([], [ + $cursor = $this->collection->find($this->withDiscriminator([]), [ 'typeMap' => ['root' => 'array', 'document' => 'array'], ]); @@ -231,7 +235,10 @@ public function findBy( $options['typeMap'] = ['root' => 'array', 'document' => 'array']; - $cursor = $this->collection->find($this->metadata->mapFilterToFieldPaths($filter), $options); + $cursor = $this->collection->find( + $this->withDiscriminator($this->metadata->mapFilterToFieldPaths($filter)), + $options, + ); foreach ($cursor as $document) { yield $this->hydrator->hydrate( @@ -258,7 +265,10 @@ public function findOneBy(array $filter = [], array|null $orderBy = null): objec $options['sort'] = $this->metadata->mapSortingToFieldPaths($orderBy); } - $data = $this->collection->findOne($this->metadata->mapFilterToFieldPaths($filter), $options); + $data = $this->collection->findOne( + $this->withDiscriminator($this->metadata->mapFilterToFieldPaths($filter)), + $options, + ); if ($data === null) { return null; @@ -269,12 +279,35 @@ public function findOneBy(array $filter = [], array|null $orderBy = null): objec public function count(): int { - return $this->collection->countDocuments(); + return $this->collection->countDocuments($this->metadata->discriminatorFilter()); } public function has(string $id): bool { - return $this->collection->countDocuments(['_id' => $id]) > 0; + return $this->collection->countDocuments($this->withDiscriminator(['_id' => $id])) > 0; + } + + /** + * Restricts a query to the discriminator values handled by this repository. Documents without + * inheritance and repositories for the hierarchy root are left untouched. + * + * @param array $filter + * + * @return array + */ + private function withDiscriminator(array $filter): array + { + $discriminator = $this->metadata->discriminatorFilter(); + + if ($discriminator === []) { + return $filter; + } + + if ($filter === []) { + return $discriminator; + } + + return ['$and' => [$discriminator, $filter]]; } public function database(): Database diff --git a/src/Repository/RangoRepository.php b/src/Repository/RangoRepository.php index 0948b4e..233ef7d 100644 --- a/src/Repository/RangoRepository.php +++ b/src/Repository/RangoRepository.php @@ -43,7 +43,7 @@ public function insert(object ...$objects): void if (count($objects) === 1) { $object = $objects[0]; - if ($object::class !== $this->metadata->className) { + if (!$object instanceof $this->metadata->className) { throw new WrongClass($this->metadata->className, $object::class); } @@ -57,7 +57,7 @@ public function insert(object ...$objects): void $this->collection->insertMany( array_map( function (object $object): array { - if ($object::class !== $this->metadata->className) { + if (!$object instanceof $this->metadata->className) { throw new WrongClass($this->metadata->className, $object::class); } @@ -129,7 +129,7 @@ public function update(object ...$objects): void */ private function prepareUpdate(object $object): array { - if ($object::class !== $this->metadata->className) { + if (!$object instanceof $this->metadata->className) { throw new WrongClass($this->metadata->className, $object::class); } @@ -137,14 +137,18 @@ private function prepareUpdate(object $object): array $currentVersion = $this->metadata->readVersion($object); if ($currentVersion === null) { - return ['filter' => ['_id' => $data['_id']], 'set' => $data, 'newVersion' => null]; + return [ + 'filter' => $this->withDiscriminator(['_id' => $data['_id']]), + 'set' => $data, + 'newVersion' => null, + ]; } $versionField = (string)$this->metadata->versionField(); $data[$versionField] = $currentVersion + 1; return [ - 'filter' => ['_id' => $data['_id'], $versionField => $currentVersion], + 'filter' => $this->withDiscriminator(['_id' => $data['_id'], $versionField => $currentVersion]), 'set' => $data, 'newVersion' => $currentVersion + 1, ]; @@ -174,7 +178,7 @@ public function get(string $id): object /** @return T|null */ public function find(string $id): object|null { - $data = $this->collection->findOne(['_id' => $id]); + $data = $this->collection->findOne($this->withDiscriminator(['_id' => $id])); if ($data === null) { return null; @@ -186,18 +190,18 @@ public function find(string $id): object|null public function remove(string ...$id): void { if (count($id) === 1) { - $this->collection->deleteOne(['_id' => $id[0]]); + $this->collection->deleteOne($this->withDiscriminator(['_id' => $id[0]])); return; } - $this->collection->deleteMany(['_id' => ['$in' => $id]]); + $this->collection->deleteMany($this->withDiscriminator(['_id' => ['$in' => $id]])); } /** @return iterable */ public function findAll(): iterable { - $cursor = $this->collection->find(); + $cursor = $this->collection->find($this->withDiscriminator([])); foreach ($cursor as $document) { yield $this->hydrator->hydrate($this->metadata->className, $document); @@ -231,7 +235,7 @@ public function findBy( } $cursor = $this->collection->find( - $this->metadata->mapFilterToFieldPaths($filter), + $this->withDiscriminator($this->metadata->mapFilterToFieldPaths($filter)), $options, ); @@ -254,7 +258,10 @@ public function findOneBy(array $filter = [], array|null $orderBy = null): objec $options['sort'] = $this->metadata->mapSortingToFieldPaths($orderBy); } - $data = $this->collection->findOne($this->metadata->mapFilterToFieldPaths($filter), $options); + $data = $this->collection->findOne( + $this->withDiscriminator($this->metadata->mapFilterToFieldPaths($filter)), + $options, + ); if ($data === null) { return null; @@ -265,12 +272,35 @@ public function findOneBy(array $filter = [], array|null $orderBy = null): objec public function count(): int { - return $this->collection->countDocuments(); + return $this->collection->countDocuments($this->metadata->discriminatorFilter()); } public function has(string $id): bool { - return $this->collection->countDocuments(['_id' => $id]) > 0; + return $this->collection->countDocuments($this->withDiscriminator(['_id' => $id])) > 0; + } + + /** + * Restricts a query to the discriminator values handled by this repository. Documents without + * inheritance and repositories for the hierarchy root are left untouched. + * + * @param array $filter + * + * @return array + */ + private function withDiscriminator(array $filter): array + { + $discriminator = $this->metadata->discriminatorFilter(); + + if ($discriminator === []) { + return $filter; + } + + if ($filter === []) { + return $discriminator; + } + + return ['$and' => [$discriminator, $filter]]; } public function database(): Database diff --git a/tests/Integration/Fixtures/Image.php b/tests/Integration/Fixtures/Image.php new file mode 100644 index 0000000..cbb7ff5 --- /dev/null +++ b/tests/Integration/Fixtures/Image.php @@ -0,0 +1,13 @@ + Image::class, + 'video' => Video::class, +])] +abstract readonly class Media +{ + public function __construct( + #[Id] + public string $id, + public string $title, + ) { + } +} diff --git a/tests/Integration/Fixtures/Video.php b/tests/Integration/Fixtures/Video.php new file mode 100644 index 0000000..9a010b7 --- /dev/null +++ b/tests/Integration/Fixtures/Video.php @@ -0,0 +1,13 @@ +update($first, $second); } + + public function testInheritancePersistsAndLoadsPolymorphically(): void + { + $repository = $this->repositoryManager->get(Media::class); + + $repository->insert( + new Image('m-1', 'Picture', 800), + new Video('m-2', 'Clip', 60), + ); + + $image = $repository->find('m-1'); + $video = $repository->find('m-2'); + + self::assertInstanceOf(Image::class, $image); + self::assertSame(800, $image->width); + self::assertInstanceOf(Video::class, $video); + self::assertSame(60, $video->duration); + + $raw = $repository->collection()->findOne(['_id' => 'm-1']); + + self::assertNotNull($raw); + self::assertSame('image', $raw['_type']); + } + + public function testInheritanceUpdateKeepsTheConcreteType(): void + { + $repository = $this->repositoryManager->get(Media::class); + $repository->insert(new Image('m-1', 'Picture', 800)); + + $repository->update(new Image('m-1', 'Better Picture', 1600)); + + $image = $repository->find('m-1'); + + self::assertInstanceOf(Image::class, $image); + self::assertSame('Better Picture', $image->title); + self::assertSame(1600, $image->width); + } + + public function testInheritanceLeafRepositoryOnlySeesItsOwnType(): void + { + $this->repositoryManager->get(Media::class)->insert( + new Image('m-1', 'Picture', 800), + new Video('m-2', 'Clip', 60), + new Image('m-3', 'Another', 1024), + ); + + $imageRepository = $this->repositoryManager->get(Image::class); + + self::assertSame(2, $imageRepository->count()); + self::assertTrue($imageRepository->has('m-1')); + self::assertFalse($imageRepository->has('m-2')); + self::assertNull($imageRepository->find('m-2')); + + $ids = array_map( + static fn (Image $image): string => $image->id, + iterator_to_array($imageRepository->findAll(), false), + ); + + self::assertSame(['m-1', 'm-3'], $ids); + } + + public function testInheritanceLeafRepositoryRejectsAForeignType(): void + { + $imageRepository = $this->repositoryManager->get(Image::class); + + $this->expectException(WrongClass::class); + + $imageRepository->insert(new Video('m-1', 'Clip', 60)); + } + + public function testInheritanceFiltersByInheritedAndSubclassFields(): void + { + $repository = $this->repositoryManager->get(Media::class); + + $repository->insert( + new Image('m-1', 'Picture', 800), + new Video('m-2', 'Picture', 60), + ); + + $byTitle = iterator_to_array($repository->findBy(['title' => 'Picture']), false); + + self::assertCount(2, $byTitle); + + $byWidth = $repository->findOneBy(['width' => 800]); + + self::assertInstanceOf(Image::class, $byWidth); + self::assertSame('m-1', $byWidth->id); + } + + public function testInheritanceRemoveViaLeafRepositoryIgnoresOtherTypes(): void + { + $this->repositoryManager->get(Media::class)->insert( + new Image('m-1', 'Picture', 800), + new Video('m-2', 'Clip', 60), + ); + + $this->repositoryManager->get(Image::class)->remove('m-1', 'm-2'); + + $mediaRepository = $this->repositoryManager->get(Media::class); + + self::assertFalse($mediaRepository->has('m-1')); + self::assertTrue($mediaRepository->has('m-2')); + } } diff --git a/tests/Unit/Fixtures/Image.php b/tests/Unit/Fixtures/Image.php new file mode 100644 index 0000000..a2ae465 --- /dev/null +++ b/tests/Unit/Fixtures/Image.php @@ -0,0 +1,13 @@ + 'asc'])] +#[DiscriminatorMap([ + 'image' => Image::class, + 'video' => Video::class, +])] +abstract readonly class Media +{ + public function __construct( + #[Id] + public string $id, + public string $title, + ) { + } +} diff --git a/tests/Unit/Fixtures/Video.php b/tests/Unit/Fixtures/Video.php new file mode 100644 index 0000000..c50b260 --- /dev/null +++ b/tests/Unit/Fixtures/Video.php @@ -0,0 +1,13 @@ +metadata(Media::class); + + $this->hydrator = new DocumentHydrator($stackHydrator, $metadata); + } + + public function testExtractWritesTheDiscriminatorValue(): void + { + $data = $this->hydrator->extract(new Image('m-1', 'Picture', 800)); + + self::assertSame('image', $data['_type']); + self::assertSame('m-1', $data['_id']); + self::assertSame(800, $data['width']); + } + + public function testHydrateResolvesTheConcreteClassFromTheDiscriminator(): void + { + $image = $this->hydrator->hydrate(Media::class, $this->hydrator->extract(new Image('m-1', 'Picture', 800))); + $video = $this->hydrator->hydrate(Media::class, $this->hydrator->extract(new Video('m-2', 'Clip', 60))); + + self::assertInstanceOf(Image::class, $image); + self::assertSame(800, $image->width); + self::assertInstanceOf(Video::class, $video); + self::assertSame(60, $video->duration); + } + + public function testHydrateWithoutADiscriminatorValueFails(): void + { + $this->expectException(UnknownDiscriminatorValue::class); + + $this->hydrator->hydrate(Media::class, ['_id' => 'm-1', 'title' => 'Picture', 'width' => 800]); + } + + public function testHydrateWithAnUnmappedDiscriminatorValueFails(): void + { + $this->expectException(UnknownDiscriminatorValue::class); + + $this->hydrator->hydrate(Media::class, ['_id' => 'm-1', 'title' => 'Picture', '_type' => 'audio']); + } +} diff --git a/tests/Unit/Metadata/DiscriminatorMetadataFactoryTest.php b/tests/Unit/Metadata/DiscriminatorMetadataFactoryTest.php new file mode 100644 index 0000000..1f8f9bc --- /dev/null +++ b/tests/Unit/Metadata/DiscriminatorMetadataFactoryTest.php @@ -0,0 +1,144 @@ +metadata(Media::class); + + self::assertSame(Media::class, $metadata->className); + self::assertSame('media', $metadata->collection); + self::assertSame('id', $metadata->idProperty); + self::assertSame('_type', $metadata->discriminatorField); + self::assertSame(['image' => Image::class, 'video' => Video::class], $metadata->discriminatorMap); + self::assertSame(['image', 'video'], $metadata->discriminatorValues); + self::assertSame([], $metadata->discriminatorFilter()); + } + + public function testLeafMetadataInheritsCollectionAndRestrictsType(): void + { + $factory = new AttributeDocumentMetadataFactory(); + $metadata = $factory->metadata(Image::class); + + self::assertSame(Image::class, $metadata->className); + self::assertSame('media', $metadata->collection); + self::assertSame('id', $metadata->idProperty); + self::assertSame(['image'], $metadata->discriminatorValues); + self::assertSame(['_type' => ['$in' => ['image']]], $metadata->discriminatorFilter()); + } + + public function testIndexesComeFromTheRoot(): void + { + $factory = new AttributeDocumentMetadataFactory(); + $metadata = $factory->metadata(Video::class); + + self::assertEquals([new Index('by_type', ['id' => 'asc'])], $metadata->indexes); + } + + public function testFieldsAreUnionedAcrossTheHierarchy(): void + { + $factory = new AttributeDocumentMetadataFactory($this->fieldResolver()); + $metadata = $factory->metadata(Image::class); + + self::assertSame(['id', 'title', 'width', 'format', 'duration'], array_keys($metadata->fields)); + } + + public function testConflictingFieldMappingAcrossSubclasses(): void + { + $resolver = new class implements FieldMappingResolver { + public function resolve(ReflectionProperty $reflectionProperty): FieldMapping + { + $name = $reflectionProperty->getName(); + + if ($name === 'format' && $reflectionProperty->getDeclaringClass()->getShortName() === 'Video') { + return new FieldMapping('video_format'); + } + + return new FieldMapping($name); + } + }; + + $factory = new AttributeDocumentMetadataFactory($resolver); + + $this->expectException(DiscriminatorFieldConflict::class); + + $factory->metadata(Media::class); + } + + public function testMapEntryThatIsNotASubtype(): void + { + $class = new #[Document('things')] + #[DiscriminatorMap(['weird' => Address::class])] + class ('1') { + public function __construct( + #[Id] + public string $id, + ) { + } + }; + + $factory = new AttributeDocumentMetadataFactory(); + + $this->expectException(InvalidDiscriminatorMap::class); + + $factory->metadata($class::class); + } + + public function testEmptyMap(): void + { + $class = new #[Document('things')] + #[DiscriminatorMap([])] + class ('1') { + public function __construct( + #[Id] + public string $id, + ) { + } + }; + + $factory = new AttributeDocumentMetadataFactory(); + + $this->expectException(InvalidDiscriminatorMap::class); + + $factory->metadata($class::class); + } + + private function fieldResolver(): FieldMappingResolver + { + return new class implements FieldMappingResolver { + public function resolve(ReflectionProperty $reflectionProperty): FieldMapping + { + return new FieldMapping($reflectionProperty->getName()); + } + }; + } +} diff --git a/tests/Unit/Metadata/DocumentMetadataTest.php b/tests/Unit/Metadata/DocumentMetadataTest.php index e73979e..2c59b75 100644 --- a/tests/Unit/Metadata/DocumentMetadataTest.php +++ b/tests/Unit/Metadata/DocumentMetadataTest.php @@ -14,6 +14,52 @@ #[CoversClass(DocumentMetadata::class)] final class DocumentMetadataTest extends TestCase { + public function testDiscriminatorLookupsWithoutInheritance(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + ); + + self::assertNull($metadata->classForDiscriminator('image')); + self::assertNull($metadata->discriminatorForClass(stdClass::class)); + self::assertSame([], $metadata->discriminatorFilter()); + } + + public function testDiscriminatorLookupsResolveBothDirections(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + discriminatorField: '_type', + discriminatorMap: ['thing' => stdClass::class], + discriminatorValues: ['thing'], + ); + + self::assertSame(stdClass::class, $metadata->classForDiscriminator('thing')); + self::assertSame('thing', $metadata->discriminatorForClass(stdClass::class)); + self::assertNull($metadata->classForDiscriminator('other')); + } + + public function testDiscriminatorFilterRestrictsAPartialHierarchy(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + discriminatorField: '_type', + discriminatorMap: ['a' => stdClass::class, 'b' => stdClass::class], + discriminatorValues: ['a'], + ); + + self::assertSame(['_type' => ['$in' => ['a']]], $metadata->discriminatorFilter()); + } + public function testPropertyPathToFieldPathWithoutMappingThrowsException(): void { $metadata = new DocumentMetadata(