🔍

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

2023/04/13に公開

はじめに

laravelのリレーションであるhasMany等は自分でSQLを書かなくても関連するModelを取って来てくれます。
今回はhasManyを題材として裏側のコードでは何をやっているかを見ていきます。

例えばUserModelとPostModelが1対多の関係で、
$user = User::find(1)で$userを取得しているとして、
$user->posts()->getとすると、下記のようなSQLが生成されます。

select * from `posts` where `posts`.`user_id` = ? and `posts`.`user_id` is not null

このSQLがうまく生成される過程を見ていきます。

結構クラス間の関係がわからなくなるので適宜この図に戻っていただければなと思います。

QueryBuilderから実際のSQLが生成されるのですが、今回のゴールはQueryBuilderというクラスにどうしたら適切な句が設定されるのかを見ることとします。
理由としてQueryBuilderが実際のSQL文を生成する過程はあまりEloquentとは関係ないからと、もう一つはちょっと難しいからという理由です。

例えばJoin句の設定は下記みたいな形で行われます。

<?php

namespace Illuminate\Database\Query;

class Builder implements BuilderContract
{
  public $joins;
   
  public $bindings = [
        'select' => [],
        'from' => [],
        'join' => [],
        'where' => [],
        'groupBy' => [],
        'having' => [],
        'order' => [],
        'union' => [],
        'unionOrder' => [],
    ];
  public function join($table, $first, $operator = null, $second = null, $type = 'inner', $where = false)
    {
        $join = $this->newJoinClause($this, $type, $table);
        if ($first instanceof Closure) {
            $first($join);

            $this->joins[] = $join;

            $this->addBinding($join->getBindings(), 'join');
        }

        ...
        return $this;
    }
}
select * from `posts` where `posts`.`user_id` = ? and `posts`.`user_id` is not null

なので、今回の目標として下記が設定される流れを見ていくこととします。
from句に posts
where句にposts.user_id = ? and posts.user_id is not null

処理が軽いメソッド等は省略していくことがありますので、分かりにくい等あればコメント頂けるとありがたいです。

from句の設定

まず初めにUserModelで下記のように設定し、user->posts()と呼んだところからスタートします。

public function posts(){
  return $this->hasMany(Post::class)
}

$this->hasMany(Post::class)としますが、hasManyメソッドはどこにあるかというと、
UserModelの親であるModelクラスが使っているHasRelationshipsトレイトにあります。

 //$related = Post::class
 //$foreignKey = user_id
 //$localKey = id
  public function hasMany($related, $foreignKey = null, $localKey = null)
    {
        $instance = $this->newRelatedInstance($related);

        $foreignKey = $foreignKey ?: $this->getForeignKey();

        $localKey = $localKey ?: $this->getKeyName();

        return $this->newHasMany(
            $instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey
        );
    }

$instanceとあり、$this->newRelatedInstance(Post::class)となっているので、
$instace = PostModelとなりそうですね。
実際の処理を見ていきます。

 protected function newRelatedInstance($class)
     {
         return tap(new $class, function ($instance) {
             if (! $instance->getConnectionName()) {
                 $instance->setConnection($this->connection);
             }
         });
     } 

new $classでPostModelを作っています。

hasManyの最後の一行に移っていくと、下記のようになっています。

 //$instance = PostModel
 //$instance->getTable() = "posts"
 //$instance->getTable().'.'.$foreignKey = "posts"."user_id"
 
 //$this = UserModel
 
 
 return $this->newHasMany(
            $instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey
        )

PostModel->newQuery()を見てきましょう。

   /**
         * Get a new query builder for the model's table.
         *
         * @return \Illuminate\Database\Eloquent\Builder
         */
    public function newQuery()
     {
         return $this->registerGlobalScopes($this->newQueryWithoutScopes());
     }
     
     registerGlocalScopeは名前の通りグローバルスコープ絡みの処理なので今回は飛ばす
     public function registerGlobalScopes($builder)
         {
             foreach ($this->getGlobalScopes() as $identifier => $scope) {
                 $builder->withGlobalScope($identifier, $scope);
             }
     
             return $builder;
         }
      
      withとwithCountも今回は関係ないので飛ばす
      withもwithCountもBuilderのもの、その名の通りBuilderパターンなので、
      newModelQueryが返すBuilderのまま返り値は変わらない
      public function newQueryWithoutScopes()
         {
             return $this->newModelQuery()
                         ->with($this->with)
                         ->withCount($this->withCount);
         }    
         
         
      public function newModelQuery()
          {
              return $this->newEloquentBuilder(
                  $this->newBaseQueryBuilder()
              )->setModel($this);
          }  
	  
       public function newEloquentBuilder($query)
           {
               return new Builder($query);
           }
       
   EloquentBuilder.php
   public function __construct(QueryBuilder $query)
    {
        $this->query = $query;
    }

newQueryからはEloqquentBuilderが返ることがわかり、EloquentBuilderはQueryBuilderを内包していることがわかります。

    /**
     * Get a new query builder instance for the connection.
     *
     * @return \Illuminate\Database\Query\Builder
     */
  protected function newBaseQueryBuilder()
           {
               return $this->getConnection()->query();
           }

QueryBuilderの生成にはpostModel->getConnection()が関わってくるのですが、複雑なのでConnection絡みはおまけでやりましょう。
ここでは、getConnection()ではIlluminate\Database\Connection.phpが返ることだけ記載します。

  Connection.php
 /**
      * Get a new query builder instance.
      *
      * @return \Illuminate\Database\Query\Builder
      */
     public function query()
     {
         return new QueryBuilder(
             $this, $this->getQueryGrammar(), $this->getPostProcessor()
         );
     }

上記の通りconnectionからQueryBuilderが生成されることがわかりました。

EloquentBuilderとQueryBuilderを生成したところで、newModelQueryに戻るとsetModel(PostModel)があるので見ていきます。

  public function newModelQuery()
          {
              return $this->newEloquentBuilder(
                  $this->newBaseQueryBuilder()
              )->setModel($this);
          }  

   //$model = PostModel
   //$this->query = $this->newBaseQueryBuilder()で生成したqueryBuilder
   //$model->getTable() = "posts"
   
   public function setModel(Model $model)
              {
                  $this->model = $model;
          
                  $this->query->from($model->getTable());
          
                  return $this;
              }
              

$this->query->fromでQueryBuilderのfrom句を設定しています。

QueryBuilder.php

$table = "posts"
public function from($table, $as = null)
    {
        if ($this->isQueryable($table)) {
            return $this->fromSub($table, $as);
        }

        $this->from = $as ? "{$table} as {$as}" : $table;

        return $this;
    }

これで、from句に postsの設定方法がわかりました。

次はwhere句にposts.user_id = ? and posts.user_id is not null部分を見ていきましょう。

where句の設定

where句の設定は実は一番初めにhasManyを呼び出した瞬間に戻ります。
最初にBuilderを紹介したかったので飛ばしていました。
newHasMany以降の処理を見ていきましょう。

 //$related = Post::class
 //$foreignKey = user_id
 //$localKey = id
  public function hasMany($related, $foreignKey = null, $localKey = null)
    {
        $instance = $this->newRelatedInstance($related);

        $foreignKey = $foreignKey ?: $this->getForeignKey();

        $localKey = $localKey ?: $this->getKeyName();

        return $this->newHasMany(
            $instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey
        );
    }
 //$query = EloquentBuiler
 //$model = UserModel
 //$foreignKey = "posts"."user_id"
 //$localKey = "id"

  protected function newHasMany(Builder $query, Model $parent, $foreignKey, $localKey)
    {
        return new HasMany($query, $parent, $foreignKey, $localKey);
    }

newHasManyはHasOneOrManyにあります。(わかりにくい...)

 HasOneOrMany.php
 
 public function __construct(Builder $query, Model $parent, $foreignKey, $localKey)
    {
        $this->localKey = $localKey;
        $this->foreignKey = $foreignKey;

        parent::__construct($query, $parent);
    }

parentはRelationクラスのことです。

 Relation.php
 
 //$query = EloquentBuiler
 //$model = UserModel
 //$this->related = EloquentBuilderのsetModelでセットしたPostModel
 
 public function __construct(Builder $query, Model $parent)
    {
        $this->query = $query;
        $this->parent = $parent;
        $this->related = $query->getModel();

        $this->addConstraints();
    }
    
  

ここらへんは親子関係行ったり来たりして面倒ですが、addConstaraintsは各リレーションにあります。
今回はHasOneOrManyにあります。

   HasOneOrMany.php
   
   //$query = EloquentBuiler
   //$model = UserModel
   //$this->related = EloquentBuilderのsetModelでセットしたPostModel
   //$foreignKey = "posts"."user_id"
   // $this->getParentKey() = UserModel->id
   public function addConstraints()
    {
        if (static::$constraints) {
            $query = $this->getRelationQuery();

            $query->where($this->foreignKey, '=', $this->getParentKey());

            $query->whereNotNull($this->foreignKey);
        }
    }

QueryBuilderのwhereを見ると下記のようにwhere句が設定されることがわかります。

 QueryBuilder.php
 
 public function where($column, $operator = null, $value = null, $boolean = 'and')
    {
        ...
        $type = 'Basic';

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

        if (! $value instanceof Expression) {
            $this->addBinding($this->flattenValue($value), 'where');
        }

        return $this;
    }

compactがちょっとわかりにくいんですけど、下記みたいな連想配列を生成しています。

[
 "type" => $type,
 "column" => $column,
]

これで、where句にposts.user_id = ? and posts.user_id is not nullの部分もわかりました。

get()

あとはhasMany()->get()のgetの部分を見ましょう。
get()はRelationクラスにあります。

 Relation.php
 
 //$this->query = EloquentBuilder
 public function get($columns = ['*'])
     {
         return $this->query->get($columns);
     }
   EloquentBuilder.php
   
    public function get($columns = ['*'])
    {
        $builder = $this->applyScopes();

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

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

applyScopesはローカルスコープ関連なので飛ばします。
同じ$modelsという変数名を使っていて分かりにくいですが、

$models = $builder->getModels($columns)でまずeagerLoad以外のクエリ、
$models = $builder->eagerLoadRelations($models)でeagerLoadを行います。

例えばUser::with("posts")->get()であれば、
$models = $builder->getModels($columns)はselect * from usersで、$models = []Userとなり、
$models = $builder->eagerLoadRelations($models)はselect * from posts where posts.user_id in [一つ目のクエリで取得したUserのid達]となり、$models = []Postとなります。

今回はeagerLoad関係ないので一つ目のクエリだけですね。

   EloquentBuilder.php
   
   //$this->model = PostModel
   //$this->query = QueryBuilder
   public function getModels($columns = ['*'])
    {
        return $this->model->hydrate(
            $this->query->get($columns)->all()
        )->all();
    }

model->hydrateはmodelにいろいろ設定するところなので飛ばします。

  QueryBuilder.php
  
  public function get($columns = ['*'])
    {
        return collect($this->onceWithColumns(Arr::wrap($columns), function () {
            return $this->processor->processSelect($this, $this->runSelect());
        }));
    }
    
   protected function runSelect()
    {
        return $this->connection->select(
            $this->toSql(), $this->getBindings(), ! $this->useWritePdo
        );
    }
    
    public function toSql()
    {
        $this->applyBeforeQueryCallbacks();

        return $this->grammar->compileSelect($this);
    }

toSqlはEloquentからSQL文を出力するのに使った方も多いのではないでしょうか。
これでEloquentから実際のSQLに至る前段階までがわかりました。
実際のSQLを組み立てるところは下記みたいにGrammer.phpにあります。組み立てるだけと言えばだけですが、解説も難しいのでファイルだけの紹介にさせてください。

Grammer.php

public function compileSelect(Builder $query)
    {
        if (($query->unions || $query->havings) && $query->aggregate) {
            return $this->compileUnionAggregate($query);
        }

        // If the query does not have any columns set, we'll set the columns to the
        // * character to just get all of the columns from the database. Then we
        // can build the query and concatenate all the pieces together as one.
        $original = $query->columns;

        if (is_null($query->columns)) {
            $query->columns = ['*'];
        }

        // To compile the query, we'll spin through each component of the query and
        // see if that component exists. If it does we'll just call the compiler
        // function for the component which is responsible for making the SQL.
        $sql = trim($this->concatenate(
            $this->compileComponents($query))
        );

        if ($query->unions) {
            $sql = $this->wrapUnion($sql).' '.$this->compileUnions($query);
        }

        $query->columns = $original;

        return $sql;
    }

おわりに

今回はhasManyの場合を見ていきました。
次回はbelongsTo...ではなくwith+hasManyを見ていきたいと思います。
今回の最後のところでeagerLoadを使う場合のクエリを発行する箇所がEloquentBuilderにありましたが次回はそのあたりも絡んできます。

マジックメソッドと継承で親子関係行き来させるのエディタのジャンプ効かないのでやめてほしいです;;

おまけ

Connectionの設定絡みの話

今回の解説でPostModelにUserModelのConnectionを設定した場面があったと思いますが、UserModelのConnectionはどこから来たのでしょうか?

User::find(1)でuserを取得した前提だったので、User::findを見てみましょう。

public static function __callStatic($method, $parameters)
    {
        return (new static)->$method(...$parameters);
    }
    
    
 public function __call($method, $parameters)
    {
        if (in_array($method, ['increment', 'decrement'])) {
            return $this->$method(...$parameters);
        }

        if ($resolver = (static::$relationResolvers[get_class($this)][$method] ?? null)) {
            return $resolver($this);
        }

        return $this->forwardCallTo($this->newQuery(), $method, $parameters);
    }
  

modelにfindメソッドはないので__callStatic -> __callでEloquentBuilderに処理が委譲されています。

$this->newQuery()でEloquentBuilderを生成し始めます。

Model.php
public function newModelQuery()
    {
        return $this->newEloquentBuilder(
            $this->newBaseQueryBuilder()
        )->setModel($this);
    }
  
 protected function newBaseQueryBuilder()
    {
        return $this->getConnection()->query();
    }

public function getConnection()
    {
        return static::resolveConnection($this->getConnectionName());
    }
 public function getConnectionName()
    {
        return $this->connection;
    }

ここで$this->connection=nullです。

Model.php

public static function resolveConnection($connection = null)
    {
        return static::$resolver->connection($connection);
    }

ここの$resolverはDataServiceProviderで下記のようにセットされています。


 DatabaseSeriveProvider.php
        
        
  public function boot()
      {
         Model::setConnectionResolver($this->app['db']);
      
          Model::setEventDispatcher($this->app['events']);
      }
     
  protected function registerConnectionServices()
       {
        
             $this->app->singleton('db.factory', function ($app) {
                 return new ConnectionFactory($app);
             });
     
             $this->app->singleton('db', function ($app) {
                 return new DatabaseManager($app, $app['db.factory']);
             });
     
             $this->app->bind('db.connection', function ($app) {
                 return $app['db']->connection();
             });
     
             $this->app->bind('db.schema', function ($app) {
                 return $app['db']->connection()->getSchemaBuilder();
             });
     
             $this->app->singleton('db.transactions', function ($app) {
                 return new DatabaseTransactionsManager;
             });
      }

上記からresolver=DatabaseManagerとわかるので、DatabaseManager->connection(null)を見ればいいです。

  DatabaseManager.php
  //$name = null
  public function connection($name = null)
        {
            [$database, $type] = $this->parseConnectionName($name);
    
            $name = $name ?: $database; //$nameはmysqlとかが入る
    
            if (! isset($this->connections[$name])) {
                $this->connections[$name] = $this->configure(
                    $this->makeConnection($database), $type
                );
            }
    
            return $this->connections[$name];
        }
 
 
  protected function parseConnectionName($name)
         {
                $name = $name ?: $this->getDefaultConnection();
        
                return Str::endsWith($name, ['::read', '::write'])
                                    ? explode('::', $name, 2) : [$name, null];
         }
        
  public function getDefaultConnection()
         {
               return $this->app['config']['database.default'];
          }
 

laravelのサービスコンテナからコンフィグを読んで、database.defaultを取得していることがわかります。値としてはmysqlとかですね。

laravelのサービスコンテナとかコンフィグ読み取りについてはこちら

https://zenn.dev/cube/articles/f88c5b6654e729
https://zenn.dev/cube/articles/d7b978c5eabde5

DatabaseManager.php
 protected function makeConnection($name)
           {
               $config = $this->configuration($name);
               ...
               return $this->factory->make($config, $name);
           }
       protected function configuration($name)
           {
               $name = $name ?: $this->getDefaultConnection();
       
               
               $connections = $this->app['config']['database.connections']; <---情報読み取り
       
               if (is_null($config = Arr::get($connections, $name))) {
                   throw new InvalidArgumentException("Database connection [{$name}] not configured.");
               }
       
               return (new ConfigurationUrlParser)
                           ->parseConfiguration($config);
           }

makeConnection->configurationではコンフィグからDBの接続情報を読み取っています。

resolver->connection()の返り値はmakeConnectionで作られたConnectionクラスです。
makeConnectionの$this->factoryはConnectionFactory.phpなのでそこをみましょう。
なぜかConnectionFactoryなのかは、DatabaseSeriveProviderで設定されていたからですね。

ConnectionFactory.php

protected function createConnection($driver, $connection, $database, $prefix = '', array $config = [])
    {
        if ($resolver = Connection::getResolver($driver)) {
            return $resolver($connection, $database, $prefix, $config);
        }

        return match ($driver) {
            'mysql' => new MySqlConnection($connection, $database, $prefix, $config),
            'pgsql' => new PostgresConnection($connection, $database, $prefix, $config),
            'sqlite' => new SQLiteConnection($connection, $database, $prefix, $config),
            'sqlsrv' => new SqlServerConnection($connection, $database, $prefix, $config),
            default => throw new InvalidArgumentException("Unsupported driver [{$driver}]."),
        };
    }

上記の各ConnectionはConnection.phpを継承しています。

さて、Connectionの正体までわかったので、ModelでQueryBuilderを生成しているところに戻りましょう。

 Model.php
 
 protected function newBaseQueryBuilder()
    {
        return $this->getConnection()->query();
    }

今回はMySQLConnectionとします。
MySQLConnection extends Connectionという関係で、query()はConnection.phpにあります。

Connection.php

   public function query()
    {
        return new QueryBuilder(
            $this, $this->getQueryGrammar(), $this->getPostProcessor()
        );
    }

このようにしてqueryBuilderが生成されます。

findの処理残り部分

EloquentBuilderの生成が終わってfindを呼ぶところから

 EloquentBuilder.php
 
 public function find($id, $columns = ['*'])
    {
        if (is_array($id) || $id instanceof Arrayable) {
            return $this->findMany($id, $columns);
        }

        return $this->whereKey($id)->first($columns);
    }
 public function first($columns = ['*'])
    {
        return $this->take(1)->get($columns)->first();
    }

whereKeyでwhere句を設定して、次はfirstなんですが、takeはEloquentBuilderに存在しません。

 EloquentBuilder.php
 public function __call($method, $parameters)
    {
        ...
        $this->forwardCallTo($this->query, $method, $parameters);

        return $this;
    }

takeはQueryBuilderに委譲されていることがわかります。
なので、take->get->firstはQueryBuilderを見ていきましょう。

 public function take($value)
    {
        return $this->limit($value);
    }
    
  public function get($columns = ['*'])
    {
        return collect($this->onceWithColumns(Arr::wrap($columns), function () {
            return $this->processor->processSelect($this, $this->runSelect());
        }));
    }

limit句を設定した後、SQLを実行となっています。

Discussion