mediagone/doctrine-specifications

Doctrine 实现的仓库规范模式

0.5.0 2024-04-22 14:37 UTC

README

Latest Version on Packagist Total Downloads Software License

你可能已经积累了杂乱无章的仓库或重复的准则,这使得编写或维护查询变得困难。

但如果你看到查询是这样的呢?

// Find all articles written by a given user
$articles = $repository->find(
    ManyArticles::asEntity()
        ->postedByUser($userId)
);

或者也

// Find all published articles in a given category
$articles = $repository->find(
    ManyArticles::asEntity()
    ->published()
    ->inCategory($categoryId)
    ->orderedByDateDesc()
    ->paginate($pageNumber, $itemsPerPage)
);

如果你喜欢它,你可能需要这个包 ;)

准则的组合是无限的,无需任何代码重复。
它还将使自定义 读取模型 (DTOs) 的激活变得容易。

摘要

  1. 介绍
  2. 使用示例
  3. 扩展用途
    1. 返回格式
    2. 连接
    3. 读取模型
    4. 使用多个实体管理器
    5. 命令总线
  4. 泛型规范
    1. 选择规范
    2. 过滤规范
    3. 附加规范
    4. 调试规范
  5. 命名和组织规范

安装

此包需要 PHP 7.4+ 和 Doctrine ORM 2.7+

将其作为 Composer 依赖项添加

$ composer require mediagone/doctrine-specifications

介绍

经典的 仓库模式 (每个实体一个类,包含多个方法,每个查询一个方法)随着其向一个混乱的类增长,很快就会显示出其局限性。

使用 查询函数 通过将查询拆分成单独的类部分解决了问题,但你可能仍然会有很多代码重复。如果查询准则可以任意组合,事情会变得更糟,这可能会导致创建指数级数量的类。

此时,规范模式 就能救命,帮助你将它们拆分成显式和可重用的过滤器,提高数据库查询的可用性和可测试性。此包是这个模式的定制版本(对于纯粹主义者),受 Benjamin Eberlei 的 文章 启发。它围绕一个简单的概念:规范
每个 规范 定义了一组将要自动应用到 Doctrine 的 QueryBuilder 和 Query 对象上的准则,在两个方法的帮助下:

abstract class Specification
{
    public function modifyBuilder(QueryBuilder $builder) : void { }
    public function modifyQuery(Query $query) : void { }
}

规范可以自由组合来构建复杂的查询,同时保持 易于测试和维护

使用示例

我们将一起学习如何创建以下查询

$articles = $repository->find(
    ManyArticles::asEntity()
        ->postedByUser($userId)
        ->orderedAlphabetically()
        ->maxCount(5)
);

每个方法将查询拆分成单独的 "规范"

  • asEntity => SelectArticleEntity 规范
  • postedByUser => FilterArticlePostedBy 规范
  • orderedAlphabetically => OrderArticleAlphabetically 规范
  • maxCount => LimitMaxCount 规范

这将导致这个干净的查询类

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            SelectEntity::specification(Article::class, 'article'),
        );
    }
    
    public function postedByUser(UserId $userId) : self
    {
        $this->whereFieldEqual('article.user', 'userId', $userId);
        return $this;
    }
    
    public function orderedAlphabetically() : self
    {
        $this->orderResultsByAsc('article.title');
        return $this;
    }
    
    public function maxCount(int $count) : self
    {
        $this->limitResultsMaxCount($count);
        return $this;
    }
}

现在我们将一步步解释如何构建这个类

规范复合类

首先,我们需要创建我们的主要类,它将在我们的示例中稍后更新。它扩展了 SpecificationCompound,它提供了一个简单的规范注册机制,我们将在稍后详细了解。

我们将使用静态工厂方法 asEntity() 来构建查询对象并定义其返回类型。在这里,我们希望以 实体 的形式返回结果,但也可以选择填充一个 读取模型 (DTO)(例如 asModel())或返回一个标量值(例如 asCount())。

namespace App\Blog\Article\Query; // Example namespace, choose what fits best to your project

use Mediagone\Doctrine\Specifications\SpecificationCompound;

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            // Put select specifications here (one or more)
        );
    }
    
    // We'll add more specification methods here later
}

备注

  • 每个 SpecificationCompound 都必须初始化一个 结果格式 和(至少)一个 初始规范
  • 复合构造函数是受保护的,以强制使用 静态工厂方法,因为描述性的命名更能体现查询将返回的内容。
  • 您可能还想为总是返回单个结果的查询创建另一个名为 OneArticle 的复合。

选择文章实体规范

我们的第一个规范通过重载 modifyBuilder 方法来定义查询构建器中选择的实体

namespace App\Blog\Article\Query\Specifications; // Example namespace

use App\Blog\Article; // assumed FQCN of your entity
use Doctrine\ORM\QueryBuilder;
use Mediagone\Doctrine\Specifications\Specification;

final class SelectArticleEntity extends Specification
{
    public function modifyBuilder(QueryBuilder $builder) : void
    {
        $builder->from(Article::class, 'article');
        $builder->select('article');
    }
}

让我们将其注册到规范复合中

...
use App\Blog\Article\Query\Specifications\SelectArticleEntity;
use Mediagone\Doctrine\Specifications\SpecificationRepositoryResult;

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            new SelectArticleEntity()
        );
    }
}

这就是我们创建一个 自定义规范 的方法,但是为每个标准创建一个新的类真的很麻烦。希望库提供了许多通用规范,您可以在常见的用法中重用(参见下面的 通用规范 部分)。

因此,我们可以用通用规范替换我们的自定义规范

use App\Blog\Article;
use Mediagone\Doctrine\Specifications\SpecificationRepositoryResult;
use Mediagone\Doctrine\Specifications\Universal\SelectEntity;

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            SelectEntity::specification(Article::class, 'article'), // from + select
        );
    }
}

过滤规范

我们的第二个规范将通过作者来过滤文章

final class FilterArticlePostedByUser extends Specification
{
    private UserId $userId;

    public function __construct(UserId $userId)
    {
        $this->userId = $userId;
    }

    public function modifyBuilder(QueryBuilder $builder) : void
    {
        $builder->addWhere('article.authorId = :authorId');
        $builder->setParameter('authorId', $this->userId, 'app_userid');
    }
}

将其添加到复合中,但这次使用流畅的实例方法

final class ManyArticles extends SpecificationCompound
{
    // ...
    
    public function postedByUser(UserId $userId) : self
    {
        $this->addSpecification(new FilterArticlePostedByUser($userId));
        return $this;
    }
}

再次,存在一个通用规范,但这次您可以使用以下辅助方法来实现,而不必使用 addSpecification()(该方法内部使用它)

final class ManyArticles extends SpecificationCompound
{
    // ...
    
    public function postedByUser(UserId $userId) : self
    {
        $this->whereFieldEqual('article.user', 'userId', $userId);
        return $this;
    }
}

现在我们可以为我们的最后两个过滤器 orderedAlphabeticallymaxCount 执行完全相同的事情

final class ManyArticles extends SpecificationCompound
{
    // ...
    
    public function orderedAlphabetically() : self
    {
        // equivalent to: $doctrineQuerybuilder->addOrderBy('article.title', 'ASC');
        $this->orderResultsByAsc('article.title');
        return $this;
    }
    
    public function maxCount(int $count) : self
    {
        // equivalent to: $doctrineQuerybuilder->setMaxResults($count);
        $this->limitResultsMaxCount($count);
        return $this;
    }
}

执行查询

最后,我们可以通过使用 SpecificationRepository 类(它完全取代了传统的 Doctrine 仓库)轻松检索根据规范复合的结果

use Mediagone\Doctrine\Specifications\SpecificationRepository;

$repository = new SpecificationRepository($doctrineEntityManager);

$articles = $repository->find(
    ManyArticles::asEntity()
        ->postedByUser($userId)
        ->orderedAlphabetically()
        ->maxCount(5)
);

备注

  • 使用 依赖注入(如果可用)来实例化 DoctrineSpecificationRepository
  • 您还可以使用此服务类作为实现您自己的(例如总线中间件)的基础。

扩展用法

返回格式

该软件包允许以不同的格式检索结果

  • MANY_OBJECTS:返回一个 填充对象数组(类似于 $query->getResult()
  • MANY_OBJECTS_AS_ITERABLE:返回一个查询结果的 迭代器(类似于 $query->toIterable()
  • SINGLE_OBJECT:返回一个 单个填充对象null(类似于 $query->getOneOrNullResult()
  • SINGLE_SCALAR:返回一个 单个标量(类似于 $query->getSingleScalarResult()

因此,您可以通过在复合中添加多个 静态工厂方法,使用相同的规范来处理不同的结果类型。

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            // Return results as Article instances
            SelectEntity::specification(Article::class, 'article')
        );
    }
    
    public static function asModel() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            // Return results as ArticleModel instances
            SelectReadModel::specification(Article::class, 'article', ArticleModel::class) 
        );
    }

    public static function asCount() : self
    {
        return new self(
            SpecificationRepositoryResult::SINGLE_SCALAR,
            // Return the number of results
            SelectCount::specification(Article::class, 'article')
        );
    }
    
    // some filtering methods...
}

使用示例

$articleCount = $repository->find(
    ManyArticles::asCount() // retrieve the count instead of entities
        ->postedByUser($userId)
        ->inCategory($category)
);

连接

您可以通过在静态构造函数中添加它们来非常容易地定义查询连接。

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            SelectEntity::specification(Article::class, 'article')
            // Join will be applied anytime
            JoinLeft::specification('article.category', 'category'),
        );
    }
}

请注意,连接将应用于您的所有查询。
但是,如果您只想按需连接,则可以只为给定的规范定义它

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            SelectEntity::specification(Article::class, 'article')
        );
    }
    
    public static function byCategoryName(string $categoryName) : self
    {
        $this->joinLeft('article.category', 'category');
        $this->whereFieldEqual('category.name', 'cateName', $categoryName);
    }
}

使用相同别名的连接只会添加一次

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            SelectEntity::specification(Article::class, 'article'),
            JoinLeft::specification('article.category', 'category') // Join declaration
        );
    }
    
    public static function byCategoryName(string $categoryName) : self
    {
        // Ignored, since the join was already declared in the constructor,
        // it would be the same if declared in another called method.
        $this->joinLeft('article.category', 'category');
        $this->whereFieldEqual('category.name', 'catName', $categoryName);
    }
    
    public static function byParentCategoryName(string $categoryName) : self
    {
        // Not ignored since it uses a different alias ("pcat").
        $this->joinLeft('category.parent', 'pcat');
        $this->whereFieldEqual('pcat.name', 'pcatName', $categoryName);
    }
}

读取模型

通过专用类而不是实体检索数据可能非常有用(如果我们不需要更新实体),因为它可以加快复杂查询(它限制了填充对象的数量)并允许简化关系。

让我们看看这两个基本实体

#[ORM\Entity]
class Article
{
    #[Id]
    #[GeneratedValue]
    #[Column(type: 'integer')]
    private int $id;
    
    #[Column(type: 'string')]
    private string $title;
    
    #[Column(type: 'string')]
    private string $content;
    
    #[ManyToOne(targetEntity: Category::class)]
    private Category $category;
}

#[ORM\Entity]
class Category
{
    #[Id]
    #[GeneratedValue]
    #[Column(type: 'integer')]
    private int $id;
    
    #[Column(type: 'string')]
    private string $name;
}

获取具有其类别名称的文章的正常方式是查询实体及其相关的类别实体。但这会导致两个对象的水化,并且可能产生多个查询(具体取决于使用的获取模式)。

幸运的是,Doctrine通过使用NEW运算符(请参阅官方文档)提供了一种自定义类水化的方式。

保持查询的所选字段和DTO的构造函数参数同步可能会很繁琐,这就是为什么该包还提供了一个接口来为您处理所有这些操作。

final class ArticleModel implements SpecificationReadModel
{
    private int $id;
    private string $title;
    private string $content;
    private int $categoryId;
    private string $categoryName;
    
    // Keep field list close to the constructor's definition that uses it.
    public static function getDqlConstructorArguments(): array
    {
        return [
            'article.id',
            'article.title',
            'article.content',
            'category.id',
            'category.name',
        ];
    }
    
    public function __construct(
        int $id,
        string $title,
        string $content,
        int $categoryId,
        string $categoryName,
    ) {
        $this->id = $id;
        $this->title = $title;
        $this->content = $content;
        $this->categoryId = $categoryId;
        $this->categoryName = $categoryName;
    }
}

通过在工厂方法中注册SelectReadModel规范,在Entity位置选择读取模型非常直接。

final class ManyArticles extends SpecificationCompound
{
    public static function asModel() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            SelectReadModel::specification(Article::class, 'article', ArticleModel::class),
            JoinLeft::specification('article.category', 'category') // Join declaration
        );
    }
    
    // ...
}

之前的asModel方法转换成以下DQL:

SELECT NEW ArticleModel(article.id, article.title, article.content, category.id, category.name) FROM Article article JOIN article.category category

使用多个实体管理器

默认情况下,使用默认实体管理器,但您可以通过覆盖getEntityManager方法为每个复合体指定要使用的实体管理器。

final class ManyArticles extends SpecificationCompound
{
    public function getEntityManager(ManagerRegistry $registry) : EntityManager
    {
        return $registry->getManagerForClass(Article::class);
    }
    
}

您也可以通过在ORM配置中使用的名称来获取它。

public function getEntityManager(ManagerRegistry $registry) : EntityManager
{
    return $registry->getManager('secondary');
}

命令总线

通过查询总线使用规范查询是最合适的,它非常适合DDD,但这不是强制性的。您可以为任何总线或另一种类型的服务轻松调整自己的适配器。

您的查询类可能扩展SpecificationCompound,这使得它们可以自动由专门的中间件处理。

如果您正在寻找一个总线包(或者只是想看看它是如何工作的),您可以使用mediagone/cqrs-bus,它提供了一个SpecificationQuery基类和SpecificationQueryFetcher中间件。

通用规范

为了减少为大多数常见用法创建自定义规范的麻烦,库中包含了内置的通用规范。它们可以通过特定复合体的受保护方法轻松注册。

选择规范

过滤器规范

可用于准则方法的规范

使用示例

use Mediagone\Doctrine\Specifications\Universal\WhereFieldEqual;

final class ManyArticles extends SpecificationCompound
{
    // ...
    
    public function postedByUser(UserId $userId) : self
    {
        // the following line
        $this->whereFieldEqual('article.authorId', 'authorId',  $userId, 'app_userid');
        // is equivalent to
        $this->addSpecification(WhereFieldEqual::specification('article.authorId', 'authorId',  $userId, 'app_userid'));
        
        return $this;
    }
}

其他规范

使用示例

$pageNumber = 2;
$articlesPerPage = 10;

$articles = $repository->find(
    ManyArticles::asEntity()
    ->postedByUser($userId)
    ->inCategory($category)

    // Add results specifications separately (LimitResultsMaxCount and LimitResultsOffset)
    ->maxResult($articlesPerPage)
    ->resultOffset(($pageNumber - 1) * $articlesPerPage)
    
    // Or use the pagination specification (LimitResultsPaginate)
    ->paginate($pageNumber, $articlesPerPage)
);

最后几个规范通过允许您修改Doctrine QueryBuilder/Query(而不需要创建单独的类)提供了更大的灵活性。

use Doctrine\ORM\QueryBuilder;
use Mediagone\Doctrine\Specifications\SpecificationCompound;

final class ManyArticles extends SpecificationCompound
{
    // ...
    
    public function postedByOneOfBothUsers(UserId $userId, UserId $userId2) : self
    {
        $this->modifyBuilder(static function(QueryBuilder $builder) use ($userId, $userId2) {
            $builder
                ->andWhere('article.authorId = :authorId OR article.authorId = :authorId2')
                ->setParameter('authorId', $userId)
                ->setParameter('authorId2', $userId2)
            ;
        });
        
        return $this;
    }
}

调试规范

SpecificationCompound类内置了将调试导向的规范添加到所有复合类的方法,您不需要将它们包含在自己的复合类中

因此,您可以通过几次方法调用轻松地转储生成的DQL和SQL。

$articles = $repository->find(
    ManyArticles::asEntity()
    ->published()
    ->postedByUser($userId)
    
    ->dumpDQL() //  <--- equivalent of   dump($query->getDQL());
    ->dumpSQL() //  <--- equivalent of   dump($query->getSQL());
);

组织规范

命名规范

在此示例中使用的命名约定仅是一个建议,请根据您的需求或偏好进行调整。

没有关于命名的硬性要求,但您应该使用定义的前缀来区分您的规范。

  • Filter... : 过滤出结果,但允许 多个结果
  • Get... : 过滤出结果,以获取 唯一(或null)结果
  • Order... : 改变结果顺序的规范。
  • Select... : 定义所选结果数据(实体、DTO、连接、groupBy等)的规范...

文件组织

你可能需要创建一个单独的复合对象来查询单个文章(例如 OneArticle),因为对于单个或数组结果,规格过滤器通常不同(共享的规格可以轻松添加到两个复合对象中)。

因此,建议的文件结构可能如下所示

Article
  ├─ Query
  │   ├─ Specifications
  │   │   ├─ FilterArticlePostedBy.php
  │   │   ├─ GetArticleById.php
  │   │   ├─ OrderArticleAlphabetically.php
  │   │   ├─ SelectArticleCount.php
  │   │   ├─ SelectArticleDTO.php
  │   │   └─ SelectArticleEntity.php
  │   │
  │   ├─ ManyArticles.php
  │   └─ OneArticle.php
  │
  ├─ Article.php
  └─ ArticleDTO.php

许可

Doctrine Specifications 在 MIT 许可下授权。请参阅 LICENSE 文件。