Laravel ORM withAggregate 用法简析

最近遇到一个需求,要对模型model的某个关联表relation中的created_at字段做withMax查询,只需要日期信息不需要时间,也就是找出关联表中创建时间最新的记录的日期值。

我原本的做法先是用withMax('relation', 'created_at')取出来之后,再用循环对relation_created_at_max进行处理,这样实在是不太优雅,我尝试使用过withMax('relation', 'date(created_at)'),但结果提示找不到date(created_at)这一列,于是我不得不点进去看了一下withMax的源码:

    /**
     * Add subselect queries to include the max of the relation's column.
     *
     * @param  string|array  $relation
     * @param  string  $column
     * @return $this
     */
    public function withMax($relation, $column)
    {
        return $this->withAggregate($relation, $column, 'max');
    }

可以看到这个方法里面实际上就是调用了withAggregate方法:

/**
     * Add subselect queries to include an aggregate value for a relationship.
     *
     * @param  mixed  $relations
     * @param  string  $column
     * @param  string  $function
     * @return $this
     */
    public function withAggregate($relations, $column, $function = null)
    {
        if (empty($relations)) {
            return $this;
        }

        if (is_null($this->query->columns)) {
            $this->query->select([$this->query->from.'.*']);
        }

        $relations = is_array($relations) ? $relations : [$relations];

        foreach ($this->parseWithRelations($relations) as $name => $constraints) {
            // First we will determine if the name has been aliased using an "as" clause on the name
            // and if it has we will extract the actual relationship name and the desired name of
            // the resulting column. This allows multiple aggregates on the same relationships.
            $segments = explode(' ', $name);

            unset($alias);

            if (count($segments) === 3 && Str::lower($segments[1]) === 'as') {
                [$name, $alias] = [$segments[0], $segments[2]];
            }

            $relation = $this->getRelationWithoutConstraints($name);

            if ($function) {
                $hashedColumn = $this->getQuery()->from === $relation->getQuery()->getQuery()->from
                                            ? "{$relation->getRelationCountHash(false)}.$column"
                                            : $column;

                $expression = sprintf('%s(%s)', $function, $this->getQuery()->getGrammar()->wrap(
                    $column === '*' ? $column : $relation->getRelated()->qualifyColumn($hashedColumn)
                ));
            } else {
                $expression = $column;
            }

            // Here, we will grab the relationship sub-query and prepare to add it to the main query
            // as a sub-select. First, we'll get the "has" query and use that to get the relation
            // sub-query. We'll format this relationship name and append this column if needed.
            $query = $relation->getRelationExistenceQuery(
                $relation->getRelated()->newQuery(), $this, new Expression($expression)
            )->setBindings([], 'select');

            $query->callScope($constraints);

            $query = $query->mergeConstraintsFrom($relation->getQuery())->toBase();

            // If the query contains certain elements like orderings / more than one column selected
            // then we will remove those elements from the query so that it will execute properly
            // when given to the database. Otherwise, we may receive SQL errors or poor syntax.
            $query->orders = null;
            $query->setBindings([], 'order');

            if (count($query->columns) > 1) {
                $query->columns = [$query->columns[0]];
                $query->bindings['select'] = [];
            }

            // Finally, we will make the proper column alias to the query and run this sub-select on
            // the query builder. Then, we will return the builder instance back to the developer
            // for further constraint chaining that needs to take place on the query as needed.
            $alias = $alias ?? Str::snake(
                preg_replace('/[^[:alnum:][:space:]_]/u', '', "$name $function $column")
            );

            $this->selectSub(
                $function ? $query : $query->limit(1),
                $alias
            );
        }

        return $this;
    }

这段源码看上去有点恐怖,说实话我还没有完全看明白,但是只要注意到其中关键的几行代码,就可以大致上摸清楚它的用法

if ($function) {
...
}else {
    $expression = $column;
}

当这个函数有第三个参数$function传入的时候,它会走自己的一套逻辑去构造出一个sprintf('%s(%s)', ...)的一个表达式,在这套逻辑中它可能就自动加上了一些跟关联表相关的一些限定,类似于'relation'.'created_at',这样就导致我在传入date(created_at)的时候,被它解析成了'relation'.'date(created_at)',从而出现了BUG。

为了避免这种情况,我们考虑直接走else语句,不传入$function参数,也就是这样

$model->withAggregate('relation', 'max(date(created_at))')

这样就可以成功获取到我们想要的数据,唯一的缺点是这个字段会被命名为relation_max_date_created_at,导致这个结果的罪魁祸首就是这条语句

$alias = $alias ?? Str::snake(
    preg_replace('/[^[:alnum:][:space:]_]/u', '', "$name $function $column")
);

$alias变量被$name $function $column的组合覆盖掉了,为了解决这个问题,我们可以找到前面的这一行代码

if (count($segments) === 3 && Str::lower($segments[1]) === 'as') {
    [$name, $alias] = [$segments[0], $segments[2]];
}

当传入的$name里包含as的时候,$alias会被赋予一个初值,也就是as后面紧接的名字,于是我们可以把上面那条语句修改为

$model->withAggregate('relation as max_create_date', 'max(date(created_at))')

这样我们就成功地把聚合的结果保存到了max_create_date字段当中

本作品采用《CC 协议》,转载必须注明作者和本文链接
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 6

mark 经常用,学习了

3年前 评论
wjllance (楼主) 3年前

请问,如果我想查出关联表中,最新创建的数据,而且关联数据的全部字段都需要返回,该怎么写呢

3年前 评论
baber 3年前
wjllance (楼主) 3年前
wjllance (楼主) 3年前
wjllance (楼主) 3年前
winter-ice 3年前
wjllance (楼主) 3年前

@wjllance 关联关系中加上->limit(1)肯定是不对的,这样只会取出一条关系数据,结果就是,主表模型中国,relations字段的值都是一样的。

3年前 评论

withMax 是封装好的吗,文档里没找到啊

3年前 评论
baber (作者) 3年前

@fybbbb 你的框架版本多少?源码里搜肯定能搜到。具体位置:

<?php

namespace Illuminate\Database\Eloquent\Concerns;

use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\Expression;
use Illuminate\Support\Str;

trait QueriesRelationships
{
.
.
.
    /**
     * Add subselect queries to include the max of the relation's column.
     *
     * @param  string  $relation
     * @param  string  $column
     * @return $this
     */
    public function withMax($relation, $column)
    {
        return $this->withAggregate($relation, $column, 'max');
    }
.
.
.
}
3年前 评论
baber 3年前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!