Algunas palabras sobre las especificaciones.

¡Buen día a todos! Sorprendentemente, la mención del patrón "Especificación" en el contexto php es extremadamente rara. Pero con su ayuda no solo puede evitar la explosión combinatoria de métodos de repositorio , sino también mejorar la reutilización del código . Yo, a mi vez, me gustaría detenerme en una oportunidad más que brinda este patrón. Puede ayudar a resolver un problema que ocurre en casi todas las aplicaciones web. Y personalmente, realmente extrañé este conocimiento hace un par de años.







Qué hacemos



Supongamos que estamos desarrollando un rastreador de tareas. La página principal mostrará una lista de tareas. También necesitamos ver una tarea separada.







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,
        ]);
    }
}
      
      





Además, suponga que tenemos 3 tipos de usuarios:







  • Administrador: puede trabajar con todas las tareas.
  • Gerente: solo puede trabajar con las tareas de su proyecto.
  • Desarrollador: solo puede trabajar con las tareas que se le asignen.


Por tanto, es necesario crear un sistema de derechos para que cada tipo de usuario tenga acceso únicamente a las tareas destinadas a él. Se verá algo como esto:







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;
+    }
 }
      
      





Por supuesto, escribir mucho código en el controlador no es bueno. De una forma u otra, puedes distribuirlo entre servicios, usa los votantes estándar de Symfony. Pero el principal problema con este código es que nuestras reglas comerciales se repiten por completo tanto en el método de filtro como en el método isViewable. Y la corrección de este hecho ya no parece tan obvia. ¿Qué puedes hacer al respecto? Necesitamos una abstracción de reglas comerciales que funcione tanto para una lista de elementos como para una sola entidad. Esto es lo que proporciona la plantilla de especificaciones.







Escribir una especificación



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? , ?







Se puede encontrar un ejemplo completo del artículo en github .








All Articles