四、构建查询

在上一章中,我们为我们的博客软件添加了评论和标记支持。虽然它工作正常,但有些功能可以增强。

在本章中,我们将利用条令的一些非常重要的部分:条令查询语言DQL、实体存储库和查询生成器。本章将介绍以下几个方面:

  • 优化注释功能
  • 创建一个页面,通过标签过滤帖子
  • 在主页上显示帖子的评论数

理解 DQL

DQL 是条令查询语言的缩写。它是一种特定于领域的语言,与 SQL 非常相似,但不是 SQL。DQL 不是查询数据库表和行,而是查询对象模型的实体和映射属性。

DQL 的灵感来源于 Hibernate 的查询语言 HQL,这是一种流行的 Java ORM。有关更多详细信息您可以访问此网站:http://www.hibernate.org/

有关特定领域语言的更多信息请访问:

http://en.wikipedia.org/wiki/Domain-specific_language

为了更好地理解它的含义,让我们运行第一个 DQL 查询。

条令命令行工具就像瑞士军刀一样真实。它们包括一个名为orm:run-dql的命令,该命令运行 DQL 查询并显示其结果。使用它检索title和以1为标识符的帖子的所有评论:

php vendor/bin/doctrine.php orm:run-dql "SELECT p.title, c.bodyFROM Blog\Entity\Post p JOIN p.comments c WHERE p.id=1"

它看起来像一个 SQL 查询,但绝对不是 SQL 查询。检查FROMJOIN条款;它们包括以下几个方面:

  • FROM子句中使用完全限定的实体类名作为查询的根
  • 由于JOIN子句中存在Post实体类的comments属性,因此与所选Post实体关联的所有Comment实体都被加入

如您所见,可以以面向对象的方式请求来自与主实体关联的实体的数据。JOIN子句中可以使用持有关联的属性(在拥有方或相反方)。

尽管存在一些限制(特别是在子查询领域),我们将在第 5 章中进一步介绍,但 DQL 是检索对象图的强大而灵活的语言。在内部,Doctrin 解析 DQL 查询,通过数据库抽象层(DBAL)生成并执行它们,对应于 SQL 查询,并使用结果对数据结构进行水合物化。

到目前为止,我们只使用条令检索 PHP 对象。条令能够使其他类型的数据结构,特别是阵列和基本类型的数据结构水化物化。还可以编写自定义的水合器来填充任何数据结构。

如果你仔细观察上一次调用orm:run-dql的返回,你会发现它是一个数组,而不是一个对象图。

与本书涵盖的所有主题一样,关于内置水合模式和自定义水合器的更多信息可在以下网站的条令文档中获得:

http://docs.doctrine-project.org/en/latest/reference/dql-doctrine-query-language.html#hydration-模式

使用实体存储库

实体存储库是负责访问和管理实体的类。就像实体与数据库行相关一样,实体存储库与数据库表相关。

我们已经使用了条令提供的默认实体存储库来检索前面章节中的实体。所有 DQL 查询都应写入与其检索的实体类型相关的实体存储库中。它对应用程序的其他组件隐藏了 ORM,并使重用、重构和优化查询变得更容易。

条令实体存储库是表数据网关设计模式的实现。有关详细信息,请访问以下网站:

http://martinfowler.com/eaaCatalog/tableDataGateway.html

可用于每个实体的基础存储库提供了以下方式管理实体的有用方法:

  • find($id): It returns the entity with $id as an identifier or null

    实体经理的find()方法在内部使用。在前几章中,我们多次使用此快捷方式。

  • findAll():它检索包含此存储库中所有实体的数组

  • findBy(['property1' => 'value', 'property2' => 1], ['property3' => 'DESC', 'property4' => 'ASC']):它检索一个数组,该数组包含与第一个参数中传递的所有条件相匹配的实体,并由第二个参数排序
  • findOneBy(['property1' => 'value', 'property2' => 1]):与findBy()类似,只取第一个实体,如果没有一个实体符合条件则取null

实体存储库还提供允许单个属性筛选实体的快捷方式方法。它们遵循以下模式:findBy*()findOneBy*()

例如,调用findByTitle('My title')相当于调用findBy(['title' => 'My title'])

此功能使用了神奇的__call()PHP 方法。有关详细信息,请访问以下网站:

http://php.net/manual/en/language.oop5.overloading.php#object.call

第 3 章关联所示,这些快捷方式不会加入相关实体,除非我们在实体类的关联注释中添加fetch="EAGER"属性。如果(且仅当)通过方法调用请求相关实体(或实体集合),将发出另一个 SQL 查询。

在我们的博客应用程序中,我们希望在详细帖子视图中显示评论,但不需要从帖子列表中获取评论。对于列表来说,通过fetch属性进行快速加载不是一个好的选择,而延迟加载会降低详细视图的速度。

解决方案是创建一个自定义存储库,其中包含用于执行我们自己的查询的额外方法。我们将编写一个自定义方法来整理详细视图中的注释。

创建自定义实体存储库

自定义实体存储库是扩展条令提供的基本实体存储库类的类。它们设计用于接收运行 DQL 查询的自定义方法。

像往常一样,我们将使用映射信息来告诉条令使用自定义存储库类。这是@Entity注释的repositoryClass属性的作用。

请执行以下步骤以创建自定义实体存储库:

  1. src/Blog/Entity/位置重新打开Post.php文件,并向现有@Entity注释添加repositoryClass属性,如以下代码行所示:

    php @Entity(repositoryClass="PostRepository")

  2. 条令命令行工具还提供了实体存储库生成器。键入以下命令以使用它:

    ```php php vendor/bin/doctrine.php orm:generate:repositories src/

    ```

  3. src/Blog/Entity/位置打开这个新的空自定义存储库,我们刚刚在PostRepository.phpPostRepository.php文件中生成了它。添加以下检索帖子和评论的方法:

    php /** * Finds a post with its comments * * @param int $id * @return Post */ public function findWithComments($id) { return $this ->createQueryBuilder('p') ->addSelect('c') ->leftJoin('p.comments', 'c') ->where('p.id = :id') ->orderBy('c.publicationDate', 'ASC') ->setParameter('id', $id) ->getQuery() ->getOneOrNullResult() ; }

我们的自定义存储库扩展了条令提供的默认实体存储库。本章前面描述的标准方法仍然可用。

开始使用查询生成器

QueryBuilder是一个对象,旨在通过一个带有 fluent 接口的 PHP API 来帮助构建 DQL 查询(有关 fluent 接口的更多信息,请参见第 2 章实体和映射信息。它允许我们通过getDql()方法(用于调试)检索生成的 DQL 查询,或直接使用Query对象(由 Doctrine 提供)。

为了提高性能,QueryBuilder缓存生成的 DQL 查询并管理内部状态。

DQL 查询的完整 API 和状态记录在以下网站上:

http://docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/query-builder.html

我们将深入解释我们在PostRepository类中创建的findWithComments()方法。

首先,使用继承自基础实体库的createQueryBuilder()方法创建QueryBuilder实例。QueryBuilder实例将字符串作为参数。此字符串将用作主实体类的别名。默认情况下,选择主实体类的所有字段,除SELECTFROM外,不填充其他子句。

leftJoin()调用创建一个JOIN子句,用于检索与帖子相关联的注释。它的第一个参数是要连接的属性,第二个参数是别名;这些将用于查询联接实体类(此处,字母c将用作Comment类的别名)。

除非使用 SQLJOIN子句,否则 DQL 查询将自动获取与主实体关联的实体。不需要像ONUSING这样的关键词。条令自动知道是否必须使用联接表或外键列。

addSelect()调用将注释数据附加到SELECT子句。实体类的别名用于检索所有字段(这类似于 SQL 中的*运算符)。与本章的第一个 DQL 查询一样,可以使用符号alias.propertyName检索特定字段。

您猜对了,对where()方法的调用设置了查询的WHERE部分。

在幕后,原则使用准备好的 SQL 语句。它们比标准 SQL 查询更高效。

id参数将由调用setParameter()设置的值填充。

再次感谢准备好的语句和这个setParameter()方法,SQL 注入攻击被自动避免。

SQL 注入攻击是一种使用未逃逸的用户输入执行恶意 SQL 查询的方法。让我们以下面的坏 DQL 查询为例,检查用户是否具有特定角色:

$query = $entityManager->createQuery('SELECT ur FROMUserRole ur WHERE ur.username = "' . $username . '" ANDur.role = "' . $role . '"');
$hasRole = count($query->getResult());

此 DQL 查询将由条令转换为 SQL。如果有人键入以下用户名:

" OR "a"="a

字符串中包含的 SQL 代码将被注入,查询将始终返回一些结果。攻击者现在可以访问私人区域。

正确的方法应该是使用以下代码:

$query = $entityManager->createQuery("SELECT ur FROMUserRole WHERE username = :username and role = :role");
$query->setParameters([
    'username' => $username,
    'role' => $role
]);
$hasRole = count($query->getResult());

由于准备好了语句,用户名中包含的特殊字符(如引号)并不危险,而且这个代码段将按预期工作。

orderBy()调用生成一个ORDER BY子句,该子句根据注释的发布日期排序结果,以较早者为准。

提示

大多数 SQL 指令在 DQL 中也有一个面向对象的等价物。最常见的连接类型可以使用 DQL 创建;他们通常有相同的名字。

getQuery()调用告诉查询生成器生成 DQL 查询(如果需要,它将从缓存中获取查询,如果可能的话),实例化一个条令Query对象,并用生成的 DQL 查询填充它。

生成的 DQL 查询如下所示:

SELECT p, c FROM Blog\Entity\Post p LEFT JOIN p.comments c WHEREp.id = :id ORDER BY c.publicationDate ASC

Query对象公开了另一种用于调试的有用方法:getSql()。顾名思义,getSql()返回与 DQL 查询对应的 SQL 查询,DQL 查询将在 DBMS 上运行。对于我们的 DQL 查询,底层 SQL 查询如下所示:

SELECT p0_.id AS id0, p0_.title AS title1, p0_.body AS body2,p0_.publicationDate AS publicationDate3, c1_.id AS id4, c1_.bodyAS body5, c1_.publicationDate AS publicationDate6, c1_.post_id ASpost_id7 FROM Post p0_ LEFT JOIN Comment c1_ ON p0_.id =c1_.post_id WHERE p0_.id = ? ORDER BY c1_.publicationDate ASC

getOneOrNullResult()方法执行它,检索第一个结果,并将其作为Post实体实例返回(如果找不到结果,该方法返回null

QueryBuilder对象类似,Query对象管理内部状态,以便仅在必要时生成底层 SQL 查询。

在使用原则时,性能是需要非常小心的。当设置为生产模式时(参见第 1 章开始学习条令 2),ORM 能够缓存生成的查询(通过QueryBuilder对象的 DQL、通过Query对象的 SQL)和查询结果。

ORM 必须配置为使用以下网站所示的一个强大、快速、受支持的系统(APC、Memcache、XCache 或 Redis):

http://docs.doctrine-project.org/en/latest/reference/caching.html

我们仍然需要更新视图层来处理新的findWithComments()方法。

web/位置打开view-post.php文件,您会发现以下代码片段:

$post = $entityManager->getRepository('Blog\Entity\Post')->find($_GET['id']);

将前一行代码替换为以下代码段:

$post = $entityManager->getRepository('Blog\Entity\Post')->findWithComments($_GET['id']);

标签过滤

为了发现 QueryBuilder 和 DQL 的更高级用法,我们将创建一个包含一个或多个标记的帖子列表。

标签过滤有利于搜索引擎优化,让读者轻松找到感兴趣的内容。我们将建立一个系统,能够列出有几个共同标签的帖子;例如,所有贴有条令和符号的帖子。

要使用标签过滤我们的帖子,请执行以下步骤:

  1. Add another method to our custom PostRepository class (src/Blog/Entity/PostRepository.php) using the following code:

    php /** * Finds posts having tags * * @param string[] $tagNames * @return Post[] */ public function findHavingTags(array $tagNames) { return $queryBuilder = $this ->createQueryBuilder('p') ->addSelect('t') ->join('p.tags', 't') ->where('t.name IN (:tagNames)') ->groupBy('p.id') ->having('COUNT(t.name) >= :numberOfTags') ->setParameter('tagNames', $tagNames) ->setParameter('numberOfTags',count($tagNames)) ->getQuery() ->getResult() ; }

    这个方法有点复杂。它接受一个参数作为标记名数组,并返回一个包含所有这些标记的 POST 数组。

    这个问题需要解释一下,如下所示:

    • 主实体类(由继承的createQueryBuilder()方法自动设置)为Post,别名为字母p
    • 我们通过JOIN子句加入相关标签;Tag类的别名为t
    • 由于调用了where(),我们仅检索由参数中传递的一个标记标记的帖子。我们使用了一个很棒的原则特性,它允许我们直接使用数组作为查询参数。
    • where()的结果按id分组,并调用groupBy()
    • 我们使用HAVING子句中的聚合函数COUNT()来过滤由$tagNames数组的一些标记标记的帖子,但不是全部。
    • Edit the index.php file in web/ to use our new method. Here, you will find the following code:

    php /** @var $posts \Blog\Entity\Post[] Retrieve the list ofall blog posts */ $posts = $entityManager->getRepository('Blog\Entity\Post')->findAll();

    并将前面的代码替换为下一个代码段:

    php $repository = $entityManager->getRepository('Blog\Entity\Post'); /** @var $posts \Blog\Entity\Post[] Retrieve the list ofall blog posts */ $posts = isset($_GET['tags']) ? $repository->findHavingTags($_GET['tags']) : $repository->findAll();

    现在,当 URL 中存在一个名为tagsGET参数时,它用于过滤帖子。更好的方法是,如果传入几个逗号分隔的标记,则只显示包含所有这些标记的帖子。

  2. 在您喜爱的浏览器中键入http://localhost:8000/index.php?tags=tag4,tag5。多亏了我们在上一章中创建的装置,应该列出第 5 和第 10 篇文章。

  3. In the same file, find the following code:

    php <p> <?=nl2br(htmlspecialchars($post->getBody()))?> </p>

    并添加标签列表,如下所示:

    php <ul> <?php foreach ($post->getTags() as $tag): ?> <li> <a href="index.php?tags=<?=urlencode($tag)?>"><?=htmlspecialchars($tag)?></a> </li> <?php endforeach ?> </ul>

将显示带有标记页面链接的智能标记列表。您可以复制此代码,然后粘贴到web/位置的view-post.php文件中;或者更好,不要重复自己:创建一个小助手函数来显示标签。

点评

我们还需要做一些表面上的改变。有很多评论的帖子引起了许多读者的兴趣。如果每个帖子的评论数量可以直接从列表页面获得,那就更好了。条令可以填充一个数组,该数组包含调用aggregate函数的结果作为第一行,水合实体作为第二行。

将以下方法添加到PostRepository类中,用于检索带有相关注释的帖子:

    /**
     * Finds posts with comment count
     *
     * @return array
     */
    public function findWithCommentCount()
    {
        return $this
            ->createQueryBuilder('p')
            ->leftJoin('p.comments', 'c')
            ->addSelect('COUNT(c.id)')
            ->groupBy('p.id')
            ->getQuery()
            ->getResult()
        ;
    }

由于GROUP BY子句和对addSelect()的调用,此方法将返回二维数组,而不是Post实体的数组。返回数组中的数组包含两个值,如下所示:

  • 我们的Post实体在第一个索引中
  • 第二个索引处 DQL(注释数)的COUNT()函数的结果

web/位置的index.php文件中,找到以下代码:

    $posts = $repository->findHavingTags(explode(',',$_GET['tags']));
} else {
    $posts = $repository->findAll();
}

并用以下代码替换前面的代码以使用我们的新方法:

    $results = $repository->findHavingTags(explode(',',$_GET['tags']));
} else {
    $results = $repository->findWithCommentCount();
} 

要匹配findWithCommentCount()返回的新结构,请查找以下代码:

<?php foreach ($posts as $post): ?>

并将前面的代码替换为下一个代码段:

<?php
    foreach ($results as $result):
        $post = $result[0];
        $commentCount = $result[1];
?>

如前所述,在处理此类案件时,使用定制水合器是一种更好的做法。

您还应查看自定义 AST Walker,如以下网站所示:

http://docs.doctrine-project.org/en/latest/cookbook/dql-custom-walkers.html

查找以下代码段:

<?php if (empty($posts)): ?>

并将前面的代码替换为下一个代码段:

<?php if (empty($results)): ?>

现在是显示评论数量的时候了。在标记列表后插入以下代码:

        <?php if ($commentCount == 0): ?>
            Be the first to comment this post.
        <?php elseif ($commentCount == 1): ?>
            One comment
        <?php else: ?>
            <?= $commentCount ?> comments
        <?php endif ?>

由于web/位置的index.php文件也使用findHavingTags()方法显示标签物品列表,我们也需要更新此方法。这是使用以下代码完成的:

            // …
            ->addSelect('t')
            ->addSelect('COUNT(c.id)')
            ->leftJoin('p.comments', 'c')
            // …

总结

在本章中,我们学习了 DQL、它与 SQL 的区别以及它的查询生成器。我们还学习了实体存储库的概念以及如何创建自定义存储库。

即使从这些主题和一般的条令中还有很多东西需要学习,我们的知识应该足以开始使用条令作为一个持久性系统开发完整而复杂的应用程序。

在本书的最后一章第 5 章继续中,我们将进一步介绍一些更高级的主题,包括如何处理继承、如何进行本机 SQL 查询以及事件系统的基础知识。