Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .atoum.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
<?php

use \atoum\atoum;
use Symfony\Component\HttpKernel\Attribute\ValueResolver;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;

$report = $script->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);
}
3 changes: 3 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
201 changes: 201 additions & 0 deletions src/TingBundle/ArgumentResolver/EntityValueResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
<?php

namespace CCMBenchmark\TingBundle\ArgumentResolver;

use CCMBenchmark\Ting\Exception;
use CCMBenchmark\Ting\MetadataRepository;
use CCMBenchmark\Ting\Repository\Metadata;
use CCMBenchmark\Ting\Repository\Repository;
use CCMBenchmark\TingBundle\Attribute\MapEntity;
use CCMBenchmark\TingBundle\Repository\RepositoryFactory;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
* Heavily inspired by https://github.com/symfony/symfony/blob/7.2/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php
*/

final class EntityValueResolver implements ValueResolverInterface
{
private MapEntity $defaults;
public function __construct(
private MetadataRepository $metadataRepository,
private RepositoryFactory $repositoryFactory,
private ?ExpressionLanguage $expressionLanguage = null,
?MapEntity $defaults = null,
) {
$this->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;
}
}
}
72 changes: 72 additions & 0 deletions src/TingBundle/Attribute/MapEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace CCMBenchmark\TingBundle\Attribute;

use CCMBenchmark\TingBundle\ArgumentResolver\EntityValueResolver;
use Symfony\Component\HttpKernel\Attribute\ValueResolver;

/**
* Indicates that a controller argument should receive an Entity.
*/
#[\Attribute(\Attribute::TARGET_PARAMETER)]
class MapEntity extends ValueResolver
{
/**
* @param class-string|null $class The entity class
* @param string|null $expr An expression to fetch the entity using the {@see https://symfony.com/doc/current/components/expression_language.html ExpressionLanguage} syntax.
* Any request attribute are available as a variable, and your entity repository in the 'repository' variable.
* @param array<string, string>|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 = [];
}
}
39 changes: 39 additions & 0 deletions src/TingBundle/DependencyInjection/EntityFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace CCMBenchmark\TingBundle\DependencyInjection;

use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\UserProvider\UserProviderFactoryInterface;
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class EntityFactory implements UserProviderFactoryInterface
{
public function create(ContainerBuilder $container, string $id, array $config): void
{
$container
->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()
;
}
}
Loading