diff --git a/.gitignore b/.gitignore index b0f6ffa..f69ee25 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ phpstan.neon infection.log infection.html .phpbench/ +.aider* diff --git a/composer.json b/composer.json index ce6fbd1..4d46762 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ ], "require": { "php": "~8.3.0 || ~8.4.0 || ~8.5.0" , - "patchlevel/hydrator": "^1.18.0" + "patchlevel/hydrator": "dev-add-methods-on-normalizers as 1.23.0" }, "require-dev": { "ext-mongodb": "^2.1", diff --git a/composer.lock b/composer.lock index 9c7b621..9806354 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "638c502b45f9b612af14255b8f5c7b76", + "content-hash": "5679695186cd93daaefd4e8f01bc87e6", "packages": [ { "name": "patchlevel/hydrator", - "version": "1.18.0", + "version": "dev-add-methods-on-normalizers", "source": { "type": "git", "url": "https://github.com/patchlevel/hydrator.git", - "reference": "fb802134da1eb6294a4358b1f55dbe3621104d80" + "reference": "4b8f1007d56a47764057302c538241bc0b64b79f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/fb802134da1eb6294a4358b1f55dbe3621104d80", - "reference": "fb802134da1eb6294a4358b1f55dbe3621104d80", + "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/4b8f1007d56a47764057302c538241bc0b64b79f", + "reference": "4b8f1007d56a47764057302c538241bc0b64b79f", "shasum": "" }, "require": { @@ -66,9 +66,9 @@ ], "support": { "issues": "https://github.com/patchlevel/hydrator/issues", - "source": "https://github.com/patchlevel/hydrator/tree/1.18.0" + "source": "https://github.com/patchlevel/hydrator/tree/add-methods-on-normalizers" }, - "time": "2026-03-13T17:09:15+00:00" + "time": "2026-04-08T14:05:16+00:00" }, { "name": "psr/cache", @@ -275,16 +275,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v8.0.4", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f662acc6ab22a3d6d716dcb44c381c6002940df6", + "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6", "shasum": "" }, "require": { @@ -336,7 +336,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.8" }, "funding": [ { @@ -356,7 +356,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:55+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -436,16 +436,16 @@ }, { "name": "symfony/type-info", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "3c7de103dd6cb68be24e155838a64ef4a70ae195" + "reference": "622d81551770029d44d16be68969712eb47892f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/3c7de103dd6cb68be24e155838a64ef4a70ae195", - "reference": "3c7de103dd6cb68be24e155838a64ef4a70ae195", + "url": "https://api.github.com/repos/symfony/type-info/zipball/622d81551770029d44d16be68969712eb47892f1", + "reference": "622d81551770029d44d16be68969712eb47892f1", "shasum": "" }, "require": { @@ -494,7 +494,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v8.0.7" + "source": "https://github.com/symfony/type-info/tree/v8.0.8" }, "funding": [ { @@ -514,7 +514,7 @@ "type": "tidelift" } ], - "time": "2026-03-04T13:55:34+00:00" + "time": "2026-03-30T15:14:47+00:00" } ], "packages-dev": [ @@ -1425,16 +1425,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "v6.7.2", + "version": "6.8.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "6fea66c7204683af437864e7c4e7abf383d14bc0" + "reference": "89ac92bcfe5d0a8a4433c7b89d394553ae7250cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/6fea66c7204683af437864e7c4e7abf383d14bc0", - "reference": "6fea66c7204683af437864e7c4e7abf383d14bc0", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/89ac92bcfe5d0a8a4433c7b89d394553ae7250cc", + "reference": "89ac92bcfe5d0a8a4433c7b89d394553ae7250cc", "shasum": "" }, "require": { @@ -1494,9 +1494,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/v6.7.2" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.8.0" }, - "time": "2026-02-15T15:06:22+00:00" + "time": "2026-04-02T12:43:11+00:00" }, { "name": "marc-mabe/php-enum", @@ -2335,11 +2335,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.44", + "version": "2.1.46", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", - "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", "shasum": "" }, "require": { @@ -2384,7 +2384,7 @@ "type": "github" } ], - "time": "2026-03-25T17:34:21+00:00" + "time": "2026-04-01T09:25:14+00:00" }, { "name": "phpstan/phpstan-phpunit", @@ -4519,16 +4519,16 @@ }, { "name": "symfony/console", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", "shasum": "" }, "require": { @@ -4593,7 +4593,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.7" + "source": "https://github.com/symfony/console/tree/v7.4.8" }, "funding": [ { @@ -4613,7 +4613,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:20+00:00" + "time": "2026-03-30T13:54:39+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4684,16 +4684,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e" + "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e", - "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/58b9790d12f9670b7f53a1c1738febd3108970a5", + "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5", "shasum": "" }, "require": { @@ -4730,7 +4730,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.6" + "source": "https://github.com/symfony/filesystem/tree/v7.4.8" }, "funding": [ { @@ -4750,20 +4750,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/finder", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + "reference": "e0be088d22278583a82da281886e8c3592fbf149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", "shasum": "" }, "require": { @@ -4798,7 +4798,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.6" + "source": "https://github.com/symfony/finder/tree/v7.4.8" }, "funding": [ { @@ -4818,20 +4818,20 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:40:50+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/options-resolver", - "version": "v8.0.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7" + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7", - "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8", "shasum": "" }, "require": { @@ -4869,7 +4869,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v8.0.0" + "source": "https://github.com/symfony/options-resolver/tree/v8.0.8" }, "funding": [ { @@ -4889,7 +4889,7 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:55:31+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", @@ -5308,16 +5308,16 @@ }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", "shasum": "" }, "require": { @@ -5349,7 +5349,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v7.4.8" }, "funding": [ { @@ -5369,7 +5369,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/service-contracts", @@ -5460,16 +5460,16 @@ }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { @@ -5526,7 +5526,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -5546,20 +5546,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/var-dumper", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209" + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", - "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", "shasum": "" }, "require": { @@ -5613,7 +5613,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.0.6" + "source": "https://github.com/symfony/var-dumper/tree/v8.0.8" }, "funding": [ { @@ -5633,7 +5633,7 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:29+00:00" + "time": "2026-03-31T07:15:36+00:00" }, { "name": "thecodingmachine/safe", @@ -5936,9 +5936,17 @@ "time": "2024-03-07T20:33:40+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "patchlevel/hydrator", + "version": "dev-add-methods-on-normalizers", + "alias": "1.23.0", + "alias_normalized": "1.23.0.0" + } + ], "minimum-stability": "stable", "stability-flags": { + "patchlevel/hydrator": 20, "patchlevel/rango": 15 }, "prefer-stable": false, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 60acd9e..e944c91 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,8 +6,10 @@ cacheDirectory=".phpunit.cache"> - tests - tests/IntegrationTest.php + tests/Integration + + + tests/Unit diff --git a/src/Hydrator/ODMExtension.php b/src/Hydrator/ODMExtension.php index bd030fc..5b4c391 100644 --- a/src/Hydrator/ODMExtension.php +++ b/src/Hydrator/ODMExtension.php @@ -6,18 +6,11 @@ use Patchlevel\Hydrator\Extension; use Patchlevel\Hydrator\StackHydratorBuilder; -use Patchlevel\ODM\Metadata\AttributeDocumentMetadataFactory; -use Patchlevel\ODM\Metadata\DocumentMetadataFactory; final class ODMExtension implements Extension { - public function __construct( - private readonly DocumentMetadataFactory $factory = new AttributeDocumentMetadataFactory(), - ) { - } - public function configure(StackHydratorBuilder $builder): void { - $builder->addMetadataEnricher(new ODMMappingMetadataEnricher($this->factory)); + $builder->addMiddleware(new ODMMiddleware()); } } diff --git a/src/Hydrator/ODMMappingMetadataEnricher.php b/src/Hydrator/ODMMappingMetadataEnricher.php deleted file mode 100644 index b83093b..0000000 --- a/src/Hydrator/ODMMappingMetadataEnricher.php +++ /dev/null @@ -1,44 +0,0 @@ -factory->metadata($classMetadata->className); - - $classMetadata->extras[DocumentMetadata::class] = $documentMetadata; - - $propertyMetadata = $classMetadata->properties[$documentMetadata->idProperty] ?? null; - - if ($propertyMetadata === null) { - throw new RuntimeException(); - } - - if ($propertyMetadata->fieldName !== $propertyMetadata->propertyName) { - throw new RuntimeException(); - } - - $propertyMetadata->fieldName = '_id'; - } catch (ClassIsNotAnDocument) { - return; - } - } -} diff --git a/src/Hydrator/ODMMiddleware.php b/src/Hydrator/ODMMiddleware.php new file mode 100644 index 0000000..50ddfca --- /dev/null +++ b/src/Hydrator/ODMMiddleware.php @@ -0,0 +1,53 @@ +next()->hydrate($metadata, $data, $context, $stack); + } + + unset($context[DocumentMetadata::class]); + + $fieldName = $metadata->properties[$documentMetadata->idProperty]->fieldName; + + $data[$fieldName] = $data[self::ID_FIELD_NAME]; + unset($data[self::ID_FIELD_NAME]); + + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + $documentMetadata = $context[DocumentMetadata::class] ?? null; + + if (!$documentMetadata instanceof DocumentMetadata) { + return $stack->next()->extract($metadata, $object, $context, $stack); + } + + unset($context[DocumentMetadata::class]); + + $data = $stack->next()->extract($metadata, $object, $context, $stack); + + $fieldName = $metadata->properties[$documentMetadata->idProperty]->fieldName; + + $data[self::ID_FIELD_NAME] = $data[$fieldName]; + unset($data[$fieldName]); + + return $data; + } +} diff --git a/src/Metadata/AttributeDocumentMetadataFactory.php b/src/Metadata/AttributeDocumentMetadataFactory.php index 4e02913..4845dcb 100644 --- a/src/Metadata/AttributeDocumentMetadataFactory.php +++ b/src/Metadata/AttributeDocumentMetadataFactory.php @@ -10,8 +10,18 @@ use Patchlevel\ODM\Index; use ReflectionClass; -final readonly class AttributeDocumentMetadataFactory implements DocumentMetadataFactory +final class AttributeDocumentMetadataFactory implements DocumentMetadataFactory { + /** + * @var array, DocumentMetadata> + */ + private array $metadataCache = []; + + public function __construct( + private readonly FieldMappingResolver|null $fieldResolver = null, + ) { + } + /** * @param class-string $className * @@ -21,6 +31,10 @@ */ public function metadata(string $className): DocumentMetadata { + if (isset($this->metadataCache[$className])) { + return $this->metadataCache[$className]; + } + $reflection = new ReflectionClass($className); $attributes = $reflection->getAttributes(Document::class); @@ -34,6 +48,7 @@ public function metadata(string $className): DocumentMetadata $collection = $attribute->collection; $database = $attribute->database; $idProperty = null; + $fields = []; foreach ($reflection->getProperties() as $reflectionProperty) { $attributes = $reflectionProperty->getAttributes(Id::class); @@ -49,16 +64,33 @@ public function metadata(string $className): DocumentMetadata $idProperty = $reflectionProperty->getName(); } + foreach ($reflection->getProperties() as $reflectionProperty) { + if ($idProperty === $reflectionProperty->getName()) { + $fields[$reflectionProperty->getName()] = new FieldMapping('_id'); + + continue; + } + + $field = $this->fieldResolver?->resolve($reflectionProperty); + + if (!$field) { + continue; + } + + $fields[$reflectionProperty->getName()] = $field; + } + if ($idProperty === null) { throw new NoIdPropertyFound($className); } - return new DocumentMetadata( + return $this->metadataCache[$className] = new DocumentMetadata( $className, $database, $collection, $idProperty, $this->indexes($reflection), + $fields, ); } diff --git a/src/Metadata/DocumentMetadata.php b/src/Metadata/DocumentMetadata.php index d16d1dd..8ac2489 100644 --- a/src/Metadata/DocumentMetadata.php +++ b/src/Metadata/DocumentMetadata.php @@ -6,12 +6,15 @@ use Patchlevel\ODM\Index; -/** @template T of object */ +/** + * @template T of object + */ final readonly class DocumentMetadata { /** * @param class-string $className - * @param list $indexes + * @param list $indexes + * @param array $fields */ public function __construct( public string $className, @@ -19,6 +22,83 @@ public function __construct( public string $collection, public string $idProperty, public array $indexes = [], + public array $fields = [], ) { } + + public function propertyPathToFieldPath(string $propertyPath): string + { + $parts = explode('.', $propertyPath); + $fields = $this->fields; + $fieldParts = []; + + foreach ($parts as $part) { + if (!isset($fields[$part])) { + $fieldParts[] = $part; + continue; + } + + $field = $fields[$part]; + $fieldParts[] = $field->fieldName; + $fields = $field->children; + } + + return implode('.', $fieldParts); + } + + /** + * @param array $filter + * + * @return array + */ + public function mapFilterToFieldPaths(array $filter): array + { + $result = []; + + foreach ($filter as $key => $value) { + if (!is_string($key)) { + $result[$key] = is_array($value) ? $this->mapFilterToFieldPaths($value) : $value; + continue; + } + + if (str_starts_with($key, '$')) { + if (is_array($value)) { + if (array_is_list($value)) { + $result[$key] = array_map( + static fn (mixed $item): mixed => is_array($item) ? $this->mapFilterToFieldPaths($item) : $item, + $value + ); + } else { + $result[$key] = $this->mapFilterToFieldPaths($value); + } + } else { + $result[$key] = $value; + } + + continue; + } + + $fieldPath = $this->propertyPathToFieldPath($key); + + $result[$fieldPath] = is_array($value) ? $this->mapFilterToFieldPaths($value) : $value; + } + + return $result; + } + + /** + * @param array $orderBy + * + * @return array + */ + public function mapSortingToFieldPaths(array $orderBy): array + { + $result = []; + + foreach ($orderBy as $propertyPath => $direction) { + $result[$this->propertyPathToFieldPath($propertyPath)] = $direction === 'desc' ? -1 : 1; + } + + return $result; + } } diff --git a/src/Metadata/FieldMapping.php b/src/Metadata/FieldMapping.php new file mode 100644 index 0000000..bd161b0 --- /dev/null +++ b/src/Metadata/FieldMapping.php @@ -0,0 +1,15 @@ + $children + */ + public function __construct( + public string $fieldName, + public array $children = [], + ) { + } +} \ No newline at end of file diff --git a/src/Metadata/FieldMappingResolver.php b/src/Metadata/FieldMappingResolver.php new file mode 100644 index 0000000..e67b752 --- /dev/null +++ b/src/Metadata/FieldMappingResolver.php @@ -0,0 +1,10 @@ +hydrator->metadata($reflectionProperty->getDeclaringClass()->getName()); + $property = $metadata->properties[$reflectionProperty->getName()]; + + return $this->resolvePropertyMetadata($property); + } + + private function resolvePropertyMetadata(PropertyMetadata $property): FieldMapping + { + if (!$property->normalizer) { + return new FieldMapping($property->fieldName); + } + + return $this->resolveNormalizer($property->fieldName, $property->normalizer); + } + + private function resolveNormalizer(string $fieldName, Normalizer $normalizer): FieldMapping + { + if ($normalizer instanceof ArrayNormalizer) { + return $this->resolveNormalizer($fieldName, $normalizer->innerNormalizer()); + } + + if ($normalizer instanceof ObjectNormalizer) { + return $this->resolveObjectNormalizer($fieldName, $normalizer); + } + + if ($normalizer instanceof ArrayShapeNormalizer) { + return $this->resolveArrayShape($fieldName, $normalizer); + } + + return new FieldMapping($fieldName); + } + + private function resolveObjectNormalizer(string $fieldName, ObjectNormalizer $objectNormalizer): FieldMapping + { + $metadata = $this->hydrator->metadata($objectNormalizer->getClassName()); + $children = []; + + foreach ($metadata->properties as $property) { + $children[$property->getName()] = $this->resolvePropertyMetadata($property); + } + + return new FieldMapping( + $fieldName, + $children + ); + } + + private function resolveArrayShape(string $fieldName, ArrayShapeNormalizer $arrayShapeNormalizer): FieldMapping + { + $children = []; + + foreach ($arrayShapeNormalizer->innerNormalizers() as $key => $normalizer) { + $children[$key] = $this->resolveNormalizer($key, $normalizer); + } + + return new FieldMapping( + $fieldName, + $children + ); + } +} \ No newline at end of file diff --git a/src/Repository/MongoDBRepository.php b/src/Repository/MongoDBRepository.php index b811431..e89c447 100644 --- a/src/Repository/MongoDBRepository.php +++ b/src/Repository/MongoDBRepository.php @@ -42,7 +42,10 @@ public function insert(object ...$objects): void throw new WrongClass($this->metadata->className, $object::class); } - $data = $this->hydrator->extract($object); + $data = $this->hydrator->extract( + $object, + [DocumentMetadata::class => $this->metadata], + ); $this->collection->insertOne($data); return; @@ -53,7 +56,10 @@ public function insert(object ...$objects): void throw new WrongClass($this->metadata->className, $object::class); } - return $this->hydrator->extract($object); + return $this->hydrator->extract( + $object, + [DocumentMetadata::class => $this->metadata], + ); }, $objects)); } @@ -71,7 +77,10 @@ public function update(object ...$objects): void throw new WrongClass($this->metadata->className, $object::class); } - $data = $this->hydrator->extract($object); + $data = $this->hydrator->extract( + $object, + [DocumentMetadata::class => $this->metadata], + ); $this->collection->updateOne(['_id' => $data['_id']], ['$set' => $data]); @@ -83,7 +92,10 @@ public function update(object ...$objects): void throw new WrongClass($this->metadata->className, $object::class); } - $data = $this->hydrator->extract($object); + $data = $this->hydrator->extract( + $object, + [DocumentMetadata::class => $this->metadata], + ); return [ 'updateOne' => [ @@ -126,7 +138,11 @@ public function find(string $id): object|null return null; } - return $this->hydrator->hydrate($this->metadata->className, $data); + return $this->hydrator->hydrate( + $this->metadata->className, + $data, + [DocumentMetadata::class => $this->metadata], + ); } public function remove(string ...$id): void @@ -148,7 +164,11 @@ public function findAll(): iterable ]); foreach ($cursor as $document) { - yield $this->hydrator->hydrate($this->metadata->className, $document); + yield $this->hydrator->hydrate( + $this->metadata->className, + $document, + [DocumentMetadata::class => $this->metadata], + ); } } @@ -175,18 +195,19 @@ public function findBy( } if ($orderBy !== null) { - $options['sort'] = array_map( - static fn ($direction) => $direction === 'desc' ? -1 : 1, - $orderBy, - ); + $options['sort'] = $this->metadata->mapSortingToFieldPaths($orderBy); } $options['typeMap'] = ['root' => 'array', 'document' => 'array']; - $cursor = $this->collection->find($filter, $options); + $cursor = $this->collection->find($this->metadata->mapFilterToFieldPaths($filter), $options); foreach ($cursor as $document) { - yield $this->hydrator->hydrate($this->metadata->className, $document); + yield $this->hydrator->hydrate( + $this->metadata->className, + $document, + [DocumentMetadata::class => $this->metadata], + ); } } @@ -204,15 +225,20 @@ public function findOneBy(array $filter = [], array|null $orderBy = null): objec ]; if ($orderBy !== null) { - $options['sort'] = array_map( - static fn ($direction) => $direction === 'desc' ? -1 : 1, - $orderBy, - ); + $options['sort'] = $this->metadata->mapSortingToFieldPaths($orderBy); } - $data = $this->collection->findOne($filter, $options); + $data = $this->collection->findOne($this->metadata->mapFilterToFieldPaths($filter), $options); - return $data ? $this->hydrator->hydrate($this->metadata->className, $data) : null; + if ($data === null) { + return null; + } + + return $this->hydrator->hydrate( + $this->metadata->className, + $data, + [DocumentMetadata::class => $this->metadata], + ); } public function count(): int @@ -268,10 +294,7 @@ public function updateIndexes(bool $dropUnknown = false): void continue; } - $keys = array_map( - static fn ($direction) => $direction === 'desc' ? -1 : 1, - $index->keys, - ); + $keys = $this->metadata->mapSortingToFieldPaths($index->keys); $this->collection->createIndex($keys, [ 'name' => $index->name, diff --git a/src/Repository/MongoDBRepositoryManager.php b/src/Repository/MongoDBRepositoryManager.php index 12a1123..f5e16b7 100644 --- a/src/Repository/MongoDBRepositoryManager.php +++ b/src/Repository/MongoDBRepositoryManager.php @@ -12,6 +12,7 @@ use Patchlevel\ODM\Hydrator\ODMExtension; use Patchlevel\ODM\Metadata\AttributeDocumentMetadataFactory; use Patchlevel\ODM\Metadata\DocumentMetadataFactory; +use Patchlevel\ODM\Metadata\StackHydratorFieldMappingResolver; final class MongoDBRepositoryManager implements RepositoryManager { @@ -53,16 +54,20 @@ public function get(string $documentClass): MongoDBRepository /** @param list $extensions */ public static function create(Client $client, array $extensions = []): self { - $metadataFactory = new AttributeDocumentMetadataFactory(); - $builder = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) - ->useExtension(new ODMExtension($metadataFactory)); + ->useExtension(new ODMExtension()); foreach ($extensions as $extension) { $builder->useExtension($extension); } - return new self($client, $metadataFactory, $builder->build()); + $hydrator = $builder->build(); + + $metadataFactory = new AttributeDocumentMetadataFactory( + new StackHydratorFieldMappingResolver($hydrator), + ); + + return new self($client, $metadataFactory, $hydrator); } } diff --git a/src/Repository/RangoRepository.php b/src/Repository/RangoRepository.php index 972ff24..9a9b9a5 100644 --- a/src/Repository/RangoRepository.php +++ b/src/Repository/RangoRepository.php @@ -41,7 +41,11 @@ public function insert(object ...$objects): void throw new WrongClass($this->metadata->className, $object::class); } - $data = $this->hydrator->extract($object); + $data = $this->hydrator->extract( + $object, + [DocumentMetadata::class => $this->metadata], + ); + $this->collection->insertOne($data); return; @@ -52,7 +56,10 @@ public function insert(object ...$objects): void throw new WrongClass($this->metadata->className, $object::class); } - return $this->hydrator->extract($object); + return $this->hydrator->extract( + $object, + [DocumentMetadata::class => $this->metadata], + ); }, $objects)); } @@ -70,7 +77,10 @@ public function update(object ...$objects): void throw new WrongClass($this->metadata->className, $object::class); } - $data = $this->hydrator->extract($object); + $data = $this->hydrator->extract( + $object, + [DocumentMetadata::class => $this->metadata], + ); $this->collection->updateOne(['_id' => $data['_id']], ['$set' => $data]); @@ -82,7 +92,10 @@ public function update(object ...$objects): void throw new WrongClass($this->metadata->className, $object::class); } - $data = $this->hydrator->extract($object); + $data = $this->hydrator->extract( + $object, + [DocumentMetadata::class => $this->metadata], + ); return [ 'updateOne' => [ @@ -123,7 +136,11 @@ public function find(string $id): object|null return null; } - return $this->hydrator->hydrate($this->metadata->className, $data); + return $this->hydrator->hydrate( + $this->metadata->className, + $data, + [DocumentMetadata::class => $this->metadata], + ); } public function remove(string ...$id): void @@ -143,7 +160,11 @@ public function findAll(): iterable $cursor = $this->collection->find(); foreach ($cursor as $document) { - yield $this->hydrator->hydrate($this->metadata->className, $document); + yield $this->hydrator->hydrate( + $this->metadata->className, + $document, + [DocumentMetadata::class => $this->metadata], + ); } } @@ -153,8 +174,12 @@ public function findAll(): iterable * * @return iterable */ - public function findBy(array $filter, array|null $orderBy = null, int|null $limit = null, int|null $offset = null): iterable - { + public function findBy( + array $filter, + array|null $orderBy = null, + int|null $limit = null, + int|null $offset = null, + ): iterable { $options = []; if ($limit !== null) { @@ -166,16 +191,20 @@ public function findBy(array $filter, array|null $orderBy = null, int|null $limi } if ($orderBy !== null) { - $options['sort'] = array_map( - static fn ($direction) => $direction === 'desc' ? -1 : 1, - $orderBy, - ); + $options['sort'] = $this->metadata->mapSortingToFieldPaths($orderBy); } - $cursor = $this->collection->find($filter, $options); + $cursor = $this->collection->find( + $this->metadata->mapFilterToFieldPaths($filter), + $options, + ); foreach ($cursor as $document) { - yield $this->hydrator->hydrate($this->metadata->className, $document); + yield $this->hydrator->hydrate( + $this->metadata->className, + $document, + [DocumentMetadata::class => $this->metadata], + ); } } @@ -190,15 +219,20 @@ public function findOneBy(array $filter = [], array|null $orderBy = null): objec $options = ['limit' => 1]; if ($orderBy !== null) { - $options['sort'] = array_map( - static fn ($direction) => $direction === 'desc' ? -1 : 1, - $orderBy, - ); + $options['sort'] = $this->metadata->mapSortingToFieldPaths($orderBy); } - $data = $this->collection->findOne($filter, $options); + $data = $this->collection->findOne($this->metadata->mapFilterToFieldPaths($filter), $options); + + if ($data === null) { + return null; + } - return $data ? $this->hydrator->hydrate($this->metadata->className, $data) : null; + return $this->hydrator->hydrate( + $this->metadata->className, + $data, + [DocumentMetadata::class => $this->metadata], + ); } public function count(): int @@ -243,10 +277,7 @@ public function updateIndexes(bool $dropUnknown = false): void $desiredNames = []; foreach ($this->metadata->indexes as $index) { - $keys = array_map( - static fn ($direction) => $direction === 'desc' ? -1 : 1, - $index->keys, - ); + $keys = $this->metadata->mapSortingToFieldPaths($index->keys); $this->collection->createIndex($keys, [ 'name' => $index->name, diff --git a/src/Repository/RangoRepositoryManager.php b/src/Repository/RangoRepositoryManager.php index d00f09d..e86fcea 100644 --- a/src/Repository/RangoRepositoryManager.php +++ b/src/Repository/RangoRepositoryManager.php @@ -11,6 +11,7 @@ use Patchlevel\ODM\Hydrator\ODMExtension; use Patchlevel\ODM\Metadata\AttributeDocumentMetadataFactory; use Patchlevel\ODM\Metadata\DocumentMetadataFactory; +use Patchlevel\ODM\Metadata\StackHydratorFieldMappingResolver; use Patchlevel\Rango\Client; final class RangoRepositoryManager implements RepositoryManager @@ -53,16 +54,20 @@ public function get(string $documentClass): RangoRepository /** @param list $extensions */ public static function create(Client $client, array $extensions = []): self { - $metadataFactory = new AttributeDocumentMetadataFactory(); - $builder = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) - ->useExtension(new ODMExtension($metadataFactory)); + ->useExtension(new ODMExtension()); foreach ($extensions as $extension) { $builder->useExtension($extension); } - return new self($client, $metadataFactory, $builder->build()); + $hydrator = $builder->build(); + + $metadataFactory = new AttributeDocumentMetadataFactory( + new StackHydratorFieldMappingResolver($hydrator), + ); + + return new self($client, $metadataFactory, $hydrator); } } diff --git a/tests/Integration/MongoDBRepositoryTest.php b/tests/Integration/MongoDBRepositoryTest.php index 334a08a..ae2c047 100644 --- a/tests/Integration/MongoDBRepositoryTest.php +++ b/tests/Integration/MongoDBRepositoryTest.php @@ -35,7 +35,7 @@ public function setUp(): void $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) - ->useExtension(new ODMExtension($documentMetadataFactory)) + ->useExtension(new ODMExtension()) ->build(); $this->client->dropDatabase('patchlevel'); @@ -223,6 +223,35 @@ public function testFindWithFilter(): void self::assertSame(['r-1', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); } + public function testFindWithFilterById(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-2', + 'name' => 'Beans', + 'status' => 'inactive', + 'skills' => ['js'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-3', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['tracking'], + ]); + + $results = iterator_to_array($repository->findBy(['id' => ['$in' => ['r-1', 'r-3']]]), false); + + self::assertCount(2, $results); + self::assertSame(['r-1', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); + } + public function testFindWithLimit(): void { $repository = $this->repositoryManager->get(Profile::class); diff --git a/tests/Integration/RangoRepositoryTest.php b/tests/Integration/RangoRepositoryTest.php index d96402a..18571c8 100644 --- a/tests/Integration/RangoRepositoryTest.php +++ b/tests/Integration/RangoRepositoryTest.php @@ -40,7 +40,7 @@ public function setUp(): void $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) - ->useExtension(new ODMExtension($documentMetadataFactory)) + ->useExtension(new ODMExtension()) ->build(); $this->client->dropDatabase('patchlevel'); @@ -223,6 +223,35 @@ public function testFindWithFilter(): void self::assertSame(['r-1', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); } + public function testFindWithFilterById(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-2', + 'name' => 'Beans', + 'status' => 'inactive', + 'skills' => ['js'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-3', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['tracking'], + ]); + + $results = iterator_to_array($repository->findBy(['id' => ['$in' => ['r-1', 'r-3']]]), false); + + self::assertCount(2, $results); + self::assertSame(['r-1', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); + } + public function testFindWithLimit(): void { $repository = $this->repositoryManager->get(Profile::class); diff --git a/tests/Unit/Metadata/DocumentMetadataTest.php b/tests/Unit/Metadata/DocumentMetadataTest.php new file mode 100644 index 0000000..d94c544 --- /dev/null +++ b/tests/Unit/Metadata/DocumentMetadataTest.php @@ -0,0 +1,103 @@ +propertyPathToFieldPath('name')); + } + + public function testPropertyPathToFieldPathWithMapping(): void + { + $metadata = new DocumentMetadata( + className: \stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'name' => new FieldMapping('_name'), + ], + ); + + self::assertSame('_name', $metadata->propertyPathToFieldPath('name')); + } + + public function testPropertyPathToFieldPathWithNestedMapping(): void + { + $metadata = new DocumentMetadata( + className: \stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'address' => new FieldMapping('_address', [ + 'street' => new FieldMapping('_street'), + ]), + ], + ); + + self::assertSame('_address._street', $metadata->propertyPathToFieldPath('address.street')); + } + + public function testPropertyPathToFieldPathWithPartialNestedMapping(): void + { + $metadata = new DocumentMetadata( + className: \stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'address' => new FieldMapping('_address'), + ], + ); + + self::assertSame('_address.street', $metadata->propertyPathToFieldPath('address.street')); + } + + public function testPropertyPathToFieldPathWithDeeplyNestedMapping(): void + { + $metadata = new DocumentMetadata( + className: \stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'a' => new FieldMapping('_a', [ + 'b' => new FieldMapping('_b', [ + 'c' => new FieldMapping('_c'), + ]), + ]), + ], + ); + + self::assertSame('_a._b._c', $metadata->propertyPathToFieldPath('a.b.c')); + } + + public function testPropertyPathToFieldPathUnmappedFieldPassedThrough(): void + { + $metadata = new DocumentMetadata( + className: \stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [], + ); + + self::assertSame('unknown', $metadata->propertyPathToFieldPath('unknown')); + } +}