diff --git a/.atoum.php b/.atoum.php
index 17cbc1a..54030a8 100644
--- a/.atoum.php
+++ b/.atoum.php
@@ -1,10 +1,28 @@
addDefaultReport();
$coverageField = new atoum\report\fields\runner\coverage\html('Ting', __DIR__ . '/tests/coverage/');
$script->noCodeCoverageForClasses('Symfony\Component\Validator\Constraint', 'Symfony\Component\Validator\ConstraintValidator', 'Symfony\Component\DependencyInjection\Extension\Extension', 'Symfony\Component\HttpKernel\DependencyInjection\Extension');
$coverageField->setRootUrl('file://' . __DIR__ . '/tests/coverage/index.html');
$report->addField($coverageField);
-$runner->addTestsFromDirectory('tests/units');
+/**
+ * @var $runner \atoum\atoum\scripts\runner
+ */
+$testsDirectory = __DIR__ . '/tests/units/TingBundle';
+$subDirectories = glob($testsDirectory . '/*', GLOB_ONLYDIR);
+$files = glob($testsDirectory . '/*.php');
+
+if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) {
+ // Exclude ArgumentResolver from tests, available only in SF6+
+ $subDirectories = array_filter($subDirectories, fn($directory) => $directory !== $testsDirectory . '/ArgumentResolver' );
+}
+foreach ($subDirectories as $directory) {
+ $runner->addTestsFromPattern($directory . '/*');
+}
+foreach ($files as $file) {
+ $runner->addTestsFromPattern($files);
+}
diff --git a/CHANGELOG b/CHANGELOG
index 1cfbce7..8cb7d0c 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,6 @@
+3.9.0 (unreleased):
+ * Feature: (SF6.2+) Supports automatic value resolving in controllers (with or without MapEntity), see [https://github.com/symfony/symfony/blob/7.2/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php](Doctrine Bridge)
+
3.8.1 (2025-01-06):
* PHP 8.4: Implicit nullable parameters are now deprecated
* Add return type matching SF 7 deprecation
diff --git a/composer.json b/composer.json
index c253062..fbc5925 100644
--- a/composer.json
+++ b/composer.json
@@ -16,7 +16,10 @@
"require-dev": {
"atoum/atoum": "^4.2",
"atoum/stubs": "^2.2",
- "brick/geo": ">=0.5 <=1.0"
+ "brick/geo": ">=0.5 <=1.0",
+ "symfony/expression-language": "^6.3 || ^7.2",
+ "symfony/uid": "^6.0 || ^7.0",
+ "symfony/security-bundle": "^6.0 || ^7.0"
},
"autoload": {
"psr-4" : {
diff --git a/src/TingBundle/ArgumentResolver/EntityValueResolver.php b/src/TingBundle/ArgumentResolver/EntityValueResolver.php
new file mode 100644
index 0000000..bef55fc
--- /dev/null
+++ b/src/TingBundle/ArgumentResolver/EntityValueResolver.php
@@ -0,0 +1,201 @@
+defaults = $defaults ?? new MapEntity();
+ }
+
+ public function resolve(Request $request, ArgumentMetadata $argument): array
+ {
+ if (\is_object($request->attributes->get($argument->getName()))) {
+ return [];
+ }
+
+ $options = $argument->getAttributes(MapEntity::class, ArgumentMetadata::IS_INSTANCEOF);
+ $options = ($options[0] ?? $this->defaults)->withDefaults($this->defaults, $argument->getType());
+
+ if (!$options->class || $options->disabled) {
+ return [];
+ }
+ $repository = null;
+
+ $this->metadataRepository->findMetadataForEntity($options->class, function (Metadata $metadata) use (&$repository) {
+ $repository = $this->repositoryFactory->get($metadata->getRepository());
+ }, fn () => null);
+ if ($repository === null) {
+ return [];
+ }
+
+ $message = '';
+ if (null !== $options->expr) {
+ if (null === $object = $this->findViaExpression($repository, $request, $options)) {
+ $message = \sprintf(' The expression "%s" returned null.', $options->expr);
+ }
+ // find by identifier?
+ } elseif (false === $object = $this->find($repository, $request, $options, $argument)) {
+ // find by criteria
+ if (!$criteria = $this->getCriteria($request, $options, $argument)) {
+ return [];
+ }
+ try {
+ $object = $repository->getOneBy($criteria);
+ } catch (Exception $e) {
+ $object = null;
+ }
+ }
+
+ if (null === $object && !$argument->isNullable()) {
+ throw new NotFoundHttpException($options->message ?? (\sprintf('"%s" object not found by "%s".', $options->class, self::class).$message));
+ }
+
+ return [$object];
+ }
+
+ private function find(Repository $repository, Request $request, MapEntity $options, ArgumentMetadata $argument): false|object|null
+ {
+ if ($options->mapping || $options->exclude) {
+ return false;
+ }
+
+ $id = $this->getIdentifier($request, $options, $argument);
+ if (false === $id || null === $id) {
+ return $id;
+ }
+ if (\is_array($id) && \in_array(null, $id, true)) {
+ return null;
+ }
+ try {
+ return $repository->get($id, $options->forcePrimary);
+ } catch (Exception $e) {
+ return null;
+ }
+ }
+
+ private function getIdentifier(Request $request, MapEntity $options, ArgumentMetadata $argument): mixed
+ {
+ if (\is_array($options->id)) {
+ $id = [];
+ foreach ($options->id as $field) {
+ // Convert "%s_uuid" to "foobar_uuid"
+ if (str_contains($field, '%s')) {
+ $field = \sprintf($field, $argument->getName());
+ }
+
+ $id[$field] = $request->attributes->get($field);
+ }
+
+ return $id;
+ }
+
+ if ($options->id) {
+ return $request->attributes->get($options->id) ?? ($options->stripNull ? false : null);
+ }
+
+ $name = $argument->getName();
+
+ if ($request->attributes->has($name)) {
+ if (\is_array($id = $request->attributes->get($name))) {
+ return false;
+ }
+
+ foreach ($request->attributes->get('_route_mapping') ?? [] as $parameter => $attribute) {
+ if ($name === $attribute) {
+ $options->mapping = [$name => $parameter];
+
+ return false;
+ }
+ }
+
+ return $id ?? ($options->stripNull ? false : null);
+ }
+
+ if ($request->attributes->has('id')) {
+ return $request->attributes->get('id') ?? ($options->stripNull ? false : null);
+ }
+
+ return false;
+ }
+
+ private function getCriteria(Request $request, MapEntity $options, ArgumentMetadata $argument): array
+ {
+ if (!($mapping = $options->mapping) && \is_array($criteria = $request->attributes->get($argument->getName()))) {
+ foreach ($options->exclude as $exclude) {
+ unset($criteria[$exclude]);
+ }
+
+ if ($options->stripNull) {
+ $criteria = array_filter($criteria, static fn ($value) => null !== $value);
+ }
+
+ return $criteria;
+ } elseif (null === $mapping) {
+ throw new \RuntimeException("Auto-mapping is not supported for Ting entities. Declare the identifier using either the #[MapEntity] attribute or mapped route parameters.");
+ }
+
+ if ($mapping && array_is_list($mapping)) {
+ $mapping = array_combine($mapping, $mapping);
+ }
+
+ foreach ($options->exclude as $exclude) {
+ unset($mapping[$exclude]);
+ }
+ if (!$mapping) {
+ return [];
+ }
+
+ $criteria = [];
+
+ foreach ($mapping as $attribute => $field) {
+ $criteria[$field] = $request->attributes->get($attribute);
+ }
+
+ if ($options->stripNull) {
+ $criteria = array_filter($criteria, static fn ($value) => null !== $value);
+ }
+
+ return $criteria;
+ }
+
+ private function findViaExpression(Repository $repository, Request $request, MapEntity $options): object|iterable|null
+ {
+ if (!$this->expressionLanguage) {
+ throw new \LogicException(\sprintf('You cannot use the "%s" if the ExpressionLanguage component is not available. Try running "composer require symfony/expression-language".', __CLASS__));
+ }
+
+ $variables = array_merge($request->attributes->all(), [
+ 'repository' => $repository,
+ 'request' => $request,
+ ]);
+
+ try {
+ return $this->expressionLanguage->evaluate($options->expr, $variables);
+ } catch (Exception $e) {
+ return null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/TingBundle/Attribute/MapEntity.php b/src/TingBundle/Attribute/MapEntity.php
new file mode 100644
index 0000000..1cf2be5
--- /dev/null
+++ b/src/TingBundle/Attribute/MapEntity.php
@@ -0,0 +1,72 @@
+|null $mapping Configures the properties and values to use with the getOneBy() method
+ * The key is the route placeholder name and the value is the property name
+ * @param string[]|null $exclude Configures the properties that should be used in the getOneBy() method by excluding
+ * one or more properties so that not all are used
+ * @param bool|null $stripNull Whether to prevent null values from being used as parameters in the query (defaults to false)
+ * @param string[]|string|null $id If an id option is configured and matches a route parameter, then the resolver will find by the primary key
+ * @param bool $forcePrimary If true, forces Ting to always use the primary / master to retrieve the object
+ */
+ public function __construct(
+ public ?string $class = null,
+ public ?string $expr = null,
+ public ?array $mapping = null,
+ public ?array $exclude = null,
+ public ?bool $stripNull = null,
+ public array|string|null $id = null,
+ public bool $forcePrimary = false,
+ bool $disabled = false,
+ string $resolver = EntityValueResolver::class,
+ public ?string $message = null,
+ ) {
+ parent::__construct($resolver, $disabled);
+ $this->selfValidate();
+ }
+
+ public function withDefaults(self $defaults, ?string $class): static
+ {
+ $clone = clone $this;
+ $clone->class ??= class_exists($class ?? '') ? $class : null;
+ $clone->expr ??= $defaults->expr;
+ $clone->mapping ??= $defaults->mapping;
+ $clone->exclude ??= $defaults->exclude ?? [];
+ $clone->stripNull ??= $defaults->stripNull ?? false;
+ $clone->id ??= $defaults->id;
+ $clone->forcePrimary ??= $defaults->forcePrimary ?? false;
+ $clone->message ??= $defaults->message;
+
+ $clone->selfValidate();
+
+ return $clone;
+ }
+
+ private function selfValidate(): void
+ {
+ if (!$this->id) {
+ return;
+ }
+ if ($this->mapping) {
+ throw new \LogicException('The "id" and "mapping" options cannot be used together on #[MapEntity] attributes.');
+ }
+ if ($this->exclude) {
+ throw new \LogicException('The "id" and "exclude" options cannot be used together on #[MapEntity] attributes.');
+ }
+ $this->mapping = [];
+ }
+}
\ No newline at end of file
diff --git a/src/TingBundle/DependencyInjection/EntityFactory.php b/src/TingBundle/DependencyInjection/EntityFactory.php
new file mode 100644
index 0000000..6ab1088
--- /dev/null
+++ b/src/TingBundle/DependencyInjection/EntityFactory.php
@@ -0,0 +1,39 @@
+setDefinition($id, new ChildDefinition('ting.security.user_provider'))
+ ->addArgument($config['class'])
+ ->addArgument($config['property'])
+ ;
+ }
+
+ public function getKey(): string
+ {
+ return 'ting';
+ }
+
+ public function addConfiguration(NodeDefinition $builder): void
+ {
+ $builder
+ ->children()
+ ->scalarNode('class')
+ ->isRequired()
+ ->info('The full entity class name of your user class.')
+ ->cannotBeEmpty()
+ ->end()
+ ->scalarNode('property')->defaultNull()->end()
+ ->end()
+ ;
+ }
+}
diff --git a/src/TingBundle/DependencyInjection/TingExtension.php b/src/TingBundle/DependencyInjection/TingExtension.php
index f54e6f3..85ff5e7 100644
--- a/src/TingBundle/DependencyInjection/TingExtension.php
+++ b/src/TingBundle/DependencyInjection/TingExtension.php
@@ -25,6 +25,7 @@
namespace CCMBenchmark\TingBundle\DependencyInjection;
use CCMBenchmark\Ting\Repository\Metadata;
+use CCMBenchmark\TingBundle\ArgumentResolver\EntityValueResolver;
use CCMBenchmark\TingBundle\Schema\Column;
use CCMBenchmark\TingBundle\Schema\Table;
use Doctrine\Common\Cache\VoidCache;
@@ -37,6 +38,8 @@
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\DependencyInjection\Reference;
+use Symfony\Component\HttpKernel\Attribute\ValueResolver;
+use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\Uid\Uuid;
@@ -129,6 +132,20 @@ public function load(array $configs, ContainerBuilder $container): void
$definition = $container->getDefinition('ting.cache_data_collector');
$definition->addMethodCall('setCacheLogger', [$reference]);
}
+
+ if (interface_exists(ValueResolverInterface::class) && class_exists(ValueResolver::class)) {
+ $definition = new Definition(EntityValueResolver::class);
+
+ $definition->setArguments([
+ new Reference('ting.metadatarepository'),
+ new Reference('ting'),
+ (new Reference('Symfony\Component\ExpressionLanguage\ExpressionLanguage', ContainerInterface::NULL_ON_INVALID_REFERENCE))
+ ]);
+
+ $definition->addTag('controller.argument_value_resolver', ['priority' => 110]);
+
+ $container->setDefinition('ting.entity_value_resolver', $definition);
+ }
}
/**
diff --git a/src/TingBundle/Resources/config/services.xml b/src/TingBundle/Resources/config/services.xml
index 9f6caca..b8674a2 100644
--- a/src/TingBundle/Resources/config/services.xml
+++ b/src/TingBundle/Resources/config/services.xml
@@ -106,5 +106,10 @@
+
+
+
+
+
diff --git a/src/TingBundle/Security/EntityUserProvider.php b/src/TingBundle/Security/EntityUserProvider.php
new file mode 100644
index 0000000..63659a1
--- /dev/null
+++ b/src/TingBundle/Security/EntityUserProvider.php
@@ -0,0 +1,137 @@
+
+ */
+class EntityUserProvider implements UserProviderInterface, PasswordUpgraderInterface
+{
+ public function __construct(
+ private readonly MetadataRepository $metadataRepository,
+ private readonly RepositoryFactory $repositoryFactory,
+ private readonly string $class,
+ private readonly ?string $property = null,
+ ) {
+ }
+
+ public function loadUserByIdentifier(string $identifier): UserInterface
+ {
+ $repository = $this->getRepository();
+ if (null !== $this->property) {
+ $user = $repository->getOneBy([$this->property => $identifier]);
+ } else {
+ if (!$repository instanceof UserLoaderInterface) {
+ throw new \InvalidArgumentException(\sprintf('You must either make the "%s" entity Ting Repository ("%s") implement "CCMBenchmark\TingBundle\Security\UserLoaderInterface" or set the "property" option in the corresponding entity provider configuration.', $this->class, get_debug_type($repository)));
+ }
+
+ $user = $repository->loadUserByIdentifier($identifier);
+ }
+
+ if (null === $user) {
+ $e = new UserNotFoundException(\sprintf('User "%s" not found.', $identifier));
+ $e->setUserIdentifier($identifier);
+
+ throw $e;
+ }
+
+ return $user;
+ }
+
+ public function refreshUser(UserInterface $user): UserInterface
+ {
+ if (!$user instanceof $this->class) {
+ throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', get_debug_type($user)));
+ }
+
+ $repository = $this->getRepository();
+ if ($repository instanceof UserProviderInterface) {
+ $refreshedUser = $repository->refreshUser($user);
+ } else {
+ // The user must be reloaded via the primary key as all other data
+ // might have changed without proper persistence in the database.
+ // That's the case when the user has been changed by a form with
+ // validation errors.
+ if (!$id = $this->getIdentifierValues($user)) {
+ throw new \InvalidArgumentException('You cannot refresh a user from the EntityUserProvider that does not contain an identifier. The user object has to be serialized with its own identifier mapped by Doctrine.');
+ }
+
+ $refreshedUser = $repository->get($id);
+ if (null === $refreshedUser) {
+ $e = new UserNotFoundException('User with id '.json_encode($id).' not found.');
+ $e->setUserIdentifier(json_encode($id));
+
+ throw $e;
+ }
+ }
+
+ return $refreshedUser;
+ }
+
+ public function supportsClass(string $class): bool
+ {
+ return $class === $this->class || is_subclass_of($class, $this->class);
+ }
+
+ /**
+ * @final
+ */
+ public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
+ {
+ if (!$user instanceof $this->class) {
+ throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', get_debug_type($user)));
+ }
+
+ $repository = $this->getRepository();
+ if ($repository instanceof PasswordUpgraderInterface) {
+ $repository->upgradePassword($user, $newHashedPassword);
+ }
+ }
+
+ private function getMetadata(): Metadata
+ {
+ $metadata = null;
+ $this->metadataRepository->findMetadataForEntity($this->class, function (Metadata $innerMetadata) use (&$metadata) {
+ $metadata = $innerMetadata;
+ }, fn () => null);
+
+ if ($metadata === null) {
+ throw new \InvalidArgumentException(\sprintf('No metadata found for entity "%s".', $this->class));
+ }
+
+ return $metadata;
+ }
+
+ private function getRepository(): Repository
+ {
+ return $this->repositoryFactory->get($this->getMetadata()->getRepository());
+ }
+
+ private function getIdentifierValues($user): ?array
+ {
+ $metadata = $this->getMetadata();
+ $primaries = $metadata->getPrimaries();
+ if ($primaries === []) {
+ return null;
+ }
+ $identifierValues = [];
+ foreach ($primaries as $primary) {
+ $identifierValues[$primary] = $metadata->getEntityPropertyByFieldName($user, $primary);
+ }
+
+ return $identifierValues;
+ }
+}
diff --git a/src/TingBundle/Security/UserLoaderInterface.php b/src/TingBundle/Security/UserLoaderInterface.php
new file mode 100644
index 0000000..4064174
--- /dev/null
+++ b/src/TingBundle/Security/UserLoaderInterface.php
@@ -0,0 +1,15 @@
+hasExtension('security')) {
+ $container->getExtension('security')->addUserProviderFactory(new EntityFactory());
+ }
+ }
}
diff --git a/tests/fixtures/EntityWithAttributesRepository.php b/tests/fixtures/EntityWithAttributesRepository.php
index f26f8c3..f1e6a2f 100644
--- a/tests/fixtures/EntityWithAttributesRepository.php
+++ b/tests/fixtures/EntityWithAttributesRepository.php
@@ -1,6 +1,6 @@
metadataRepository = new \mock\CCMBenchmark\Ting\MetadataRepository(new SerializerFactory());
+ $connectionPool = new ConnectionPool();
+ $queryFactory = new QueryFactory();
+ $unitOfWork = new UnitOfWork($connectionPool, $this->metadataRepository, $queryFactory);
+ $hydrator = new Hydrator();
+ $this->repositoryFactory = new \mock\CCMBenchmark\TingBundle\Repository\RepositoryFactory(
+ $connectionPool,
+ $this->metadataRepository,
+ new QueryFactory(),
+ new CollectionFactory($this->metadataRepository, $unitOfWork, $hydrator),
+ $unitOfWork,
+ new \CCMBenchmark\Ting\Cache\Cache(),
+ new SerializerFactory()
+ );
+ $this->expressionLanguage = new \mock\Symfony\Component\ExpressionLanguage\ExpressionLanguage();
+
+ $this->resolver = new \CCMBenchmark\TingBundle\ArgumentResolver\EntityValueResolver(
+ $this->metadataRepository,
+ $this->repositoryFactory,
+ $this->expressionLanguage
+ );
+ }
+
+ public function testReturnsEmptyArrayWhenArgumentIsAlreadyObject(): void
+ {
+ if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) {
+ return;
+ }
+ $this
+ ->given($request = new Request(['argumentName' => new \stdClass()]))
+ ->and($argument = new ArgumentMetadata('argumentName', null, false, false, null))
+
+ ->when($result = $this->resolver->resolve($request, $argument))
+
+ ->then
+ ->array($result)
+ ->isEmpty();
+ }
+
+ public function testReturnsEmptyArrayWhenMapEntityIsDisabled(): void
+ {
+ if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) {
+ return;
+ }
+ $this
+ ->given($mapEntity = new MapEntity(class: 'TestEntity', disabled: true))
+ ->and($argument = $this->createArgumentMetadataForAttributes($mapEntity))
+ ->and($request = new Request())
+
+ ->when($result = $this->resolver->resolve($request, $argument))
+
+ ->then
+ ->array($result)
+ ->isEmpty();
+ }
+
+ public function testWithRouteMapping(): void
+ {
+ if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) {
+ return;
+ }
+ $this
+ ->given($argumentCity = $this->createArgumentMetadataForAttributes(new MapEntity(class: 'City'), 'city'))
+ ->and($argumentCountry = $this->createArgumentMetadataForAttributes(new MapEntity(class: 'Country'), 'country'))
+ ->and($request = new Request(attributes: ['city' => 'Paris', 'country' => 'France', '_route_mapping' => ['slug' => 'city', 'country' => 'country']]))
+ ->and($city = new \stdClass())
+ ->and($country = new \stdClass())
+
+ ->and($repository = new \mock\tests\fixtures\SimpleRepository())
+ ->and($this->calling($repository)->getOneBy = static fn($criteria) => match($criteria) {
+ ['slug' => 'Paris'] => $city,
+ ['country' => 'France'] => $country,
+ } )
+ ->and($this->mockRepositoryFactoryAndMetadata($repository))
+
+ ->when($result = $this->resolver->resolve($request, $argumentCity))
+ ->then
+ ->array($result)
+ ->hasSize(1)
+ ->contains($city)
+ ->when($result = $this->resolver->resolve($request, $argumentCountry))
+ ->then
+ ->array($result)
+ ->hasSize(1)
+ ->contains($country)
+ ;
+ }
+
+ public function testThrowsNotFoundHttpExceptionWhenObjectCannotBeFound(): void
+ {
+ if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) {
+ return;
+ }
+ $simpleRepository = new \mock\tests\fixtures\SimpleRepository();
+ $this->calling($simpleRepository)->get = null;
+ $this
+ ->given($mapEntity = new MapEntity(class: 'TestEntity', id: 'id'))
+ ->and($argument = $this->createArgumentMetadataForAttributes($mapEntity))
+ ->and($request = new Request(attributes: ['id' => 123]))
+
+ ->and($this->calling($this->metadataRepository)->findMetadataForEntity =
+ fn($class, $success) => $success(new \mock\CCMBenchmark\Ting\Repository\Metadata(new SerializerFactory())))
+
+ ->and($this->calling($this->repositoryFactory)->get = $simpleRepository)
+
+ ->exception(function () use ($request, $argument) {
+ $this->resolver->resolve($request, $argument);
+ })
+ ->isInstanceOf(NotFoundHttpException::class);
+ }
+
+ public function testReturnsObjectWhenFoundInRepository()
+ {
+ if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) {
+ return;
+ }
+ $this
+ ->given($mapEntity = new MapEntity(class: 'TestEntity', id: 'id'))
+ ->and($argument = $this->createArgumentMetadataForAttributes($mapEntity))
+ ->and($request = new Request(attributes: ['id' => 123]))
+
+ ->and($repository = new \mock\tests\fixtures\SimpleRepository())
+ ->and($this->calling($repository)->get = $object = new \stdClass())
+ ->and($this->mockRepositoryFactoryAndMetadata($repository))
+
+ ->when($result = $this->resolver->resolve($request, $argument))
+
+ ->then
+ ->array($result)
+ ->hasSize(1)
+ ->contains($object);
+ }
+
+ public function testEvaluatesExpressionWhenProvided()
+ {
+ if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) {
+ return;
+ }
+ $this
+ ->given($mapEntity = new MapEntity(class: 'TestEntity', expr: 'repository.find(id)'))
+ ->and($argument = $this->createArgumentMetadataForAttributes($mapEntity))
+ ->and($request = new Request(attributes: ['id' => 123]))
+
+ ->and($repository = new \mock\tests\fixtures\SimpleRepository())
+ ->and($this->mockRepositoryFactoryAndMetadata($repository))
+ ->and($this->calling($this->expressionLanguage)->evaluate = $object = new \stdClass())
+
+ ->when($result = $this->resolver->resolve($request, $argument))
+
+ ->then
+ ->array($result)
+ ->hasSize(1)
+ ->contains($object);
+ }
+
+ public function testExpressionFailureReturns404()
+ {
+ if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) {
+ return;
+ }
+ $this
+ ->given($mapEntity = new MapEntity(class: 'TestEntity', expr: 'repository.find(id)'))
+ ->and($argument = $this->createArgumentMetadataForAttributes($mapEntity))
+ ->and($request = new Request(attributes: ['id' => 123]))
+
+ ->and($repository = new \mock\tests\fixtures\SimpleRepository())
+ ->and($this->mockRepositoryFactoryAndMetadata($repository))
+ ->and($this->calling($this->expressionLanguage)->evaluate = null)
+
+ ->exception(function () use ($request, $argument) {
+ $this->resolver->resolve($request, $argument);
+ })
+ ->isInstanceOf(NotFoundHttpException::class);
+ }
+
+ public function testReturnsEmptyArrayWhenCriteriaCannotBeDetermined()
+ {
+ if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) {
+ return;
+ }
+ $this
+ ->given($mapEntity = new MapEntity(class: 'TestEntity', mapping: []))
+ ->and($argument = $this->createArgumentMetadataForAttributes($mapEntity))
+ ->and($request = new Request())
+
+ ->and($repository = new \mock\tests\fixtures\SimpleRepository())
+ ->and($this->calling($repository)->getOneBy->doesNothing())
+ ->and($this->mockRepositoryFactoryAndMetadata($repository))
+
+ ->when($result = $this->resolver->resolve($request, $argument))
+
+ ->then
+ ->array($result)
+ ->isEmpty();
+ }
+
+ public function testReturnsObjectWhenCriteriaCanBeDetermined()
+ {
+ if (!interface_exists(ValueResolverInterface::class) || !class_exists(ValueResolver::class)) {
+ return;
+ }
+ $this
+ ->given($mapEntity = new MapEntity(class: 'TestEntity', mapping: ['name' => 'name', 'id' => 'id']))
+ ->and($argument = $this->createArgumentMetadataForAttributes($mapEntity))
+ ->and($request = new Request(attributes: ['id' => 123, 'name' => 'foo']))
+
+ ->and($repository = new \mock\tests\fixtures\SimpleRepository())
+ ->and($this->calling($repository)->getOneBy = $object = new \stdClass())
+ ->and($this->mockRepositoryFactoryAndMetadata($repository))
+
+ ->when($result = $this->resolver->resolve($request, $argument))
+
+ ->then
+ ->array($result)
+ ->hasSize(1)
+ ->contains($object);
+ }
+
+ private function createArgumentMetadataForAttributes(MapEntity $attribute, string $argumentName = 'argumentName'): ArgumentMetadata
+ {
+ return new ArgumentMetadata($argumentName, $attribute->class, false, false, null, false, [$attribute]);
+ }
+
+ private function mockRepositoryFactoryAndMetadata($repository): void
+ {
+ $metadata = new \mock\CCMBenchmark\Ting\Repository\Metadata(new SerializerFactory());
+ $this->calling($metadata)->getRepository = 'RepositoryClass';
+
+ $this->calling($this->metadataRepository)->findMetadataForEntity =
+ fn($class, $success) => $success($metadata);
+
+ $this->calling($this->repositoryFactory)->get = $repository;
+ }
+}
\ No newline at end of file
diff --git a/tests/units/TingBundle/DependencyInjection/TingExtension.php b/tests/units/TingBundle/DependencyInjection/TingExtension.php
index d15cec1..a9b64dd 100644
--- a/tests/units/TingBundle/DependencyInjection/TingExtension.php
+++ b/tests/units/TingBundle/DependencyInjection/TingExtension.php
@@ -32,15 +32,6 @@
class TingExtension extends \atoum
{
- public function testEmpty()
- {
- // Minimum test to ensure code execution
- $this
- ->if($containerBuilder = new ContainerBuilder(new ParameterBag(['kernel.debug' => false])))
- ->then($this->newTestedInstance->load([], $containerBuilder))
- ;
- }
-
public function testAutoConfigurationWithAttributes()
{
$fixtureInstance = new Definition(EntityWithAttributes::class);
@@ -86,4 +77,15 @@ public function testAutoConfigurationWithAttributes()
])
;
}
+
+ public function testContainerShouldDeclareValueResolverIfAvailable()
+ {
+ $this
+ ->if($containerBuilder = new ContainerBuilder(new ParameterBag(['kernel.debug' => false])))
+ ->then($this->newTestedInstance->load([], $containerBuilder))
+ ->and($shouldBeDeclared = interface_exists(ValueResolverInterface::class) && class_exists(ValueResolver::class))
+ ->boolean($containerBuilder->hasDefinition('ting.argumentvalueresolver'))
+ ->isEqualTo($shouldBeDeclared)
+ ;
+ }
}