Laravel Database——Eloquent Model 关联模型加载与查询

前言

本文 GitBook 地址: https://www.gitbook.com/book/leoyang90/lar...

我们在上一篇文章中介绍了模型关系的定义初始化,我们可以看到,在初始化的过程中 laravel 已经为各种关联关系的模型预先插入了初始的 where 条件。本文将会进一步介绍如何添加自定义的查询条件,如何加载、预加载关联模型。

 

关联模型的加载

当我们定义关联模型后:

class User extends Model
{
    /**
     * 获得与用户关联的电话记录。
     */
    public function phone()
    {
        $this->hasOne('App\Phone', 'user_id', 'id');
    }
}

我们可以像成员变量一样来获取与之关联的模型:

$user = App\User::find(1);

foreach ($user->posts as $post) {
    //
}

实际上,模型的属性获取函数的确可以加载关联模型:

public function getAttribute($key)
{
    if (! $key) {
        return;
    }

    ...

    return $this->getRelationValue($key);
}

getRelationValue 函数专用于加载我们之前定义的关联模型:

public function getRelationValue($key)
{
    if ($this->relationLoaded($key)) {
        return $this->relations[$key];
    }

    if (method_exists($this, $key)) {
        return $this->getRelationshipFromMethod($key);
    }
}

public function relationLoaded($key)
{
    return array_key_exists($key, $this->relations);
}

可以看到,关联的加载带有缓存,laravel 首先会验证当前关联关系是否已经被加载,如果加载过,那么直接返回缓存结果。

protected function getRelationshipFromMethod($method)
{
    $relation = $this->$method();

    if (! $relation instanceof Relation) {
        throw new LogicException(get_class($this).'::'.$method.' must return a relationship instance.');
    }

    return tap($relation->getResults(), function ($results) use ($method) {
        $this->setRelation($method, $results);
    });
}

当我们调用 $user->posts 语句的时候,laravel 会调用 posts 函数,该函数开始定义关联关系,并且返回 hasOne 对象,在这里将会调用 getResults 函数来加载关联模型:

 public function getResults()
{
    return $this->query->first() ?: $this->getDefaultFor($this->parent);
}

getDefaultFor 函数用于在未查询到任何关联模型时的情况。我们在定义关联的时候,可以提供默认的方法来控制返回的结果:

public function user()
{
    return $this->belongsTo('App\User')->withDefault();
}

public function user()
{
    return $this->belongsTo('App\User')->withDefault([
        'name' => '游客',
    ]);
}

public function user()
{
    return $this->belongsTo('App\User')->withDefault(function ($user) {
        $user->name = '游客';
    });
}

withDefault 可以提供空值、数组、闭包函数等等选项,getDefaultFor 函数在关联没有查询到结果的时候,按要求返回一个模型:

public function withDefault($callback = true)
{
    $this->withDefault = $callback;

    return $this;
}

protected function getDefaultFor(Model $parent)
{
    if (! $this->withDefault) {
        return;
    }

    $instance = $this->newRelatedInstanceFor($parent);

    if (is_callable($this->withDefault)) {
        return call_user_func($this->withDefault, $instance) ?: $instance;
    }

    if (is_array($this->withDefault)) {
        $instance->forceFill($this->withDefault);
    }

    return $instance;
}

获取到关联模型后,就要放入缓存当中,以备后续情况使用:

public function setRelation($relation, $value)
{
    $this->relations[$relation] = $value;

    return $this;
}

 

关联模型的预加载

with 函数

当作为属性访问 Eloquent 关联时,关联数据是「懒加载」的。意味着在你第一次访问该属性时,才会加载关联数据。不过,当你查询父模型时,Eloquent 还可以进行「预加载」关联数据。预加载避免了 N + 1 查询问题。

预加载可以一次操作中预加载关联模型并且自定义用于 select 的列,可以预加载几个不同的关联,还可以预加载嵌套关联,预加载关联数据的时候,为查询指定额外的约束条件:

$books = App\Book::with(['author:id,name'])->get();

$books = App\Book::with(['author', 'publisher'])->get();

$books = App\Book::with('author.contacts')->get();

$users = App\User::with(['posts' => function ($query) {
    $query->where('title', 'like', '%first%');
}])->get();

我们来看看 with 函数:

public static function with($relations)
{
    return (new static)->newQuery()->with(
        is_string($relations) ? func_get_args() : $relations
    );
}

预加载调用 Eloquent/builderwith 函数:

public function with($relations)
{
    $eagerLoad = $this->parseWithRelations(is_string($relations) ? func_get_args() : $relations);

    $this->eagerLoad = array_merge($this->eagerLoad, $eagerLoad);

    return $this;
}

eagerLoad 成员变量用于存放预加载的关联关系,parseWithRelations 用于解析关联关系:

protected function parseWithRelations(array $relations)
{
    $results = [];

    foreach ($relations as $name => $constraints) {
        if (is_numeric($name)) {
            $name = $constraints;

            list($name, $constraints) = Str::contains($name, ':')
                        ? $this->createSelectWithConstraint($name)
                        : [$name, function () {
                            //
                        }];
        }

        $results = $this->addNestedWiths($name, $results);

        $results[$name] = $constraints;
    }

    return $results;
}

当我们在模型关系中写入 : 符合的时候,说明我们不想 select *,而是想要只查询特定的字段,createSelectWithConstraint:

protected function createSelectWithConstraint($name)
{
    return [explode(':', $name)[0], function ($query) use ($name) {
        $query->select(explode(',', explode(':', $name)[1]));
    }];
}

也就是为关联关系添加 select 条件。

当我们想要进行嵌套查询的时候,需要在关联关系中写下 .addNestedWiths :

protected function addNestedWiths($name, $results)
{
    $progress = [];

    foreach (explode('.', $name) as $segment) {
        $progress[] = $segment;

        if (! isset($results[$last = implode('.', $progress)])) {
            $results[$last] = function () {
                //
            };
        }
    }

    return $results;
}

可以看到,addNestedWiths 为嵌套的模型关系赋予默认的空闭包函数,例如 a.b.caddNestedWiths 返回的 results 数组中会有三个成员: aa.ba.b.c,这三个变量的闭包函数都是空。

接下来,parseWithRelationsa.b.c 的闭包函数重新赋值,将用户定义的约束条件赋予给 a.b.c

get 函数预加载

with 函数为 laravel 提供了需要预加载的关联关系,get 函数在从数据库中获取父模型的数据后,会将需要预加载的模型也一并取出来:

public function get($columns = ['*'])
{
    $builder = $this->applyScopes();

    if (count($models = $builder->getModels($columns)) > 0) {
        $models = $builder->eagerLoadRelations($models);
    }

    return $builder->getModel()->newCollection($models);
}

顾名思义 eagerLoadRelations 函数就是获取预加载模型的的函数:

public function eagerLoadRelations(array $models)
{
    foreach ($this->eagerLoad as $name => $constraints) {
        // For nested eager loads we'll skip loading them here and they will be set as an
        // eager load on the query to retrieve the relation so that they will be eager
        // loaded on that query, because that is where they get hydrated as models.
        if (strpos($name, '.') === false) {
            $models = $this->eagerLoadRelation($models, $name, $constraints);
        }
    }

    return $models;
}

在这里,很让人费解的是 if 条件,这个条件语句看起来排除了嵌套预加载关系。例如上面的 a.b.ceagerLoadRelations 只会加载 a 这个关联关系。其实原因是:

// For nested eager loads we'll skip loading them here and they will be set as an
eager load on the query to retrieve the relation so that they will be eager
loaded on that query, because that is where they get hydrated as models.

翻译过来就是说,嵌套预加载要一步一步的来,第一次只加载 a,获得了 a 的关联模型之后,第二次再加载 b,最后加载 c。这里看不懂没关系,答案在下面的代码中:

protected function eagerLoadRelation(array $models, $name, Closure $constraints)
{
    $relation = $this->getRelation($name);

    $relation->addEagerConstraints($models);

    $constraints($relation);

    return $relation->match(
        $relation->initRelation($models, $name),
        $relation->getEager(), $name
    );
}

eagerLoadRelation 是预加载关联关系的核心,我们可以看到加载关联模型关系主要有四个步骤:

  • 通过关系名来调用 hasOne 等函数来加载模型关系 relation
  • 利用 models 来为模型关系添加约束条件
  • 调用 with 函数附带的约束条件
  • 从数据库获取关联模型并匹配到各个父模型中,作为父模型的属性

我们先从调用关联函数 getRelation 来说:

getRelation

public function getRelation($name)
{
    $relation = Relation::noConstraints(function () use ($name) {
        try {
            return $this->getModel()->{$name}();
        } catch (BadMethodCallException $e) {
            throw RelationNotFoundException::make($this->getModel(), $name);
        }
    });

    $nested = $this->relationsNestedUnder($name);

    if (count($nested) > 0) {
        $relation->getQuery()->with($nested);
    }

    return $relation;
}

我们在上一个文章说过,hasOne 等函数会自动加约束条件例如:


select phone where phone.user_id = user.id

但是这个约束条件并不适用于预加载,因为预加载的父模型通常不只只一个。因此需要调用函数 noConstraints 来避免加载约束条件:

public static function noConstraints(Closure $callback)
{
    $previous = static::$constraints;

    static::$constraints = false;

    try {
        return call_user_func($callback);
    } finally {
        static::$constraints = $previous;
    }
}

接下来,就要调用定义关联的函数:

return $this->getModel()->{$name}();

下面的 relationsNestedUnder 函数用于加载嵌套的预加载关联关系:

protected function relationsNestedUnder($relation)
{
    $nested = [];

    foreach ($this->eagerLoad as $name => $constraints) {
        if ($this->isNestedUnder($relation, $name)) {
            $nested[substr($name, strlen($relation.'.'))] = $constraints;
        }
    }

    return $nested;
}

protected function isNestedUnder($relation, $name)
{
    return Str::contains($name, '.') && Str::startsWith($name, $relation.'.');
}

从代码上可以看出来,如果当前的模型关系是 arelationsNestedUnder 函数会把其嵌套的关系都检测出来:a.ba.b.c,并且放入 nested 数组中:nested[b]、nested[b.c]

接下来:

if (count($nested) > 0) {
    $relation->getQuery()->with($nested);
}

就会继续递归预加载关联关系。

关联关系预加载约束条件

获得关联关系之后,就要加载各个关联关系自己的预加载约束条件:

public function addEagerConstraints(array $models)
{
    $this->query->whereIn(
        $this->foreignKey, $this->getKeys($models, $this->localKey)
    );
}

也就是从父模型的外键来为关联模型添加 where 条件。当然各个关联关系不同,这个函数也有一定的区别。

with 预加载约束条件

接下来还有加载 with 函数的约束条件 :

$constraints($relation);

匹配父模型

当关联关系的约束条件都设置完毕后,就要从数据库中来获取关联模型:

$relation->match(
    $relation->initRelation($models, $name),
    $relation->getEager(), $name
);

public function getEager()
{
    return $this->get();
}

initRelation 会为父模型设置默认的关联模型:

public function initRelation(array $models, $relation)
{
    foreach ($models as $model) {
        $model->setRelation($relation, $this->getDefaultFor($model));
    }

    return $models;
}

两步都做好了,接下来就要为父模型和子模型进行匹配了:

public function match(array $models, Collection $results, $relation)
{
    return $this->matchOne($models, $results, $relation);
}

public function matchOne(array $models, Collection $results, $relation)
{
    return $this->matchOneOrMany($models, $results, $relation, 'one');
}

protected function matchOneOrMany(array $models, Collection $results, $relation, $type)
{
    $dictionary = $this->buildDictionary($results);

    foreach ($models as $model) {
        if (isset($dictionary[$key = $model->getAttribute($this->localKey)])) {
            $model->setRelation(
                $relation, $this->getRelationValue($dictionary, $key, $type)
            );
        }
    }

    return $models;
}

匹配的过程分为两步:创建目录 buildDictionary 和设置子模型 setRelation

protected function buildDictionary(Collection $results)
{
    $dictionary = [];

    $foreign = $this->getForeignKeyName();

    foreach ($results as $result) {
        $dictionary[$result->{$foreign}][] = $result;
    }

    return $dictionary;
}

创建目录 buildDictionary 函数根据子模型的外键 foreign 将子模型进行分类,拥有同一外键的子模型放入同一个数组中。

接下来,为父模型设置子模型:

foreach ($models as $model) {
    if (isset($dictionary[$key = $model->getAttribute($this->localKey)])) {
        $model->setRelation(
            $relation, $this->getRelationValue($dictionary, $key, $type)
        );
    }
}

protected function getRelationValue(array $dictionary, $key, $type)
{
    $value = $dictionary[$key];

    return $type == 'one' ? reset($value) : $this->related->newCollection($value);
}

如果目录 dictionary 中存在父模型的主键,就会从目录中取出对应的子模型数组,并利用 setRelation 来为父模型设置关联模型。

 

关联模型的关联查询

基于存在的关联查询

官方样例:

// 获得所有至少有一条评论的文章...
$posts = App\Post::has('comments')->get();

// 获得所有有三条或三条以上评论的文章...
$posts = Post::has('comments', '>=', 3)->get();

// 获得所有至少有一条获赞评论的文章...
$posts = Post::has('comments.votes')->get();

// 获得所有至少有一条评论内容满足 foo% 条件的文章
$posts = Post::whereHas('comments', function ($query) {
    $query->where('content', 'like', 'foo%');
})->get();

has 函数用于基于存在的关联查询:

public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', Closure $callback = null)
{
    if (strpos($relation, '.') !== false) {
        return $this->hasNested($relation, $operator, $count, $boolean, $callback);
    }

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

    $method = $this->canUseExistsForExistenceCheck($operator, $count)
                    ? 'getRelationExistenceQuery'
                    : 'getRelationExistenceCountQuery';

    $hasQuery = $relation->{$method}(
        $relation->getRelated()->newQuery(), $this
    );

    if ($callback) {
        $hasQuery->callScope($callback);
    }

    return $this->addHasWhere(
        $hasQuery, $relation, $operator, $count, $boolean
    );
}

has 函数的步骤:

  • 获取无约束的关联关系
  • 为关联关系添加 existence 约束
  • 为关联关系添加 has 外部约束
  • 将关联关系添加到 where 条件中

无约束的关联关系

protected function getRelationWithoutConstraints($relation)
{
    return Relation::noConstraints(function () use ($relation) {
        return $this->getModel()->{$relation}();
    });
}

这个不用多说,和预加载的原理一样。

existence 约束

关系模型的 existence 约束条件很简单:

select * from post where user.id = post.user_id

laravel 还考虑一种特殊情况,那就是自己关联自己,这个时候就会为模型命名一个新的 hash

select * from user as wedfklk where user.id = wedfklk.foreignKey

源代码比较简单:

public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*'])
{
    if ($query->getQuery()->from == $parentQuery->getQuery()->from) {
        return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns);
    }

    return parent::getRelationExistenceQuery($query, $parentQuery, $columns);
}

public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*'])
{
    $query->from($query->getModel()->getTable().' as '.$hash = $this->getRelationCountHash());

    $query->getModel()->setTable($hash);

    return $query->select($columns)->whereColumn(
        $this->getQualifiedParentKeyName(), '=', $hash.'.'.$this->getForeignKeyName()
    );
}

public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*'])
{
    return $query->select($columns)->whereColumn(
        $this->getQualifiedParentKeyName(), '=', $this->getExistenceCompareKey()
    );
}

public function getExistenceCompareKey()
{
    return $this->getQualifiedForeignKeyName();
}

ExistenceCount 约束

ExistenceCount 约束只是 select * 变成了 select count(*):

select count(*) from post where user.id = post.user_id

源代码:

public function getRelationExistenceCountQuery(Builder $query, Builder $parentQuery)
{
    return $this->getRelationExistenceQuery(
        $query, $parentQuery, new Expression('count(*)')
    );
}

关联关系添加到 where 条件

当关联关系的 存在 约束设置完毕后,就要加载到父模型的 where 条件中,一般会作为父模型的子查询:

protected function addHasWhere(Builder $hasQuery, Relation $relation, $operator, $count, $boolean)
{
    $hasQuery->mergeConstraintsFrom($relation->getQuery());

    return $this->canUseExistsForExistenceCheck($operator, $count)
            ? $this->addWhereExistsQuery($hasQuery->toBase(), $boolean, $operator === '<' && $count === 1)
            : $this->addWhereCountQuery($hasQuery->toBase(), $operator, $count, $boolean);
}

public function addWhereExistsQuery(Builder $query, $boolean = 'and', $not = false)
{
    $type = $not ? 'NotExists' : 'Exists';

    $this->wheres[] = compact('type', 'operator', 'query', 'boolean');

    $this->addBinding($query->getBindings(), 'where');

    return $this;
}

protected function addWhereCountQuery(QueryBuilder $query, $operator = '>=', $count = 1, $boolean = 'and')
{
    $this->query->addBinding($query->getBindings(), 'where');

    return $this->where(
        new Expression('('.$query->toSql().')'),
        $operator,
        is_numeric($count) ? new Expression($count) : $count,
        $boolean
    );
}

existence 约束最后条件:

select * from user where exists (select * from phone where phone.user_id=user.id)

ExistenceCount 约束:

select * from user where (select count(*) from phone where phone.user_id=user.id) >= 3

嵌套查询

嵌套查询需要进行递归来调用 has 函数:

protected function hasNested($relations, $operator = '>=', $count = 1, $boolean = 'and', $callback = null)
{
    $relations = explode('.', $relations);

    $closure = function ($q) use (&$closure, &$relations, $operator, $count, $callback) {
        count($relations) > 1
            ? $q->whereHas(array_shift($relations), $closure)
            : $q->has(array_shift($relations), $operator, $count, 'and', $callback);
    };

    return $this->has(array_shift($relations), '>=', 1, $boolean, $closure);
}

public function whereHas($relation, Closure $callback = null, $operator = '>=', $count = 1)
{
    return $this->has($relation, $operator, $count, 'and', $callback);
}

例如

$posts = Post::has('comments.votes')->get();

首先 hasNested 会返回:

$this->has('comments', '>=', 1, 'and', function ($q) use (&$closure, ‘votes’, '>=', 1, $callback) {
        $q->has(‘votes’, '>=', 1, 'and', $callback);
    }
);

生成的 sql:

select * from post where exist (select * from comment where comment.post_id=post.id and where exist (select * from vote where vote.comment_id=comment.id))

基于不存在的关联查询

基于不存在的关联查询只是基于存在的关联查询

public function doesntHave($relation, $boolean = 'and', Closure $callback = null)
{
    return $this->has($relation, '<', 1, $boolean, $callback);
}

public function whereDoesntHave($relation, Closure $callback = null)
{
    return $this->doesntHave($relation, 'and', $callback);
}

关联数据计数

如果您只想统计结果数而不需要加载实际数据,那么可以使用 withCount 方法,此方法会在您的结果集模型中添加一个 {关联名}_count 字段。例如:

$posts = App\Post::withCount('comments')->get();
//select *,(select count(*) from comment where comment.post_id=post.id) as comments_count from post 

foreach ($posts as $post) {
    echo $post->comments_count;
}

//多个关联数据「计数」,并为其查询添加约束条件:
$posts = Post::withCount(['votes', 'comments' => function ($query) {
    $query->where('content', 'like', 'foo%');
}])->get();
//select *,(select count(*) from comment where comment.post_id=post.id and content like 'foo%') as comments_count,(select count(*) from votes where vote.post_id=post.id) as votes_count from post 

echo $posts[0]->votes_count;
echo $posts[0]->comments_count;

//可以为关联数据计数结果起别名,允许在同一个关联上多次计数:
$posts = Post::withCount([
    'comments',
    'comments as pending_comments_count' => function ($query) {
        $query->where('approved', false);
    }
])->get();
//select *,(select count(*) from comment where comment.post_id=post.id) as comments_count,(select count(*) from comment where comment.post_id=post.id and approved=false) as pending_comments_count from post

echo $posts[0]->comments_count;

echo $posts[0]->pending_comments_count;

withCount 的源代码与 has 的代码高度相似:

public function withCount($relations)
{
    if (empty($relations)) {
        return $this;
    }

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

    $relations = is_array($relations) ? $relations : func_get_args();

    foreach ($this->parseWithRelations($relations) as $name => $constraints) {
        $segments = explode(' ', $name);

        unset($alias);

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

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

        $query = $relation->getRelationExistenceCountQuery(
            $relation->getRelated()->newQuery(), $this
        );

        $query->callScope($constraints);

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

        $column = $alias ?? Str::snake($name.'_count');

        $this->selectSub($query->toBase(), $column);
    }

    return $this;
}
  • 解析关联关系名称
  • 获取无约束的关联关系
  • 为关联关系添加 existenceCount 约束
  • 为关联关系添加 with 外部约束
  • 将关联关系添加到 where 条件中
  • 设置 alias 别名
  • 创建 select 子查询
本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 4年前 自动加精
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 1

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