🐈

laravelのリレーションはいったい何をやっているのだろう? --Eloquent ソースコードリーディング(with編)

2023/04/14に公開

はじめに

前回
https://zenn.dev/cube/articles/5bcb95776a956e

今回はwith+hasManyでの処理を見ていきます。
見方としては前回と同じようにゴールとなるSQL文の要素がQueryBuilderに上手くセットされているかを見ていきます。

今回もUser,Postを1対多としてやっていきます。

 User::with('posts')->get()
 
 select * from 'users';
 select * from 'posts' where 'posts'.'user_id' in (前のクエリで取得したUserId達);

となります。
前回ちらっとwithでは二つクエリが発行されるといいましたが上記のようになっています。
一つ目のクエリでwithを除いた処理、二つ目のクエリでwith部分のリレーションの処理です。

一つ目のクエリ(select * from 'users')

with

::withから見ていきます。

Model.php

//$relations = "posts"
//static::query() = EloquentQuery 

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

ここでfunc_get_args()は関数の引数をarrayで返します。
やりたいことは$relationsがstringであろうとarrayであろうと、絶対eloquentBuilderのwithにarrayを渡すことです。

EloquentBuilder.php
$relations = ["posts"]

public function with($relations, $callback = null)
       {
           if ($callback instanceof Closure) {
               $eagerLoad = $this->parseWithRelations([$relations => $callback]);
           } else {
               $eagerLoad = $this->parseWithRelations(is_string($relations) ? func_get_args() : $relations);
           }
   
           $this->eagerLoad = array_merge($this->eagerLoad, $eagerLoad);
   
           return $this;
       }
       
 protected function parseWithRelations(array $relations)
         {
             if ($relations === []) {
                 return [];
             }
     
             $results = [];
     
             foreach ($this->prepareNestedWithRelationships($relations) as $name => $constraints) {
              
                 $results = $this->addNestedWiths($name, $results);
     
                 $results[$name] = $constraints;
             }
     
             return $results;
         }

withで重要なことは$this->eagerLoad["posts"] = ...と$this->eagerLoadにwithで指定したリレーションを格納することです。
そのためにprepareNestedWithRelationships以降で処理をするのですが、処理が複雑になっているのは、
with("posts:id")であったり、
with(['posts' => function ($query) {
$query->where('content');
}])、
with(["posts","likes"]),
with("posts.comments")みたいな処理に対応するためです。

 protected function prepareNestedWithRelationships($relations, $prefix = '')
           {
               $preparedRelationships = [];
       
               if ($prefix !== '') {
                   $prefix .= '.';
               }
       
               foreach ($relations as $key => $value) {
                   if (! is_string($key) || ! is_array($value)) {
                       continue;
                   }
       
                   [$attribute, $attributeSelectConstraint] = $this->parseNameAndAttributeSelectionConstraint($key);
       
                   $preparedRelationships = array_merge(
                       $preparedRelationships,
                       ["{$prefix}{$attribute}" => $attributeSelectConstraint],
                       $this->prepareNestedWithRelationships($value, "{$prefix}{$attribute}"),
                   );
       
                   unset($relations[$key]);
               }
       
               
               foreach ($relations as $key => $value) {
                   if (is_numeric($key) && is_string($value)) { <----[$key, $value] = $this->parseNameAndAttributeSelectionConstraint($value);
                   }
       
                   $preparedRelationships[$prefix.$key] = $this->combineConstraints([
                       $value,
                       $preparedRelationships[$prefix.$key] ?? static function () {
                           //
                       },
                   ]);
               }
       
               return $preparedRelationships;
           }
           
 protected function parseNameAndAttributeSelectionConstraint($name)
           {
                   return str_contains($name, ':')
                       ? $this->createSelectWithConstraint($name)
                       : [$name, static function () {
                           //
                       }];
            }

今回はwith("posts")なので、prepareNestedWithRelationshipsは★印の処理に行き、
下記のようになります。

$name -> "posts" ,$constraints ->  static function () {
                                        //
                                    }
              

parseWithRelationsに戻ると$resultが下記のようになり、あとはwithで$this->eagerLoadにarray_mergeで混ぜ込むだけです。

$result = [
               "posts" => static function () {
                                        //
                                    }
           ]

ここまででwithの処理は終わりです。次はgetに行きましょう。

get

withの戻り値は$thisとなっており、EloquentBuilderクラスです。
なのでEloquentBuilder->get()を見ましょう。

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

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

$models = $builder->getModels($columns)の部分は前回に見たところですね。
$models = []Userとなります。
ここまでが一つ目のクエリ(select * from 'users')で、
ここから$models = $builder->eagerLoadRelations($models)に入ってきます。

二つ目のクエリ( select * from 'posts' where 'posts'.'user_id' in ~)

EloquentBuilder->eagerLoadRelations([]User)を見ていきます。

EloquentBuilder.php

//$models = []User ,$name = "posts", $constraints = static function(){} <-何もしない関数
 
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
              );
     }

$this->Relationを見ていきます。

EloquentBuilder.php

  //$this->getModel() = User , $name = "posts"
  
  public function getRelation($name)
     {
         $relation = Relation::noConstraints(function () use ($name) {
             try {
                 return $this->getModel()->newInstance()->$name();
             } catch (BadMethodCallException $e) {
                 throw RelationNotFoundException::make($this->getModel(), $name);
             }
         });
         
         $nested = $this->relationsNestedUnder($name);
 
         if (count($nested) > 0) {
             $relation->getQuery()->with($nested);
         }
 
         return $relation;
     }

$this->getModel()->newInstance()->$name()は
User->posts()となるので、$relationにはposts()で作ったhasManyが入ることになる
nested以降は名前の通りnestedしているwithの時の処理なので今回はパス

つまりgetRelationからはUser->posts()が返る、この処理は前回見ました。

eagerLoadRelationに戻って、$relation->addEagerConstraintsを見ます。
$relation = User->posts()なので、HasMany->addEagerConstraintsに行きましょう。

HasManyの親であるHasOneOrManyにaddEagerConstraintsがあります。

HasOneOrMany.php

//$models = []User
//$this->parent= UserModel
//$this->foreignKey=user_id
//$this->localKey= id

public function addEagerConstraints(array $models)
   {
       $whereIn = $this->whereInMethod($this->parent, $this->localKey);

       $this->getRelationQuery()->{$whereIn}(
           $this->foreignKey, $this->getKeys($models, $this->localKey)
       );
   }
  //$model = UserModel
  //$key = id
  
 protected function whereInMethod(Model $model, $key)
        {
            return $model->getKeyName() === last(explode('.', $key))
                        && in_array($model->getKeyType(), ['int', 'integer'])
                            ? 'whereIntegerInRaw'
                            : 'whereIn';
        }
Model.php

public function getKeyName()
     {
       return $this->primaryKey;
     }

whereInMethodの処理は、
UserのprimaryKeyはidで、
 $model->getKeyName() === last(explode('.', $key))はtrue
modelのkeyTypeもoverrideしていなければprotected $keyType = 'int';となっているので、
in_array($model->getKeyType(), ['int', 'integer'])もtrue

なのでwhereIn = "whereIntegerInRaw"となります。

  HasOneOrMany.php
  //$this->query = post->newQuery()
  protected function getRelationQuery()
       {
           return $this->query;
       }

getRelationQueryはQueryBuilderを返します。

addEagerConstraintsに戻ると、

HasOneOrMany.php

//$models = []User
//$this->parent= UserModel
//$this->foreignKey="posts.user_id"
//$this->localKey= id

//$this->getKeys([]User, id) = []UserId
public function addEagerConstraints(array $models)
  {
      $queryBuilder->whereInRaw(
          user_id,[]UserId)
      );
  }

となるので、QueryBuilderのwhereInRawに行きましょう。

 QueryBuilder.php
 //$column = "posts.user_id"
 //$values = []UserId
 
 public function whereIntegerInRaw($column, $values, $boolean = 'and', $not = false)
  {
      $type = 'InRaw';

      if ($values instanceof Arrayable) {
          $values = $values->toArray();
      }

      foreach ($values as &$value) {
          $value = (int) $value;
      }

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

      return $this;
  }

where句をセットしているだけですね。
ここで下記のwhere句がセットされています。

where "posts.user_id" in []UserId

再びeagerLoadRelationに戻りましょう。

EloquentBuilder.php

//$models = []User ,$name = "posts", $constraints = static function(){} <-何もしない関数

protected function eagerLoadRelation(array $models, $name, Closure $constraints)
   {
   
            $relation = HasMany
            return $relation->match(
                $relation->initRelation($models, $name),
                $relation->getEager(), $name
            );
   }

$relationはHasManyのことでしたね。
hasMany->initRelation -> hasMany->getEager -> hasMany->matchの順にみていきましょう。
まずはhasMany->initRelationから。

 HasMany.php
 
 //$models = []User
 //$relation = "posts"
 //$this->related = 
 public function initRelation(array $models, $relation)
    {
       foreach ($models as $model) {
            $model->setRelation($relation, $this->related->newCollection());
          }
               
       return $models;
    }
    
Model.php

 model->newCollectionは名前の通りnewCollectgionを返すだけ
 public function newCollection(array $models = [])
     {
        return new Collection($models);
     }
         
  //$relation = "posts"
  //$value = new Collection
  public function setRelation($relation, $value)
      {
         $this->relations[$relation] = $value;
      
         return $this;
      }

User一つ一つにsetRelationを実行していて、$user->relations["posts"] = []となります。
ここでは具体的なpostsはセットされません。

次にHasMany->getEagerを見ましょう。

HasMany.php
public function getEager()
    {
       return $this->get();
    }
    
 //$this->query = post->newQuery()だったので、EloquentBuilder
 public function get($columns = ['*'])
     {
        return $this->query->get($columns);
     }

EloquentBuilder->get()で下記のクエリを実行します、事前にwhere句がセットされているおかげですね。

select * from posts where posts.user_id in []UserId

さて、再三eagerLoadRelationに戻ります。

EloquentBuilder.php

protected function eagerLoadRelation(array $models, $name, Closure $constraints)
   {
   
            $relation = HasMany
        
            return $relation->match(
                []User,
                []Post, 
  	  "posts"
            );
   }

initRelationとgetEagerが終わって上記のようになっています、ここからはmatchで、
user->relation["posts"]=[]となっていた部分に値をセットしていきましょう。

HasMany.php

//$models = []User,$results = []Post,$relation = "posts"
public function match(array $models, Collection $results, $relation)
     {
         return $this->matchMany($models, $results, $relation);
     }
     
public function matchMany(array $models, Collection $results, $relation)
      {
          return $this->matchOneOrMany($models, $results, $relation, 'many');
      }
HasOneorMany.php

  $models = []User
  $results = []Post 
  $relation = "posts"
  $type = "many"
  $this->localKey= id
  
  
  protected function matchOneOrMany(array $models, Collection $results, $relation, $type)
      {
          $dictionary = $this->buildDictionary($results);
  
          foreach ($models as $model) {
              if (isset($dictionary[$key = $this->getDictionaryKey($model->getAttribute($this->localKey))])) {
                  $model->setRelation(
                      $relation, $this->getRelationValue($dictionary, $key, $type)
                  );
              }
          }
  
          return $models;
      }
      
HasOneorMany.php
 
 //$foreign="user_id"  $results = []Post
 protected function buildDictionary(Collection $results)
     {
         $foreign = $this->getForeignKeyName(); 
 
         return $results->mapToDictionary(function ($result) use ($foreign) {
             return [$this->getDictionaryKey($result->{$foreign}) => $result];
         })->all();
     }  

$this->buildDictinaryでやっていることは取得した[]Postの整形です。
具体例で示した方がわかりやすいので[]Postを下記のようにします。

 
        [
          Post{id:1,user_id:1},
          Post{id:2,user_id:1},
          Post{id:3,user_id:2},
         ]

この[]Postを下記のようにするのがbuildDictionaryの役目です。


 [
  1(user_id) : [  Post{id:1,user_id:1},Post{id:2,user_id:1}],
  2(user_id):  [  Post{id:3,user_id:2}                     ]
 ]

buildDictionaryが終わったところでmatchOneOrManyの残りを見ていきましょう。

HasOneorMany.php

  $models = []User
  $results = []Post 
  $relation = "posts"
  $type = "many"
  $this->localKey= id
  
  
  protected function matchOneOrMany(array $models, Collection $results, $relation, $type)
      {
          $dictionary = [
                         1(user_id) : [  Post{id:1,user_id:1},Post{id:2,user_id:1}],
                         2(user_id):  [  Post{id:3,user_id:2}                     ]
                         ];
  
          foreach ($models as $model) {
              if (isset($dictionary[$key = $this->getDictionaryKey($model->getAttribute($this->localKey))])) {
                  $model->setRelation(
                      $relation, $this->getRelationValue($dictionary, $key, $type)
                  );
              }
          }
  
          return $models;
      }
      
isset($dictionary[$key = $this->getDictionaryKey($model->getAttribute($this->localKey))])

の処理を見ていきます。

getAttributeはmodelが使用しているHasAttributesトレイトにあります。

HasAttributes.php

  public function getAttribute($key)
       {
           if (! $key) {
               return;
           }
           if (array_key_exists($key, $this->attributes) || <----array_key_exists($key, $this->casts) ||
               $this->hasGetMutator($key) ||
               $this->hasAttributeMutator($key) ||
               $this->isClassCastable($key)) {
               return $this->getAttributeValue($key);
           }
   
           if (method_exists(self::class, $key)) {
               return;
           }
   
           return $this->getRelationValue($key);
       }

今回はuser->attributesにidがあるので★印がtrueになり、getAttributeValueで値を取得します、User{id:1}だったら1を取得といった具合ですね。
値を取得するのに複雑なのはMutatorやCastの処理が絡むからです。

public function getAttributeValue($key)
    {
        return $this->transformModelValue($key, $this->getAttributeFromArray($key));
    }
 protected function transformModelValue($key, $value)
    {
        if ($this->hasGetMutator($key)) {
            return $this->mutateAttribute($key, $value);
        } elseif ($this->hasAttributeGetMutator($key)) {
            return $this->mutateAttributeMarkedAttribute($key, $value);
        }
        ...
        return $value;
    }

isset($dictionary[$key = $this->getDictionaryKey(user_id))])

getDictionaryは下記のようになっていて、今回はそのままuser_idを返すだけです。

 protected function getDictionaryKey($attribute)
    {
        if (is_object($attribute)) {
            if (method_exists($attribute, '__toString')) {
                return $attribute->__toString();
            }

            if (function_exists('enum_exists') &&
                $attribute instanceof BackedEnum) {
                return $attribute->value;
            }

            throw new InvalidArgumentException('Model attribute value is an object but does not have a __toString method.');
        }

        return $attribute;
    }

なので


 $dictionary = [
   1(user_id) : [  Post{id:1,user_id:1},Post{id:2,user_id:1}],
   2(user_id):  [  Post{id:3,user_id:2}                     ]
 ]
 
 isset($dictionary[user_id])

上記のDictionaryに$key=user_id(例えば1とか)が存在するか確かめているだけですね。

matchOneOrManyのsetRelation部分を見ていきましょう。
[]Userの各Userに対応する[]Postをセットしているのですが、ここでは下記ユーザーを例として見ていきます。

User{id:1}
HasOneorMany.php

  $models = []User
  $results = []Post 
  $relation = "posts"
  $type = "many"
  $this->localKey= id
  $key = $this->getDictionaryKey($user->getAttribute("id"))で取得したuserのid,今回は1
  
  protected function matchOneOrMany(array $models, Collection $results, $relation, $type)
      {
       
      foreach ($models as $model) {
                  $model->setRelation(
                      $relation, $this->getRelationValue($dictionary, $key, $type)
                  );
              }
          }
    
          return $models;
     }
      
  HasOneorMany.php
  $type = "many"
  $dictionary = [
     1(user_id) : [  Post{id:1,user_id:1},Post{id:2,user_id:1}],
     2(user_id):  [  Post{id:3,user_id:2}                     ]
    ]
  $key = 1(user_id)
  
  $this->related = PostModel
    
  protected function getRelationValue(array $dictionary, $key, $type)
           {
               $value = $dictionary[$key];
       
               return $type === 'one' ? reset($value) : $this->related->newCollection($value);
           }

type === "many"なので、PostModel->newCollection([ Post{id:1,user_id:1},Post{id:2,user_id:1}])となり、

getRelationValueも[ Post{id:1,user_id:1},Post{id:2,user_id:1}]です。


HasRelationShips.php
 
$relation = "posts"
$value = [ Post{id:1,user_id:1},Post{id:2,user_id:1}]
public function setRelation($relation, $value)
    {
        $this->relations[$relation] = $value;

        return $this;
    }

上記でuser{id:1}のrelationsに[ Post{id:1,user_id:1},Post{id:2,user_id:1}]がセットされます。

他のuserについても同じように[]Postがセットされるだけです。

これでwithによってuserにpostがセットされる部分が終わりました。

さて、最後に一つ残った謎があります。
それは何故user->postsと呼ぶとうまくセットした[]Postが取得できるのか?です。
withを使ってgetでSQLを実行しても結局user->relations["posts"]に値がセットされるだけで、
userにpostsというプロパティは存在しません。
この部分を最後に見ていきましょう。

なぜuser->postsが成立するか

前述したとおりuserにpostsというプロパティは存在しません。posts()は関数なので別の話です。
プロパティが存在しないときはマジックメソッドを見ることになります。

 Model.php

 $key = "posts"
 public function __get($key)
     {
         return $this->getAttribute($key);
     }

getAttributeはさっきも観たやつですが今回$keyであるpostsはUserModelのどこにも定義されていません。
なので一番最後のgetRelationValueまで行きます。

  HasAttribute.php
  
  $key = "posts"
  public function getAttribute($key)
     {
         ...
 
         return $this->getRelationValue($key); <----ここ
     }
  HasAttribute.php
  public function getRelationValue($key)
        {
            ...
            if ($this->relationLoaded($key)) {
                return $this->relations[$key];  <---------ここ
            }
    
            if (! $this->isRelation($key)) {
                return;
            }
    
            if ($this->preventsLazyLoading) {
                $this->handleLazyLoadingViolation($key);
            }
            return $this->getRelationshipFromMethod($key);
        }

withによってUserのrelationsは下記のようになっていたのでuser->postsで[]Postを引き出せるわけです。

user->relations["posts"] = [ Post{id:1,user_id:1},Post{id:2,user_id:1}]

おわりに

今回はwithによってなぜクエリが二つ発行されるか、なぜ上手くリレーション先のモデルが取ってこれるかを見ました。
with("posts.comments")など複雑な場合は見ませんでしたが、同じように見ていけば処理は終えると思います。

コードリーディングしても忘れてしまって同じコード何回も見直すとかあるので自分用という側面もありますが、処理を追いたい方、僕と同じように忘れてしまいがちの方の役にも立てればうれしいです。

Discussion