diff --git a/config/orm.php b/config/orm.php new file mode 100644 index 0000000..fe1331e --- /dev/null +++ b/config/orm.php @@ -0,0 +1,21 @@ +services(); + + $services + ->set(GeocodeEntityListener::class) + ->args([ + tagged_locator('bazinga_geocoder.provider'), + service(DriverInterface::class), + ]) + ->tag('doctrine.event_listener', ['event' => 'onFlush']) + ; +}; diff --git a/config/services.php b/config/services.php index e8dce8c..baba963 100644 --- a/config/services.php +++ b/config/services.php @@ -5,6 +5,9 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Bazinga\GeocoderBundle\Command\GeocodeCommand; +use Bazinga\GeocoderBundle\Mapping\Driver\AttributeDriver; +use Bazinga\GeocoderBundle\Mapping\Driver\ChainDriver; +use Bazinga\GeocoderBundle\Mapping\Driver\DriverInterface; use Bazinga\GeocoderBundle\Plugin\FakeIpPlugin; use Bazinga\GeocoderBundle\Validator\Constraint\AddressValidator; use Geocoder\Dumper\Dumper; @@ -49,5 +52,15 @@ service(ProviderAggregator::class), ]) ->tag('validator.constraint_validator') + + ->set(ChainDriver::class) + ->args([ + tagged_iterator('bazinga_geocoder.metadata.driver', exclude: [ChainDriver::class]), + ]) + ->tag('bazinga_geocoder.metadata.driver') + ->alias(DriverInterface::class, ChainDriver::class) + + ->set(AttributeDriver::class) + ->tag('bazinga_geocoder.metadata.driver') ; }; diff --git a/doc/doctrine.md b/doc/doctrine.md index ff12341..39042a3 100644 --- a/doc/doctrine.md +++ b/doc/doctrine.md @@ -1,10 +1,10 @@ -# Doctrine annotation support +# Doctrine support *[<< Back to documentation index](/doc/index.md)* -Wouldn't it be great if you could automatically save the coordinates of a users -address every time it is updated? Wait not more here is the feature you been always -wanted. +Wouldn't it be great if you could automatically save the coordinates of a user's +address every time it is updated? Well, wait no more—here is the feature you've +always wanted! First of all, update your entity: @@ -12,7 +12,7 @@ First of all, update your entity: use Bazinga\GeocoderBundle\Mapping\Attributes as Geocoder; -#[Geocoder\Geocodeable()] +#[Geocoder\Geocodeable(provider: 'acme')] class User { #[Geocoder\Address()] @@ -32,7 +32,7 @@ Instead of annotating a property, you can also annotate a getter: use Bazinga\GeocoderBundle\Mapping\Attributes as Geocoder; -#[Geocoder\Geocodeable()] +#[Geocoder\Geocodeable(provider: 'acme')] class User { #[Geocoder\Latitude()] @@ -42,36 +42,28 @@ class User private $longitude; #[Geocoder\Address()] - public function getAddress(): string + public function getAddress(): \Stringable|string { // Your code... } } ``` -Secondly, register the Doctrine event listener and its dependencies in your `config/services.yaml` or `config/services.php` file. -You have to indicate which provider to use to reverse geocode the address. Here we use `acme` provider we declared in bazinga_geocoder configuration earlier. +Secondly, enable Doctrine ORM listener in the configuration: ```yaml - Bazinga\GeocoderBundle\Mapping\Driver\AttributeDriver: ~ - - Bazinga\GeocoderBundle\Doctrine\ORM\GeocoderListener: - class: Bazinga\GeocoderBundle\Doctrine\ORM\GeocoderListener - arguments: - - '@bazinga_geocoder.provider.acme' - - '@Bazinga\GeocoderBundle\Mapping\Driver\AttributeDriver' - tags: - - { name: doctrine.event_listener, event: onFlush } +bazinga_geocoder: + orm: + enabled: true ``` -It is done! -Now you can use it: +That's it! Now you can use it: ```php $user = new User(); $user->setAddress('Brandenburger Tor, Pariser Platz, Berlin'); -$em->persist($event); +$em->persist($user); $em->flush(); echo $user->getLatitude(); // will output 52.516325 diff --git a/phpstan-baseline.php b/phpstan-baseline.php index 4b8c247..f609d21 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -129,6 +129,12 @@ 'count' => 1, 'path' => __DIR__.'/src/DependencyInjection/BazingaGeocoderExtension.php', ]; +$ignoreErrors[] = [ + // identifier: argument.type + 'message' => '#^Parameter \\#2 \\$array of function array_key_exists expects array, array\\|bool\\|float\\|int\\|string\\|UnitEnum\\|null given\\.$#', + 'count' => 1, + 'path' => __DIR__.'/src/DependencyInjection/BazingaGeocoderExtension.php', +]; $ignoreErrors[] = [ // identifier: argument.type 'message' => '#^Parameter \\#2 \\$config of method Bazinga\\\\GeocoderBundle\\\\DependencyInjection\\\\BazingaGeocoderExtension\\:\\:configureProviderPlugins\\(\\) expects array, mixed given\\.$#', @@ -151,13 +157,19 @@ // identifier: method.nonObject 'message' => '#^Cannot call method getLatitude\\(\\) on Geocoder\\\\Model\\\\Coordinates\\|null\\.$#', 'count' => 1, - 'path' => __DIR__.'/src/Doctrine/ORM/GeocoderListener.php', + 'path' => __DIR__.'/src/Doctrine/ORM/GeocodeEntityListener.php', ]; $ignoreErrors[] = [ // identifier: method.nonObject 'message' => '#^Cannot call method getLongitude\\(\\) on Geocoder\\\\Model\\\\Coordinates\\|null\\.$#', 'count' => 1, - 'path' => __DIR__.'/src/Doctrine/ORM/GeocoderListener.php', + 'path' => __DIR__.'/src/Doctrine/ORM/GeocodeEntityListener.php', +]; +$ignoreErrors[] = [ + // identifier: argument.missing + 'message' => '#^Missing parameter \\$provider \\(string\\) in call to Bazinga\\\\GeocoderBundle\\\\Mapping\\\\ClassMetadata constructor\\.$#', + 'count' => 1, + 'path' => __DIR__.'/src/Mapping/Driver/AttributeDriver.php', ]; $ignoreErrors[] = [ // identifier: argument.type diff --git a/src/DependencyInjection/BazingaGeocoderExtension.php b/src/DependencyInjection/BazingaGeocoderExtension.php index e678eaf..2647d23 100644 --- a/src/DependencyInjection/BazingaGeocoderExtension.php +++ b/src/DependencyInjection/BazingaGeocoderExtension.php @@ -55,6 +55,14 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load('profiling.php'); } + if (\array_key_exists('DoctrineBundle', $container->getParameter('kernel.bundles'))) { + if (true === $config['orm']['enabled']) { + $loader->load('orm.php'); + } + } elseif (true === $config['orm']['enabled']) { + throw new \LogicException('Doctrine ORM listener cannot be enabled when `doctrine/doctrine-bundle` is not installed.'); + } + if ($config['fake_ip']['enabled']) { $definition = $container->getDefinition(FakeIpPlugin::class); $definition->replaceArgument(0, $config['fake_ip']['local_ip']); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 4eae955..216465b 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -66,6 +66,18 @@ public function getConfigTreeBuilder(): TreeBuilder ->scalarNode('ip')->defaultNull()->end() ->booleanNode('use_faker')->defaultFalse()->end() ->end() + ->end() + ->arrayNode('orm') + ->addDefaultsIfNotSet() + ->treatFalseLike(['enabled' => false]) + ->treatTrueLike(['enabled' => true]) + ->treatNullLike(['enabled' => true]) + ->children() + ->booleanNode('enabled') + ->info('Turn the Doctrine ORM listener on or off.') + ->defaultValue(false) + ->end() + ->end() ->end(); return $treeBuilder; diff --git a/src/Doctrine/ORM/GeocoderListener.php b/src/Doctrine/ORM/GeocodeEntityListener.php similarity index 80% rename from src/Doctrine/ORM/GeocoderListener.php rename to src/Doctrine/ORM/GeocodeEntityListener.php index 6b0edeb..239ae3d 100644 --- a/src/Doctrine/ORM/GeocoderListener.php +++ b/src/Doctrine/ORM/GeocodeEntityListener.php @@ -14,34 +14,28 @@ use Bazinga\GeocoderBundle\Mapping\ClassMetadata; use Bazinga\GeocoderBundle\Mapping\Driver\DriverInterface; -use Doctrine\Common\EventSubscriber; use Doctrine\ORM\Event\OnFlushEventArgs; -use Doctrine\ORM\Events; use Doctrine\ORM\UnitOfWork; use Geocoder\Provider\Provider; use Geocoder\Query\GeocodeQuery; +use Symfony\Component\DependencyInjection\ServiceLocator; /** * @author Markus Bachmann + * @author Pierre du Plessis */ -final class GeocoderListener implements EventSubscriber +final class GeocodeEntityListener { + /** + * @param ServiceLocator $providerLocator + * @param DriverInterface $driver + */ public function __construct( - private readonly Provider $geocoder, + private readonly ServiceLocator $providerLocator, private readonly DriverInterface $driver, ) { } - /** - * @return list - */ - public function getSubscribedEvents(): array - { - return [ - Events::onFlush, - ]; - } - public function onFlush(OnFlushEventArgs $args): void { $em = $args->getObjectManager(); @@ -58,7 +52,7 @@ public function onFlush(OnFlushEventArgs $args): void $uow->recomputeSingleEntityChangeSet( $em->getClassMetadata($entity::class), - $entity + $entity, ); } @@ -77,7 +71,7 @@ public function onFlush(OnFlushEventArgs $args): void $uow->recomputeSingleEntityChangeSet( $em->getClassMetadata($entity::class), - $entity + $entity, ); } } @@ -101,7 +95,13 @@ private function geocodeEntity(ClassMetadata $metadata, object $entity): void return; } - $results = $this->geocoder->geocodeQuery(GeocodeQuery::create($addressString)); + $serviceId = \sprintf('bazinga_geocoder.provider.%s', $metadata->provider); + + if (!$this->providerLocator->has($serviceId)) { + throw new \RuntimeException(\sprintf('The provider "%s" is invalid for object "%s".', $metadata->provider, $entity::class)); + } + + $results = $this->providerLocator->get($serviceId)->geocodeQuery(GeocodeQuery::create($addressString)); if (!$results->isEmpty()) { $result = $results->first(); diff --git a/src/Mapping/Attributes/Geocodeable.php b/src/Mapping/Attributes/Geocodeable.php index 64a121c..7ad6a77 100644 --- a/src/Mapping/Attributes/Geocodeable.php +++ b/src/Mapping/Attributes/Geocodeable.php @@ -18,4 +18,11 @@ #[\Attribute(\Attribute::TARGET_CLASS)] class Geocodeable { + /** + * @param non-empty-string $provider + */ + public function __construct( + public readonly string $provider, + ) { + } } diff --git a/src/Mapping/ClassMetadata.php b/src/Mapping/ClassMetadata.php index 07fbdbf..60041ea 100644 --- a/src/Mapping/ClassMetadata.php +++ b/src/Mapping/ClassMetadata.php @@ -17,7 +17,11 @@ */ final class ClassMetadata { + /** + * @param non-empty-string $provider + */ public function __construct( + public readonly string $provider, public readonly ?\ReflectionProperty $addressProperty = null, public readonly ?\ReflectionProperty $latitudeProperty = null, public readonly ?\ReflectionProperty $longitudeProperty = null, diff --git a/src/Mapping/Driver/AttributeDriver.php b/src/Mapping/Driver/AttributeDriver.php index efce10e..446a0bc 100644 --- a/src/Mapping/Driver/AttributeDriver.php +++ b/src/Mapping/Driver/AttributeDriver.php @@ -43,7 +43,7 @@ public function loadMetadataFromObject(object $object): ClassMetadata throw new MappingException(sprintf('The class "%s" is not geocodeable', $object::class)); } - $args = []; + $args = ['provider' => $attributes[0]->newInstance()->provider]; foreach ($reflection->getProperties() as $property) { foreach ($property->getAttributes() as $attribute) { diff --git a/src/Mapping/Driver/ChainDriver.php b/src/Mapping/Driver/ChainDriver.php new file mode 100644 index 0000000..a103e66 --- /dev/null +++ b/src/Mapping/Driver/ChainDriver.php @@ -0,0 +1,54 @@ + + */ +final class ChainDriver implements DriverInterface +{ + /** + * @param iterable $drivers + */ + public function __construct( + private iterable $drivers, + ) { + } + + public function isGeocodeable(object $object): bool + { + foreach ($this->drivers as $driver) { + if ($driver->isGeocodeable($object)) { + return true; + } + } + + return false; + } + + public function loadMetadataFromObject(object $object): ClassMetadata + { + foreach ($this->drivers as $driver) { + try { + return $driver->loadMetadataFromObject($object); + } catch (MappingException) { + continue; + } + } + + throw new MappingException(sprintf('The class "%s" is not geocodeable.', $object::class)); + } +} diff --git a/src/Mapping/Driver/DriverInterface.php b/src/Mapping/Driver/DriverInterface.php index 8d06209..dc3b25b 100644 --- a/src/Mapping/Driver/DriverInterface.php +++ b/src/Mapping/Driver/DriverInterface.php @@ -13,10 +13,14 @@ namespace Bazinga\GeocoderBundle\Mapping\Driver; use Bazinga\GeocoderBundle\Mapping\ClassMetadata; +use Bazinga\GeocoderBundle\Mapping\Exception\MappingException; interface DriverInterface { public function isGeocodeable(object $object): bool; + /** + * @throws MappingException + */ public function loadMetadataFromObject(object $object): ClassMetadata; } diff --git a/tests/Functional/Fixtures/Entity/DummyWithEmptyProperty.php b/tests/Functional/Fixtures/Entity/DummyWithEmptyProperty.php index aa036c1..fdec761 100644 --- a/tests/Functional/Fixtures/Entity/DummyWithEmptyProperty.php +++ b/tests/Functional/Fixtures/Entity/DummyWithEmptyProperty.php @@ -23,7 +23,7 @@ use Doctrine\ORM\Mapping\Id; #[Entity] -#[Geocodeable] +#[Geocodeable(provider: 'acme')] class DummyWithEmptyProperty { #[Id] diff --git a/tests/Functional/Fixtures/Entity/DummyWithGetter.php b/tests/Functional/Fixtures/Entity/DummyWithGetter.php index c18c9bf..748b36b 100644 --- a/tests/Functional/Fixtures/Entity/DummyWithGetter.php +++ b/tests/Functional/Fixtures/Entity/DummyWithGetter.php @@ -23,7 +23,7 @@ use Doctrine\ORM\Mapping\Id; #[Entity] -#[Geocodeable] +#[Geocodeable(provider: 'acme')] class DummyWithGetter { #[Id] diff --git a/tests/Functional/Fixtures/Entity/DummyWithInvalidGetter.php b/tests/Functional/Fixtures/Entity/DummyWithInvalidGetter.php index 1739bf7..d109dc5 100644 --- a/tests/Functional/Fixtures/Entity/DummyWithInvalidGetter.php +++ b/tests/Functional/Fixtures/Entity/DummyWithInvalidGetter.php @@ -23,7 +23,7 @@ use Doctrine\ORM\Mapping\Id; #[Entity] -#[Geocodeable] +#[Geocodeable(provider: 'acme')] class DummyWithInvalidGetter { #[Id] diff --git a/tests/Functional/Fixtures/Entity/DummyWithProperty.php b/tests/Functional/Fixtures/Entity/DummyWithProperty.php index f58b029..04ad4af 100644 --- a/tests/Functional/Fixtures/Entity/DummyWithProperty.php +++ b/tests/Functional/Fixtures/Entity/DummyWithProperty.php @@ -23,7 +23,7 @@ use Doctrine\ORM\Mapping\Id; #[Entity] -#[Geocodeable] +#[Geocodeable(provider: 'acme')] class DummyWithProperty { #[Id] diff --git a/tests/Functional/Fixtures/Entity/DummyWithStringableGetter.php b/tests/Functional/Fixtures/Entity/DummyWithStringableGetter.php index a3e27f3..c06c4a2 100644 --- a/tests/Functional/Fixtures/Entity/DummyWithStringableGetter.php +++ b/tests/Functional/Fixtures/Entity/DummyWithStringableGetter.php @@ -24,7 +24,7 @@ use Doctrine\ORM\Mapping\Id; #[Entity] -#[Geocodeable] +#[Geocodeable(provider: 'acme')] class DummyWithStringableGetter { #[Id] diff --git a/tests/Functional/GeocoderListenerTest.php b/tests/Functional/GeocodeEntityListenerTest.php similarity index 96% rename from tests/Functional/GeocoderListenerTest.php rename to tests/Functional/GeocodeEntityListenerTest.php index acc8293..a3e68b3 100644 --- a/tests/Functional/GeocoderListenerTest.php +++ b/tests/Functional/GeocodeEntityListenerTest.php @@ -35,7 +35,7 @@ /** * @author Markus Bachmann */ -final class GeocoderListenerTest extends KernelTestCase +final class GeocodeEntityListenerTest extends KernelTestCase { protected function tearDown(): void { @@ -112,7 +112,6 @@ public function testPersistForProperty(): void } $kernel->addTestConfig(__DIR__.'/config/listener.yml'); - $kernel->addTestConfig(__DIR__.'/config/listener_php8.yml'); }]); $container = self::getContainer(); @@ -152,7 +151,6 @@ public function testPersistForGetter(): void } $kernel->addTestConfig(__DIR__.'/config/listener.yml'); - $kernel->addTestConfig(__DIR__.'/config/listener_'.(PHP_VERSION_ID >= 80000 ? 'php8' : 'php7').'.yml'); }]); $container = self::getContainer(); @@ -192,7 +190,6 @@ public function testPersistForStringableGetter(): void } $kernel->addTestConfig(__DIR__.'/config/listener.yml'); - $kernel->addTestConfig(__DIR__.'/config/listener_'.(PHP_VERSION_ID >= 80000 ? 'php8' : 'php7').'.yml'); }]); $container = self::getContainer(); @@ -232,7 +229,6 @@ public function testPersistForInvalidGetter(): void } $kernel->addTestConfig(__DIR__.'/config/listener.yml'); - $kernel->addTestConfig(__DIR__.'/config/listener_'.(PHP_VERSION_ID >= 80000 ? 'php8' : 'php7').'.yml'); }]); $container = self::getContainer(); @@ -265,7 +261,6 @@ public function testPersistForEmptyProperty(): void } $kernel->addTestConfig(__DIR__.'/config/listener.yml'); - $kernel->addTestConfig(__DIR__.'/config/listener_'.(PHP_VERSION_ID >= 80000 ? 'php8' : 'php7').'.yml'); }]); $container = self::getContainer(); @@ -298,7 +293,6 @@ public function testDoesNotGeocodeIfAddressNotChanged(): void } $kernel->addTestConfig(__DIR__.'/config/listener.yml'); - $kernel->addTestConfig(__DIR__.'/config/listener_'.(PHP_VERSION_ID >= 80000 ? 'php8' : 'php7').'.yml'); }]); $httpRequests = 0; diff --git a/tests/Functional/config/listener.yml b/tests/Functional/config/listener.yml index 46d42d2..fd8b50b 100644 --- a/tests/Functional/config/listener.yml +++ b/tests/Functional/config/listener.yml @@ -9,10 +9,22 @@ doctrine: validate_xml_mapping: true naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware auto_mapping: false + mappings: + App: + is_bundle: false + type: attribute + dir: '%kernel.project_dir%/tests/Functional/Fixtures/Entity' + prefix: 'Bazinga\GeocoderBundle\Tests\Functional\Fixtures\Entity' + alias: App bazinga_geocoder: profiling: enabled: false + orm: + enabled: true providers: acme: factory: Bazinga\GeocoderBundle\ProviderFactory\NominatimFactory + options: + root_url: 'https://nominatim.openstreetmap.org' + user_agent: 'geocoder-php test_suite' diff --git a/tests/Functional/config/listener_php8.yml b/tests/Functional/config/listener_php8.yml deleted file mode 100644 index 6057f6c..0000000 --- a/tests/Functional/config/listener_php8.yml +++ /dev/null @@ -1,30 +0,0 @@ -doctrine: - orm: - mappings: - App: - is_bundle: false - type: attribute - dir: '%kernel.project_dir%/tests/Functional/Fixtures/Entity' - prefix: 'Bazinga\GeocoderBundle\Tests\Functional\Fixtures\Entity' - alias: App - -bazinga_geocoder: - profiling: - enabled: false - providers: - acme: - factory: Bazinga\GeocoderBundle\ProviderFactory\NominatimFactory - options: - root_url: 'https://nominatim.openstreetmap.org' - user_agent: 'geocoder-php test_suite' - -services: - Bazinga\GeocoderBundle\Mapping\Driver\AttributeDriver: ~ - - Bazinga\GeocoderBundle\Doctrine\ORM\GeocoderListener: - class: Bazinga\GeocoderBundle\Doctrine\ORM\GeocoderListener - arguments: - - '@bazinga_geocoder.provider.acme' - - '@Bazinga\GeocoderBundle\Mapping\Driver\AttributeDriver' - tags: - - { name: doctrine.event_listener, event: onFlush } diff --git a/tests/Mapping/Driver/AttributeDriverTest.php b/tests/Mapping/Driver/AttributeDriverTest.php index fae06aa..2e3d7c1 100644 --- a/tests/Mapping/Driver/AttributeDriverTest.php +++ b/tests/Mapping/Driver/AttributeDriverTest.php @@ -35,6 +35,7 @@ public function testLoadMetadata(): void { $metadata = $this->driver->loadMetadataFromObject(new Dummy()); + self::assertSame('acme', $metadata->provider); self::assertNotNull($metadata->addressProperty); self::assertSame('address', $metadata->addressProperty->getName()); self::assertNotNull($metadata->latitudeProperty); @@ -47,6 +48,7 @@ public function testLoadMetadataWithAddressGetter(): void { $metadata = $this->driver->loadMetadataFromObject(new DummyWithAddressGetter()); + self::assertSame('acme', $metadata->provider); self::assertNotNull($metadata->addressGetter); self::assertSame('getAddress', $metadata->addressGetter->getName()); } diff --git a/tests/Mapping/Driver/Fixtures/Dummy.php b/tests/Mapping/Driver/Fixtures/Dummy.php index 9745d98..e0d4fca 100644 --- a/tests/Mapping/Driver/Fixtures/Dummy.php +++ b/tests/Mapping/Driver/Fixtures/Dummy.php @@ -17,7 +17,7 @@ use Bazinga\GeocoderBundle\Mapping\Attributes\Latitude; use Bazinga\GeocoderBundle\Mapping\Attributes\Longitude; -#[Geocodeable] +#[Geocodeable(provider: 'acme')] final class Dummy { #[Latitude] diff --git a/tests/Mapping/Driver/Fixtures/DummyWithAddressGetter.php b/tests/Mapping/Driver/Fixtures/DummyWithAddressGetter.php index 9543685..637729f 100644 --- a/tests/Mapping/Driver/Fixtures/DummyWithAddressGetter.php +++ b/tests/Mapping/Driver/Fixtures/DummyWithAddressGetter.php @@ -15,7 +15,7 @@ use Bazinga\GeocoderBundle\Mapping\Attributes\Address; use Bazinga\GeocoderBundle\Mapping\Attributes\Geocodeable; -#[Geocodeable] +#[Geocodeable(provider: 'acme')] final class DummyWithAddressGetter { #[Address]