diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 979e72c5..0b482599 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -31,6 +31,7 @@ jobs: symfony-version: - "5.4.x" - "6.2.x" + - "6.3.x" driver-version: - "stable" dependencies: @@ -45,6 +46,8 @@ jobs: exclude: - php-version: "7.4" symfony-version: "6.2.x" + - php-version: "7.4" + symfony-version: "6.3.x" services: mongodb: diff --git a/ArgumentResolver/DocumentValueResolver.php b/ArgumentResolver/DocumentValueResolver.php new file mode 100644 index 00000000..d2e53782 --- /dev/null +++ b/ArgumentResolver/DocumentValueResolver.php @@ -0,0 +1,24 @@ +entityValueResolver->resolve($request, $argument); + } +} diff --git a/Attribute/MapDocument.php b/Attribute/MapDocument.php new file mode 100644 index 00000000..c757d7d7 --- /dev/null +++ b/Attribute/MapDocument.php @@ -0,0 +1,27 @@ +loadMessengerServices($container); - // available in Symfony 6.2 and higher - if (! class_exists(EntityValueResolver::class)) { - $container->removeDefinition('doctrine_mongodb.odm.entity_value_resolver'); - $container->removeDefinition('doctrine_mongodb.odm.entity_value_resolver.expression_language'); - } else { - if (! class_exists(ExpressionLanguage::class)) { - $container->removeDefinition('doctrine_mongodb.odm.entity_value_resolver.expression_language'); - } - - $controllerResolverDefaults = []; - - if (! $config['controller_resolver']['enabled']) { - $controllerResolverDefaults['disabled'] = true; - } - - if (! $config['controller_resolver']['auto_mapping']) { - $controllerResolverDefaults['mapping'] = []; - } - - if ($controllerResolverDefaults) { - $container->getDefinition('doctrine_mongodb.odm.entity_value_resolver')->setArgument(2, (new Definition(MapEntity::class))->setArguments([ - null, - null, - null, - $controllerResolverDefaults['mapping'] ?? null, - null, - null, - null, - null, - $controllerResolverDefaults['disabled'] ?? false, - ])); - } - } + $this->loadEntityValueResolverServices($container, $loader, $config); } /** @@ -432,6 +401,46 @@ private function loadMessengerServices(ContainerBuilder $container): void $loader->load('messenger.xml'); } + /** @param array $config */ + private function loadEntityValueResolverServices(ContainerBuilder $container, FileLoader $loader, array $config): void + { + // available in Symfony 6.2 and higher + if (! class_exists(EntityValueResolver::class)) { + return; + } + + $loader->load('value_resolver.xml'); + + if (! class_exists(ExpressionLanguage::class)) { + $container->removeDefinition('doctrine_mongodb.odm.document_value_resolver.expression_language'); + } + + $controllerResolverDefaults = []; + + if (! $config['controller_resolver']['enabled']) { + $controllerResolverDefaults['disabled'] = true; + } + + if (! $config['controller_resolver']['auto_mapping']) { + $controllerResolverDefaults['mapping'] = []; + } + + if ($controllerResolverDefaults === []) { + return; + } + + $container->getDefinition('doctrine_mongodb.odm.entity_value_resolver')->setArgument(2, (new Definition(MapDocument::class))->setArguments([ + null, + null, + null, + $controllerResolverDefaults['mapping'] ?? null, + null, + null, + null, + $controllerResolverDefaults['disabled'] ?? false, + ])); + } + /** * Normalizes the driver options array * diff --git a/Resources/config/mongodb.xml b/Resources/config/mongodb.xml index 22c4362a..b3bb407e 100644 --- a/Resources/config/mongodb.xml +++ b/Resources/config/mongodb.xml @@ -208,13 +208,5 @@ - - - - - - - - diff --git a/Resources/config/value_resolver.xml b/Resources/config/value_resolver.xml new file mode 100644 index 00000000..92fcd9e6 --- /dev/null +++ b/Resources/config/value_resolver.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + controller.argument_value_resolver + + + diff --git a/Resources/doc/first_steps.rst b/Resources/doc/first_steps.rst index 036c2e36..c861496e 100644 --- a/Resources/doc/first_steps.rst +++ b/Resources/doc/first_steps.rst @@ -227,7 +227,7 @@ Once you have your repository, you have access to all sorts of helpful methods: // find *all* products $products = $repository->findAll(); - // find a group of products based on an arbitrary column value + // find a group of products based on an arbitrary field value $products = $repository->findBy(['price' => 19.99]); .. note:: @@ -249,6 +249,222 @@ to easily fetch objects based on multiple conditions: ['price' => 'ASC'] ); +Automatically Fetching Objects (DocumentValueResolver) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 4.6 + + The support of this feature was added in Doctrine MongoDB ODM Bundle 4.6. + +In many cases, you can use the ``DocumentValueResolver`` to do the query for +you automatically! You can simplify the controller to: + +.. code-block:: php + + // src/Controller/ProductController.php + namespace App\Controller; + + use App\Document\Product; + use Symfony\Component\HttpFoundation\Response; + use Symfony\Component\Routing\Annotation\Route; + // ... + + #[Route('/product/{id}')] + public function showAction(Product $product): Response + { + // use the Product! + // do something, like pass the $product object into a template + } + +That's it! The bundle uses the ``{id}`` from the route to query for the ``Product`` +by the ``id`` field. If it's not found, a 404 page is generated. + +This behavior is enabled by default on all your controllers. You can +disable it by setting the ``doctrine_mongodb.controller_resolver.auto_mapping`` +config option to ``false``. + +When disabled, you can enable it individually on the desired controllers by +using the ``MapDocument`` attribute: + +.. code-block:: php + + // src/Controller/ProductController.php + namespace App\Controller; + + use App\Document\Product; + use Doctrine\Bundle\MongoDBBundle\Attribute\MapDocument; + use Symfony\Component\HttpFoundation\Response; + use Symfony\Component\Routing\Annotation\Route; + // ... + + class ProductController extends AbstractController + { + #[Route('/product/{id}')] + public function show( + #[MapDocument] + Product $product + ): Response { + // use the Product! + // ... + } + } + +.. tip:: + + When enabled globally, it's possible to disable the behavior on a specific + controller, by using the ``MapDocument`` set to ``disabled``: + +.. code-block:: php + + public function show( + #[CurrentUser] + #[MapDocument(disabled: true)] + User $user + ): Response { + // User is not resolved by the DocumentValueResolver + // ... + } + +Fetch Automatically +~~~~~~~~~~~~~~~~~~~ + +If your route wildcards match properties in your document, then the resolver +will automatically fetch them: + +.. code-block:: php + + /** + * Fetch via identifier because {id} is in the route. + */ + #[Route('/product/{id}')] + public function showByIdentifier(Post $post): Response + { + } + + /** + * Perform a findOneBy() where the slug property matches {slug}. + */ + #[Route('/product/{slug}')] + public function showBySlug(Post $post): Response + { + } + +Automatic fetching works in these situations: + +* If ``{id}`` is in your route, then this is used to fetch by + identifier via the ``find()`` method. + +* The resolver will attempt to do a ``findOneBy()`` fetch by using + *all* of the wildcards in your route that are actually properties + on your document (non-properties are ignored). + +You can control this behavior by actually *adding* the ``MapDocument`` +attribute and using the `MapDocument options`_. + +Fetch via an Expression +~~~~~~~~~~~~~~~~~~~~~~~ + +If automatic fetching doesn't work, you can write an expression using the +`ExpressionLanguage component`_: + +.. code-block:: php + + #[Route('/product/{product_id}')] + public function show( + #[MapDocument(expr: 'repository.find(product_id)')] + Product $product + ): Response { + } + +In the expression, the ``repository`` variable will be your document's +Repository class and any route wildcards - like ``{product_id}`` are +available as variables. + +This can also be used to help resolve multiple arguments: + +.. code-block:: php + + #[Route('/product/{id}/comments/{comment_id}')] + public function show( + Product $product, + #[MapDocument(expr: 'repository.find(comment_id)')] + Comment $comment + ): Response { + } + +In the example above, the ``$product`` argument is handled automatically, +but ``$comment`` is configured with the attribute since they cannot both follow +the default convention. + +MapDocument Options +~~~~~~~~~~~~~~~~~~~ + +A number of options are available on the ``MapDocument`` attribute to +control behavior: + +``id`` + If an ``id`` option is configured and matches a route parameter, then + the resolver will find by the identifier: + +.. code-block:: php + + #[Route('/product/{product_id}')] + public function show( + #[MapDocument(id: 'product_id')] + Product $product + ): Response { + } + +``mapping`` + Configures the properties and values to use with the ``findOneBy()`` + method: the key is the route placeholder name and the value is the Doctrine + property name: + +.. code-block:: php + + #[Route('/product/{category}/{slug}/comments/{comment_slug}')] + public function show( + #[MapDocument(mapping: ['category' => 'category', 'slug' => 'slug'])] + Product $product, + #[MapDocument(mapping: ['comment_slug' => 'slug'])] + Comment $comment + ): Response { + } + +``exclude`` + Configures the properties that should be used in the ``findOneBy()`` + method by *excluding* one or more properties so that not *all* are used: + +.. code-block:: php + + #[Route('/product/{slug}/{date}')] + public function show( + #[MapDocument(exclude: ['date'])] + Product $product, + \DateTime $date + ): Response { + } + +``stripNull`` + If true, then when ``findOneBy()`` is used, any values that are + ``null`` will not be used for the query. + +``objectManager`` + By default, the ``DocumentValueResolver`` will choose the document manager + that has the class registered for it, but you can configure this: + +.. code-block:: php + + #[Route('/product/{id}')] + public function show( + #[MapDocument(objectManager: 'foo')] + Product $product + ): Response { + } + +``disabled`` + If true, the ``DoctrineValueResolver`` will not try to replace the argument. + Updating an Object ~~~~~~~~~~~~~~~~~~ @@ -487,6 +703,7 @@ repositories as services you can use the following service configuration: .. _`Basic Mapping Documentation`: https://www.doctrine-project.org/projects/doctrine-mongodb-odm/en/latest/reference/basic-mapping.html +.. _`ExpressionLanguage component`: https://symfony.com/doc/current/components/expression_language.html .. _`Conditional Operators`: https://www.doctrine-project.org/projects/doctrine-mongodb-odm/en/latest/reference/query-builder-api.html#conditional-operators .. _`DoctrineFixturesBundle`: https://symfony.com/doc/master/bundles/DoctrineFixturesBundle/index.html .. _`Query Builder`: https://www.doctrine-project.org/projects/doctrine-mongodb-odm/en/latest/reference/query-builder-api.html diff --git a/Tests/DependencyInjection/AbstractMongoDBExtensionTest.php b/Tests/DependencyInjection/AbstractMongoDBExtensionTest.php index d964fe57..d133823f 100644 --- a/Tests/DependencyInjection/AbstractMongoDBExtensionTest.php +++ b/Tests/DependencyInjection/AbstractMongoDBExtensionTest.php @@ -23,7 +23,6 @@ use Doctrine\Persistence\Mapping\Driver\MappingDriverChain; use MongoDB\Client; use PHPUnit\Framework\AssertionFailedError; -use Symfony\Bridge\Doctrine\SchemaListener\DoctrineDbalCacheAdapterSchemaSubscriber; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntityValidator; use Symfony\Component\DependencyInjection\Container; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -309,10 +308,6 @@ public function testXmlBundleMappingDetection(): void public function testNewBundleStructureXmlBundleMappingDetection(): void { - if (! class_exists(DoctrineDbalCacheAdapterSchemaSubscriber::class)) { - $this->markTestSkipped('Test requires symfony/doctrine-bridge >=5.4'); - } - $container = $this->getContainer('NewXmlBundle'); $loader = new DoctrineMongoDBExtension(); $config = DoctrineMongoDBExtensionTest::buildConfiguration( diff --git a/Tests/DependencyInjection/DoctrineMongoDBExtensionTest.php b/Tests/DependencyInjection/DoctrineMongoDBExtensionTest.php index 273a3596..ddb2d0bc 100644 --- a/Tests/DependencyInjection/DoctrineMongoDBExtensionTest.php +++ b/Tests/DependencyInjection/DoctrineMongoDBExtensionTest.php @@ -4,10 +4,10 @@ namespace Doctrine\Bundle\MongoDBBundle\Tests\DependencyInjection; +use Doctrine\Bundle\MongoDBBundle\Attribute\MapDocument; use Doctrine\Bundle\MongoDBBundle\DependencyInjection\DoctrineMongoDBExtension; use Doctrine\Bundle\MongoDBBundle\Tests\DependencyInjection\Fixtures\Bundles\DocumentListenerBundle\EventListener\TestAttributeListener; use PHPUnit\Framework\TestCase; -use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bridge\Doctrine\Messenger\DoctrineClearEntityManagerWorkerSubscriber; use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\Container; @@ -353,7 +353,10 @@ public function testControllerResolver(): void $controllerResolver = $container->getDefinition('doctrine_mongodb.odm.entity_value_resolver'); - $this->assertEquals([new Reference('doctrine_mongodb'), new Reference('doctrine_mongodb.odm.entity_value_resolver.expression_language', $container::IGNORE_ON_INVALID_REFERENCE)], $controllerResolver->getArguments()); + $this->assertEquals([ + new Reference('doctrine_mongodb'), + new Reference('doctrine_mongodb.odm.document_value_resolver.expression_language', $container::IGNORE_ON_INVALID_REFERENCE), + ], $controllerResolver->getArguments()); $container = $this->getContainer(); @@ -366,6 +369,6 @@ public function testControllerResolver(): void $container->setDefinition('controller_resolver_defaults', $container->getDefinition('doctrine_mongodb.odm.entity_value_resolver')->getArgument(2))->setPublic(true); $container->compile(); - $this->assertEquals(new MapEntity(null, null, null, [], null, null, null, null, true), $container->get('controller_resolver_defaults')); + $this->assertEquals(new MapDocument(null, null, null, [], null, null, null, true), $container->get('controller_resolver_defaults')); } } diff --git a/Tests/DocumentValueResolverFunctionalTest.php b/Tests/DocumentValueResolverFunctionalTest.php new file mode 100644 index 00000000..d26004c4 --- /dev/null +++ b/Tests/DocumentValueResolverFunctionalTest.php @@ -0,0 +1,150 @@ +get(DocumentManager::class); + $user = new User('user-identifier'); + + $dm->persist($user); + $dm->flush(); + + $client->request('GET', '/user/user-identifier'); + + $this->assertResponseIsSuccessful(); + $this->assertSame('user-identifier', $client->getResponse()->getContent()); + + $dm->remove($user); + } + + public function testWithConfiguration(): void + { + $client = static::createClient(); + + $dm = static::getContainer()->get(DocumentManager::class); + $user = new User('user-identifier'); + + $dm->persist($user); + $dm->flush(); + + $client->request('GET', '/user_with_mapping/user-identifier'); + + $this->assertResponseIsSuccessful(); + $this->assertSame('user-identifier', $client->getResponse()->getContent()); + + $dm->remove($user); + } + + protected static function getKernelClass(): string + { + return FooTestKernel::class; + } +} + +class FooTestKernel extends Kernel +{ + use MicroKernelTrait; + + private string $randomKey; + + public function __construct() + { + $this->randomKey = uniqid(''); + + parent::__construct('test', false); + } + + protected function getContainerClass(): string + { + return 'test' . $this->randomKey . parent::getContainerClass(); + } + + public function registerBundles(): array + { + return [ + new FrameworkBundle(), + new DoctrineMongoDBBundle(), + new FooBundle(), + ]; + } + + protected function configureRoutes(RoutingConfigurator $routes): void + { + $routes->add('tv_user_show', '/user/{id}') + ->controller([DocumentValueResolverController::class, 'showUserByDefault']); + + $routes->add('user_with_mapping', '/user_with_mapping/{identifier}') + ->controller([DocumentValueResolverController::class, 'showUserWithMapping']); + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + $c->loadFromExtension('framework', [ + 'secret' => 'foo', + 'router' => ['utf8' => false], + 'http_method_override' => false, + 'test' => true, + ]); + + $c->loadFromExtension('doctrine_mongodb', [ + 'connections' => ['default' => []], + 'document_managers' => [ + 'default' => [ + 'mappings' => [ + 'App' => [ + 'is_bundle' => false, + 'type' => 'attribute', + 'dir' => '%kernel.project_dir%/Document', + 'prefix' => 'Doctrine\Bundle\MongoDBBundle\Tests\Fixtures\FooBundle', + 'alias' => 'Doctrine\Bundle\MongoDBBundle\Tests\Fixtures\FooBundle', + ], + ], + ], + ], + ]); + + $loader->load(__DIR__ . '/Fixtures/FooBundle/config/services.php'); + } + + public function getProjectDir(): string + { + return __DIR__ . '/Fixtures/FooBundle/'; + } + + public function getCacheDir(): string + { + return sys_get_temp_dir() . '/doctrine_mongodb_odm_bundle' . $this->randomKey; + } + + public function getLogDir(): string + { + return sys_get_temp_dir(); + } +} diff --git a/Tests/Fixtures/FooBundle/Controller/DocumentValueResolverController.php b/Tests/Fixtures/FooBundle/Controller/DocumentValueResolverController.php new file mode 100644 index 00000000..8348027b --- /dev/null +++ b/Tests/Fixtures/FooBundle/Controller/DocumentValueResolverController.php @@ -0,0 +1,30 @@ +getId()); + } + + #[Route(path: '/user_with_identifier/{identifier}', name: 'tv_user_show_with_identifier')] + public function showUserWithMapping( + #[MapDocument(mapping: ['identifier' => 'id'])] + User $user + ): Response { + return new Response($user->getId()); + } +} diff --git a/Tests/Fixtures/FooBundle/Document/User.php b/Tests/Fixtures/FooBundle/Document/User.php new file mode 100644 index 00000000..45bb2ed2 --- /dev/null +++ b/Tests/Fixtures/FooBundle/Document/User.php @@ -0,0 +1,23 @@ +id; + } +} diff --git a/Tests/Fixtures/FooBundle/config/services.php b/Tests/Fixtures/FooBundle/config/services.php new file mode 100644 index 00000000..6a6b57bc --- /dev/null +++ b/Tests/Fixtures/FooBundle/config/services.php @@ -0,0 +1,15 @@ +services() + ->defaults() + ->autowire() + ->autoconfigure(); + + $services->load('Doctrine\\Bundle\\MongoDBBundle\\Tests\\Fixtures\\FooBundle\\', '..') + ->exclude('../{config,DataFixtures,Document}'); +}; diff --git a/composer.json b/composer.json index d86f8c68..88a64b34 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,7 @@ "doctrine/data-fixtures": "^1.3", "phpunit/phpunit": "^9.5.5", "psalm/plugin-symfony": "^5.0", + "symfony/browser-kit": "^5.4 || ^6.2", "symfony/form": "^5.4 || ^6.2", "symfony/phpunit-bridge": "^6.2", "symfony/security-bundle": "^5.4 || ^6.2",