mlukman/doctrine-helper-bundle

一套简化 Symfony-Doctrine 集成的类

1.1.5 2024-09-20 03:27 UTC

This package is auto-updated.

Last update: 2024-09-20 03:33:30 UTC


README

Packagist Version Packagist License GitHub issues

关于

Doctrine Helper Bundle 是一个 Symfony 7.x 扩展包,提供了一些功能,简化了通过流行的 Doctrine ORM 库与数据库交互的数据请求处理逻辑的开发。

安装

确保已全局安装 Composer,如 Composer 文档中的安装章节所述。

使用 Symfony Flex 的应用程序

打开命令行,进入您的项目目录并执行

composer require mlukman/doctrine-helper-bundle

未使用 Symfony Flex 的应用程序

步骤 1:下载扩展包

打开命令行,进入您的项目目录并执行以下命令以下载此扩展包的最新稳定版本

composer require mlukman/doctrine-helper-bundle:1.*

步骤 2:启用扩展包

然后,通过将其添加到项目 config/bundles.php 文件中注册的扩展包列表中来启用扩展包

// config/bundles.php

return [
    // ...
    MLukman\DoctrineHelperBundle\DoctrineHelperBundle::class => ['all' => true],
];

提供的功能

1. 在 DataStore 服务中整合常见的 Doctrine 操作

大多数 Doctrine 操作都需要链式调用几个函数,例如

$count = $em->getRepository(Comment::class)
    ->createQueryBuilder('c')
    ->select('COUNT(1)')
    ->where('email = :email')
    ->setParameter('email', $email)
    ->getQuery()
    ->getSingleScalarResult();

此扩展包提供的 DataStore 服务将此操作简化为

$count = $ds->count(Comment::class, ['email' => $email]);

以下是一些 DataStore 简化的操作

更多操作

  • 计数记录

    $ds->count(
        Comment::class, // entity class
        ['approved' => false] // (optional) filters
    );
  • 全文搜索

    $ds->fulltextSearch(
        Comment::class, // entity class
        ['text'], // array of column names that have fulltext index
        $keyword, // the keyword to search
        ['approved' => true] // (optional) filters
    )->getQuery()->getResult();
  • 获取/计数上一条/下一条记录

    $ds->getPrevious(
        $current, // the current record used as pivot
        'submitDate', // the column to sort the records by
        ['parentPost'] // matcher fields (filter only records that match these fields with pivot record)    
    );
    
    // all these methods have same parameter signatures but different return values
    $ds->countPrevious($current, 'submitDate', ['parentPost']);
    $ds->getNext($current, 'submitDate', ['parentPost']);
    $ds->countNext($current, 'submitDate', ['parentPost']);

2. 将请求体转换为对象

常见的请求体处理流程如下。

在控制器内部,会有许多行代码手动从 Request 对象中逐个读取参数并将其传输到相应的 Entity 对象中。如果控制器可以只调用一行代码就能完成这个操作会怎样呢?

$specificRequest->populate($specificEntity);

此辅助扩展包提供了 Service\RequestBodyConverter 来实现这一点!

首先,创建一个扩展 DTO\RequestBody 的 PHP 类,例如

class CommentRequest extends RequestBody
{
    public ?string $name;
    public ?string $email;
    public ?string $comment;
}

为了简单起见,只需让所有属性为 public,而不是使用设置器/获取器。

接下来,修改相应的 Entity 类以实现 DTO\RequestBodyTargetInterface。目前没有要实现的方法,此接口只是为了类型检测。但是,Entity 类必须具有与 DTO\RequestBody 子类内部相同的公共属性名称或相应的设置器方法,或者两者的混合。否则,请求中的参数将被忽略。例如

#[ORM\Entity]
class Comment implements RequestBodyTargetInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 30)]
    public ?string $name = null;

    #[ORM\Column(length: 30)]
    public ?string $email = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $comment = null;

    public function setComment(string $comment): self
    {
        $this->comment = $comment;
        return $this;
    }
}

最后,修改控制器方法,执行以下操作

  • RequestBody 子类添加到方法参数的列表中。通过在类名前添加 ? 来使其成为可选的。

    #[Route('/comments/post', name: 'app_comments_post')]
    public function comments_post(?CommentRequest $commentRQI): Response
  • 添加使用此 RequestBody 子类的代码(抱歉,如果将实体实例化和条件检查也算在内,则这不是一行代码,但实际上将值从请求传输到实体只是一行代码)。

    $comment = new Comment();
    if ($commentRQI) {
        $commentRQI->populate($comment);
    }

现在,任何以 POST 或 PUT 主体到达该路由的请求,无论是 JSON 格式还是 HTTP 表单提交,都将被转换为特定的方法参数,并能够被控制器方法利用。

注意:要排除任何转换问题,此扩展包提供了一个带有“请求转换”标签的 Web 分析员面板。

3. 简化的验证器

您可能已经熟悉用于验证带有 Symfony 验证注解(如 @[NotBlank])的对象的 ValidatorInterface。本扩展包提供了一种名为 ObjectInterface 的服务,进一步简化了验证过程。调用此服务的 validate() 方法将输出一个数组,其中已将所有错误消息按相应字段名称归总,例如:

$validateResult = $objectValidator->validate($object);
print_r($validateResult);

输出可能如下所示:

[
    "quantity" => [
        "This value should not be blank.",
        "This value should be positive."
    ],
    "reference" => [
        "Value must be unique."
    ]
]

您也可以通过调用 addValidationError() 方法手动构建验证错误数组。

$validateResult = [];
$objectValidator->addValidationError($validateResult, "quantity", "This value should not be blank.");
$objectValidator->addValidationError($validateResult, "quantity", "This value should be positive.");
$objectValidator->addValidationError($validateResult, "reference", "Value must be unique.");

如果您想对多个对象的验证进行链式操作,或者需要在对象外部实现自定义验证,该功能可能会很有帮助。

4. 自定义类型

4.1 列类型 'image' 及类 ImageWrapper

为了在数据库中存储图片,我们可以在 ORM\Column 注解中使用列类型 "image"。相应的 PHP 类是 ImageWrapper,它基本上是围绕 Imagine 库(https://imagine.readthedocs.io/)构建的。此扩展包在数据库的 BLOB 列和 PHP ImageWrapper 对象之间执行转换。

ImageWrapper 类的推荐用法是与 DTO\RequestBody 一起使用,以处理表单提交。指定一个与上传字段的 name 相匹配的属性将自动从表单提交中加载图片。

use MLukman\DoctrineHelperBundle\Type\ImageWrapper;

#[ORM\Entity]
class Profile
{
    #[ORM\Column(type: "image", nullable: true)]
    public ?ImageWrapper $photo = null;
}

要手动将图片存储到 ImageWrapper 对象中,您可以这样做:

// $image can be either a resource stream from fopen or simply the full path to the image file on the server
$profile->photo = new ImageWrapper($image);

如果图片来自文件上传表单,在处理文件上传的代码中,应首先获取引用该文件上传提交的 Symfony\Component\HttpFoundation\File\UploadedFile 对象,然后使用提供的静态方法 fromUploadedFile

$profile->photo = ImageWrapper::fromUploadedFile($uploadedFile);

一旦图片被存储并从数据库中检索出来,以下是一些使用图片的方法:

  1. 作为控制器方法的下载响应

    // passing $request is optional and only necessary for enabling client-side caching
    return $profile->photo->getDownloadResponse($request);
  2. 作为返回给客户端的 Base64 字符串,可以是 <img src> HTML 标签或 API 响应 JSON 中

    $profile->photo->getBase64DataUrl();
  3. 作为可嵌入到 <img src> 中的下载链接,与上述第 1 点结合使用

    // first, this method needs to be called inside controller
    $profile->photo->setDownloadLink($this->generateProfileImageLink($profile));
    {# inside twig template #}
    <img src="{{ profile.photo.downloadlink }}" />
    <img src="{{ profile.photo }}" /> {# this works too #}
  4. 作为用于进一步处理的原始二进制字符串

    $imageBinary = $profile->photo->get();

然而,对于简单的图像缩放,ImageWrapper 已经提供了 resize($maxWidth, $maxHeight, $keepAspectRatio) 方法

$profile->photo->resize(400,300,false)->getBase64DataUrl();

4.2 列类型 'file' 及类 FileWrapper

与仅存储图像信息的图片不同,存储文件通常还需要存储文件名和 MIME 类型,以便存储的文件数据在以后可以使用。这些附加元数据通常存储在单独的数据库列中,这增加了在读写数据库时组合和拆分这些信息的应用程序复杂度。为了简化这个问题,本扩展包提供了列类型 "file" 及其对应的 PHP 类 FileWrapper

使用此类与 DTO\RequestBody 结合将自动处理文件上传字段提交并填充文件元数据,这些元数据可以使用 getName()getMimetype()getSize()getContent() 获取。

use MLukman\DoctrineHelperBundle\Type\FileWrapper;

#[ORM\Entity]
class Profile
{
    #[ORM\Column(type: "file", nullable: true)]
    private ?FileWrapper $resume = null;
}

类似于上述的 ImageWrapperFileWrapper 也提供了 fromUploadedFilegetDownloadResponsesetDownloadLinkgetDownloadLink

4.2.1 替代列类型 'fsfile'

如果您不习惯在数据库中存储文件,您还可以使用替代列类型 'fsfile'。用法与 'file' 类似

use MLukman\DoctrineHelperBundle\Type\FileWrapper;

#[ORM\Entity]
class Profile
{
    #[ORM\Column(type: "fsfile", nullable: true)]
    private ?FileWrapper $resume = null;
}

使用 'fsfile' 时,仅将元数据存储在数据库中,格式为 JSON,而实际文件内容将存储在文件系统中,路径前缀为 {appdir}/var/fsfiles/。在 HA 部署中,请确保此路径前缀挂载在共享存储介质上。

4.3 列类型 'encrypted'

此列类型会在将提供的字符串值存储到数据库之前自动加密,在从数据库加载之后自动解密。默认情况下,用于加密和解密的密钥将来自APP_SECRET环境变量,因此如果您从一个环境迁移到另一个环境,需要同步此环境变量。但是,您也可以在启动内核期间调用静态方法\MLukman\DoctrineHelperBundle\Type\EncryptedType::setEncryptionKey($keyString)来使用不同的密钥。

use MLukman\DoctrineHelperBundle\Type\EncryptedValue;

#[ORM\Entity]
class Profile
{
    #[ORM\Column(type: "encrypted", nullable: true)]
    private ?string $nationalId = null;
}

5. 记录列表过滤

5.1 分页

当我们的应用程序处理特定实体数据的长列表时,向用户展示数据列表通常是一个挑战。简单地列出所有数据不仅会影响应用程序的性能,还会给需要滚动浏览所有数据的用户提供糟糕的体验。一种常见的做法是将列表分页成合理的块,并为用户提供导航页面的UI元素。虽然此包不提供UI元素,以便与UI框架无关,但它提供从查询参数中处理分页参数的功能,使用它们对数据库查询进行过滤,并为Twing模板准备显示正确的分页控制UI元素所需的信息。

要从分页开始,首先需要在控制器的方法中添加一个DTO\Paginator对象,例如

#[Route('/admin/users', name: 'app_admin_users')]
public function users(Paginator $paginator): Response

接下来,准备一个预先配置了基本查询、任何默认过滤和排序的\Doctrine\ORM\QueryBuilder,例如

#[Route('/admin/users', name: 'app_admin_users')]
public function users(Paginator $paginator, EntityManagerInterface $em): Response
{
    $qb = $em->getRepository(User::class)->createQueryBuilder('u')
        ->andWhere("u.status = 'Active'")
        ->addOrderBy('u.registered', 'DESC');
}

现在,让分页器通过简单地调用paginateResults()方法来执行魔法,并将查询构建器作为参数传递

#[Route('/admin/users', name: 'app_admin_users')]
public function users(Paginator $paginator, EntityManagerInterface $em): Response
{
    $qb = $em->getRepository(User::class)->createQueryBuilder('u')
        ->andWhere("u.status = 'Active'")
        ->addOrderBy('u.registered', 'DESC');
    return $this->render('admin/users/index.html.twig', [
        'users' => $paginator->paginateResults($qb),
        'paginator' => $paginator,
    ]);
}

最后,我们需要准备将显示分页控制的UI元素。上面的例子中,我们提供的$paginator对象提供了一些方法,将提供必要的信息

5.2 搜索

记录列表过滤的另一种常见方法是允许用户搜索特定关键字。此包通过提供DTO\SearchQuery简化了添加此功能,该查询需要添加到控制器的方法中,例如

#[Route('/admin/comments', name: 'app_admin_comments')]
public function comments(SearchQuery $search): Response

接下来,准备一个预先配置了基本查询、任何默认过滤和排序的\Doctrine\ORM\QueryBuilder,例如

#[Route('/admin/comments', name: 'app_admin_comments')]
public function comments(SearchQuery $search): Response
{
    $qb = $em->getRepository(Comment::class)->createQueryBuilder('c')
        ->andWhere("c.deleted = 0")
        ->addOrderBy('c.posted', 'DESC');
}

接下来,让DTO\SearchQuery通过调用applyLikeSearch()applyFulltextSearch()来执行魔法,将查询构建器作为第一个参数,并将具有别名的列列表作为第二个参数传递

#[Route('/admin/comments', name: 'app_admin_comments')]
public function comments(SearchQuery $search): Response
{
    $qb = $em->getRepository(Comment::class)->createQueryBuilder('c')
        ->andWhere("c.deleted = 0")
        ->addOrderBy('c.posted', 'DESC');
    $search->applyFulltextSearch($qb, ['c.message']);
}

然后,将查询构建器结果和SearchQuery对象传递到Twing模板

#[Route('/admin/comments', name: 'app_admin_comments')]
public function comments(SearchQuery $search): Response
{
    $qb = $em->getRepository(Comment::class)->createQueryBuilder('c')
        ->andWhere("c.deleted = 0")
        ->addOrderBy('c.posted', 'DESC');
    $search->applyFulltextSearch($qb, ['c.message']);
    return $this->render('admin/comments/index.html.twig', [
        'comments' => $qb->getQuery()->getResult(),
        'searchquery' => $search,
    ]);
}

最后,在UI中准备一个搜索字段

<input type="search" name="{{ searchquery.name }}" value="{{ searchquery.keyword }}" />

5.3 预定义查询

记录列表过滤的另一种方法是提供一组预定义的过滤器,例如按状态、类别等进行过滤。此包通过提供PDO\PreDefinedQueries提供此功能。同样,与PaginatorSearchQuery一样,显示过滤器UI元素的功能也没有提供,但这应该很容易实现。

首先,将一个DTO\PreDefinedQueries对象添加到控制器的方法中,例如

#[Route('/admin/assets', name: 'app_admin_assets')]
public function assets(PreDefinedQueries $status): Response

接下来,准备一个预先配置了基本查询、任何默认过滤和排序的\Doctrine\ORM\QueryBuilder,例如

#[Route('/admin/assets', name: 'app_admin_assets')]
public function assets(PreDefinedQueries $status): Response
{
    $qb = $em->getRepository(Asset::class)->createQueryBuilder('a')
        ->addOrderBy('a.registered', 'DESC');
}

接下来,我们需要为每个过滤器选项定义名称及其如何修改查询构建器。为此,调用addQuery()方法,传递过滤器选项的名称和一个接受查询构建器以执行修改的回调。为每个过滤器选项调用该方法(链式)并最终调用apply()方法,传递准备好的查询构建器,例如

#[Route('/admin/assets', name: 'app_admin_assets')]
public function assets(PreDefinedQueries $status): Response
{
    $qb = $em->getRepository(Asset::class)->createQueryBuilder('a')
        ->addOrderBy('a.registered', 'DESC');
    $status
        ->addQuery('all', fn(QueryBuilder $qb) => $qb)
        ->addQuery('active', fn(QueryBuilder $qb) => $qb->andWhere("a.status = 'Active'"))
        ->addQuery('inactive', fn(QueryBuilder $qb) => $qb->andWhere("a.status IN ('Disposed', 'Consumed')"))
        ->apply($qb);
}

然后,将查询构建器结果和SearchQuery对象传递到Twing模板

#[Route('/admin/assets', name: 'app_admin_assets')]
public function assets(PreDefinedQueries $status): Response
{
    $qb = $em->getRepository(Asset::class)->createQueryBuilder('a')
        ->addOrderBy('a.registered', 'DESC');
    $status
        ->addQuery('all', fn(QueryBuilder $qb) => $qb)
        ->addQuery('active', fn(QueryBuilder $qb) => $qb->andWhere("a.status = 'Active'"))
        ->addQuery('inactive', fn(QueryBuilder $qb) => $qb->andWhere("a.status IN ('Disposed', 'Consumed')"))
        ->apply($qb);
    return $this->render('admin/assets/index.html.twig', [
        'assets' => $qb->getQuery()->getResult(),
        'filter' => $status,
    ]);    
}

最后,准备提供过滤器选项列表的UI元素,这些选项在交互时导航到特定的URL。例如,使用简单的<a><ul><li>

<ul>
    {% for id,url in filter.urls %}
        <li>
            <a href="{{ url }}">
                {% if id == filter.selectedId %}
                    <strong>{{ id }}</strong>
                {% else %}
                    {{ id }}
                {% endif %}
            </a>
        </li>
    {% endfor %}
</ul>

6. 实体的创建者、创建日期时间、更新者和更新日期时间的自动审计

通过让实体类实现 \MLukman\DoctrineHelperBundle\Interface\AuditedEntityInterface 并使用 \MLukman\DoctrineHelperBundle\Trait\AuditedEntityTrait,所有实体对象的创建和更新都将自动存储,包括其他字段。

对于创建日期和更新日期字段,这些接口和特性组合已经涵盖了从实体列及其获取器和设置器到连接 Doctrine 的 PrePersistPreUpdate 事件所需的事件订阅者。

然而,对于创建者和更新者列,这些接口和特性组合仅提供事件订阅者,但实体列需要由实现实体类定义。这种设计决定是因为此包无法知道将用于实现应用的实体类是什么。因此,实现应用需要实现 AuditedEntityInterface 的剩余两个方法,以及 $createdBy$updatedBy 的实体列定义。

interface AuditedEntityInterface
{

    public function setCreatedBy(?\Symfony\Component\Security\Core\User\UserInterface $createdBy);

    public function setUpdatedBy(?\Symfony\Component\Security\Core\User\UserInterface $updatedBy);
}

例如,以下 MappedSuperclass 来自一个应用,其中实现 UserInterface 的类被命名为 Login,它与 User 类有一个 ManyToOne 关系 $user

#[ORM\MappedSuperclass]
abstract class AuditedEntity implements AuditedEntityInterface
{
    use AuditedEntityTrait;

    #[ORM\ManyToOne]
    #[ORM\JoinColumn(onDelete: 'SET NULL')]
    protected ?User $createdBy = null;

    #[ORM\ManyToOne]
    #[ORM\JoinColumn(onDelete: 'SET NULL')]
    protected ?User $updatedBy = null;

    public function getCreatedBy(): ?User
    {
        return $this->createdBy;
    }

    public function getUpdatedBy(): ?User
    {
        return $this->updatedBy;
    }

    public function setCreatedBy(?UserInterface $createdBy)
    {
        if ($createdBy instanceof Login) {
            $this->createdBy = $createdBy->getUser();
        }
    }

    public function setUpdatedBy(?UserInterface $updatedBy)
    {
        if ($updatedBy instanceof Login) {
            $this->updatedBy = $updatedBy->getUser();
        }
    }
}

7. UuidEntityTrait

此特性提供了使用 UUID 作为主键列的快捷方式。只需将其添加到类中,这就是您需要做的。

#[ORM\Entity]
class Component
{
    use \MLukman\DoctrineHelperBundle\Trait\UuidEntityTrait;
}