Quelques mots sur les spécifications

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.







TaskController.php
<?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:







TaskController.php
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 . , , .







, . . , , , .







Specification.php
<?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.







CompositeSpecification.php
<?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);
    }
}
      
      





, .







AlwaysSpecified.php
<?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 [];
    }
}
      
      





Equals.php
<?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,
        ];
    }
}
      
      





MemberOf.php
<?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,
        ];
    }
}
      
      





Not.php
<?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();
    }
}
      
      





. . .







Join.php
<?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);
    }
}
      
      





-



, , - . .







IsViewable.php
<?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);
    }
}
      
      





.







TaskController.php
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".







IsViewable.php
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 .








All Articles