Bonne journée à tous! De manière surprenante, la mention du modèle "Specification" dans le contexte php est extrêmement rare. Mais avec son aide, vous pouvez non seulement éviter l'explosion combinatoire des méthodes de référentiel , mais également améliorer la réutilisation du code . Je voudrais à mon tour m'attarder sur une autre possibilité offerte par ce modèle. Cela peut aider à résoudre un problème qui se produit dans presque toutes les applications Web. Et personnellement, j'ai vraiment manqué cette connaissance il y a quelques années.
Qu'est-ce qu'on fait
Supposons que nous développons un tracker de tâches. La page principale affichera une liste de tâches. Nous devons également afficher une tâche distincte.
<?php declare(strict_types=1); namespace App\Controller; use App\Entity\Task; use App\Repository\TaskRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; #[Route('/task')] final class TaskController extends AbstractController { #[Route('/', name: 'task_index', methods: ['GET'])] public function index(TaskRepository $taskRepository): Response { return $this->render('task/index.html.twig', [ 'tasks' => $taskRepository->findAll(), ]); } #[Route('/{id}', name: 'task_show', methods: ['GET'])] public function show(Task $task): Response { return $this->render('task/show.html.twig', [ 'task' => $task, ]); } }
De plus, supposons que nous ayons 3 types d'utilisateurs:
- Admin - peut travailler avec toutes les tâches.
- Manager - ne peut travailler qu'avec les tâches de son projet.
- Développeur - ne peut travailler qu'avec les tâches qui lui sont assignées.
Il est donc nécessaire de créer un système de droits pour que chaque type d'utilisateur n'ait accès qu'aux tâches qui lui sont destinées. Cela ressemblera à quelque chose comme ceci:
namespace App\Controller; use App\Entity\Task; +use App\Entity\User; use App\Repository\TaskRepository; +use App\Security\CurrentUserProvider; +use Doctrine\ORM\QueryBuilder; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; #[Route('/task')] final class TaskController extends AbstractController { + public function __construct(private AuthorizationCheckerInterface $authorizationChecker, private CurrentUserProvider $currentUserProvider) + { + } + #[Route('/', name: 'task_index', methods: ['GET'])] public function index(TaskRepository $taskRepository): Response { + $queryBuilder = $taskRepository->createQueryBuilder('t'); + $this->filter($queryBuilder); + return $this->render('task/index.html.twig', [ - 'tasks' => $taskRepository->findAll(), + 'tasks' => $queryBuilder->getQuery() + ->getResult(), ]); } + private function filter(QueryBuilder $queryBuilder): void + { + if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { + return; + } + + $user = $this->currentUserProvider->getUser(); + + if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { + $queryBuilder->andWhere('t.project in(:projects)') + ->setParameter('projects', $user->getProjects()); + + return; + } + + $queryBuilder->andWhere('t.performedBy = :performedBy') + ->setParameter('performedBy', $user); + } + #[Route('/{id}', name: 'task_show', methods: ['GET'])] public function show(Task $task): Response { + if (!$this->isViewable($task)) { + throw new AccessDeniedHttpException(); + } + return $this->render('task/show.html.twig', [ 'task' => $task, ]); } + + private function isViewable(Task $task): bool + { + if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { + return true; + } + + $user = $this->currentUserProvider->getUser(); + + if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { + return $user->getProjects() + ->contains($task->getProject()); + } + + return $task->getPerformedBy() === $user; + } }
, — . , symfony voters. , - filter, isViewable. . ? -, , . "".
2 , php. Happyr/Doctrine-Specification K-Phoen/rulerz. , symfony 5 . , , .
, . . , , , .
<?php declare(strict_types=1); namespace App\Specification; use Doctrine\ORM\QueryBuilder; use Symfony\Component\PropertyAccess\PropertyAccess; abstract class Specification { abstract public function isSatisfiedBy(object $entity): bool; abstract public function generateDql(string $alias): ?string; abstract public function getParameters(): array; public function modifyQuery(QueryBuilder $queryBuilder): void { } public function filter(QueryBuilder $queryBuilder): void { $this->modifyQuery($queryBuilder); $alias = $queryBuilder->getRootAliases()[0]; $dql = $this->generateDql($alias); if (null === $dql) { return; } $queryBuilder->where($dql); foreach ($this->getParameters() as $field => $value) { $queryBuilder->setParameter($field, $value); } } protected function getFieldValue(object $entity, string $field): mixed { return PropertyAccess::createPropertyAccessorBuilder() ->enableExceptionOnInvalidIndex() ->getPropertyAccessor() ->getValue($entity, $field); } }
. filter query builder. getFieldValue
.
, -, . CompositeSpecification.
<?php declare(strict_types=1); namespace App\Specification; use Doctrine\ORM\QueryBuilder; abstract class CompositeSpecification extends Specification { abstract public function getSpecification(): Specification; public function isSatisfiedBy(object $entity): bool { return $this->getSpecification() ->isSatisfiedBy($entity); } public function generateDql(string $alias): ?string { return $this->getSpecification() ->generateDql($alias); } public function getParameters(): array { return $this->getSpecification() ->getParameters(); } public function modifyQuery(QueryBuilder $queryBuilder): void { $this->getSpecification() ->modifyQuery($queryBuilder); } }
, .
<?php declare(strict_types=1); namespace App\Specification; final class AlwaysSpecified extends Specification { public function isSatisfiedBy(object $entity): bool { return true; } public function generateDql(string $alias): ?string { return null; } public function getParameters(): array { return []; } }
<?php declare(strict_types=1); namespace App\Specification; final class Equals extends Specification { public function __construct(private string $field, private mixed $value) { } public function isSatisfiedBy(object $entity): bool { return $this->value === $this->getFieldValue($entity, $this->field); } public function generateDql(string $alias): ?string { return sprintf('%s.%s = :%2$s', $alias, $this->field); } public function getParameters(): array { return [ $this->field => $this->value, ]; } }
<?php declare(strict_types=1); namespace App\Specification; final class MemberOf extends Specification { public function __construct(private string $field, private object $value) { } public function isSatisfiedBy(object $entity): bool { return $this->getFieldValue($entity, $this->field) ->contains($this->value); } public function generateDql(string $alias): ?string { return sprintf(':%2$s member of %1$s.%2$s', $alias, $this->field); } public function getParameters(): array { return [ $this->field => $this->value, ]; } }
<?php declare(strict_types=1); namespace App\Specification; final class Not extends Specification { public function __construct(private Specification $specification) { } public function isSatisfiedBy(object $entity): bool { return !$this->specification ->isSatisfiedBy($entity); } public function generateDql(string $alias): ?string { return sprintf( 'not (%s)', $this->specification->generateDql($alias) ); } public function getParameters(): array { return $this->specification ->getParameters(); } }
. . .
<?php declare(strict_types=1); namespace App\Specification; use Doctrine\ORM\QueryBuilder; final class Join extends Specification { public function __construct(private string $rootAlias, private string $field, private Specification $specification) { } public function isSatisfiedBy(object $entity): bool { return $this->specification ->isSatisfiedBy($this->getFieldValue($entity, $this->field)); } public function generateDql(string $alias): ?string { return $this->specification ->generateDql($this->field); } public function getParameters(): array { return $this->specification ->getParameters(); } public function modifyQuery(QueryBuilder $queryBuilder): void { $queryBuilder->join(sprintf('%s.%s', $this->rootAlias, $this->field), $this->field); $this->specification ->modifyQuery($queryBuilder); } }
-
, , - . .
<?php declare(strict_types=1); namespace App\Specification\Task; use App\Entity\User; use App\Security\CurrentUserProvider; use App\Specification\AlwaysSpecified; use App\Specification\CompositeSpecification; use App\Specification\Equals; use App\Specification\Join; use App\Specification\MemberOf; use App\Specification\Specification; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; final class IsViewable extends CompositeSpecification { public function __construct(private AuthorizationCheckerInterface $authorizationChecker, private CurrentUserProvider $currentUserProvider) { } public function getSpecification(): Specification { if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { return new AlwaysSpecified(); } $user = $this->currentUserProvider->getUser(); if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { $isProjectMember = new MemberOf('members', $user); return new Join('task', 'project', $isProjectMember); } return new Equals('performedBy', $user); } }
.
namespace App\Controller; use App\Entity\Task; -use App\Entity\User; use App\Repository\TaskRepository; -use App\Security\CurrentUserProvider; -use Doctrine\ORM\QueryBuilder; +use App\Specification\Task\IsViewable; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Annotation\Route; -use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; #[Route('/task')] final class TaskController extends AbstractController { - public function __construct(private AuthorizationCheckerInterface $authorizationChecker, private CurrentUserProvider $currentUserProvider) + public function __construct(private IsViewable $isViewable) { } @@ -26,7 +23,7 @@ final class TaskController extends AbstractController public function index(TaskRepository $taskRepository): Response { $queryBuilder = $taskRepository->createQueryBuilder('t'); - $this->filter($queryBuilder); + $this->isViewable->filter($queryBuilder); return $this->render('task/index.html.twig', [ 'tasks' => $queryBuilder->getQuery() @@ -34,29 +31,10 @@ final class TaskController extends AbstractController ]); } - private function filter(QueryBuilder $queryBuilder): void - { - if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { - return; - } - - $user = $this->currentUserProvider->getUser(); - - if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { - $queryBuilder->andWhere('t.project in(:projects)') - ->setParameter('projects', $user->getProjects()); - - return; - } - - $queryBuilder->andWhere('t.performedBy = :performedBy') - ->setParameter('performedBy', $user); - } - #[Route('/{id}', name: 'task_show', methods: ['GET'])] public function show(Task $task): Response { - if (!$this->isViewable($task)) { + if (!$this->isViewable->isSatisfiedBy($task)) { throw new AccessDeniedHttpException(); } @@ -64,20 +42,4 @@ final class TaskController extends AbstractController 'task' => $task, ]); } - - private function isViewable(Task $task): bool - { - if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { - return true; - } - - $user = $this->currentUserProvider->getUser(); - - if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { - return $user->getProjects() - ->contains($task->getProject()); - } - - return $task->getPerformedBy() === $user; - } }
! . ?
, , "archived".
use App\Entity\User; use App\Security\CurrentUserProvider; use App\Specification\AlwaysSpecified; +use App\Specification\AndX; use App\Specification\CompositeSpecification; use App\Specification\Equals; use App\Specification\Join; use App\Specification\MemberOf; +use App\Specification\Not; +use App\Specification\Project\IsArchived; use App\Specification\Specification; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; @@ -26,14 +29,23 @@ final class IsViewable extends CompositeSpecification return new AlwaysSpecified(); } + $isNotArchived = new Not(new IsArchived()); $user = $this->currentUserProvider->getUser(); if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { $isProjectMember = new MemberOf('members', $user); - return new Join('task', 'project', $isProjectMember); + return $this->getProjectSpecification(new AndX($isNotArchived, $isProjectMember)); } - return new Equals('performedBy', $user); + return new AndX( + new Equals('performedBy', $user), + $this->getProjectSpecification($isNotArchived) + ); + } + + private function getProjectSpecification(Specification $specification): Join + { + return new Join('task', 'project', $specification); } }
. , . . . . . — - , . . , - .
, ? php? , ?
Un exemple complet de l'article peut être trouvé sur github .