79
63

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Laravel の取得系メソッド cursor() や chunk() を徹底比較する

Last updated at Posted at 2022-07-26

Laravel で DB から複数レコードを取得する

バッチ処理などで大量のレコードを扱う場面があります。そんな時どんなメソッドを使ってデータを取得すれば良いでしょうか。

今回は Eloquent で複数レコードを取得する方法とその違いについて詳しく解説します。

get() と cursor() と chunk() って何が違うの?どう使い分ければいいの?
そんな人にぜひ読んでもらいたい記事です。

まとめ

分割取得 カーソルページネーション 遅延ハイドレーション 値の受け取り方
get() ✖️ ✖️ 返り値
cursor() ✖️ ⭕️ 返り値
chunk() ⭕️ ✖️ ✖️ コールバック
chunkById() ⭕️ ⭕️ ✖️ コールバック
each() ⭕️ ✖️ ✖️ コールバック
eachById() ⭕️ ⭕️ ✖️ コールバック
lazy() ⭕️ ✖️ ✖️ 返り値
lazyById() ⭕️ ⭕️ ✖️ 返り値
  • getcursor は常に全件取得する
  • cursor だけが遅延してハイドレートされる
  • cursor は Eager Loading が使えない
  • chunk(ById)each(ById) は同じことをしている
  • each(ById) は,chunk(ById) の foreach を書く手間が省けて,塊を意識せずにフラットなストリームとして扱える
  • lazy(ById) は内部で yield しているので,each(ById) と同様に foreach 不要でフラットなストリームとして扱える
  • each(ById)lazy(ById) は,chunk(ById) をスマートに書けるようにしたもの

どれを使えば良い?

最終的に取得したいレコードの数がそれほど多くなく,メモリ不足になる心配がない
get()

最終的に取得したいレコードの数はそれほど多くないが,多少遅くてもできる限りメモリ消費を抑えたい
cursor()

最終的に取得したいレコードの数が多く,メモリ不足になる可能性がある
→ 基本は eachById() で,使えない場合は each()
 または好みで lazyById() ,使えない場合は lazy()

補足

  • ページネーションする際は,基本的にカーソルページネーションを使うべきです。

  • cursor() のメモリ消費の話は,バッファクエリを前提としています。
    非バッファクエリにして保存先をDB側のメモリにすれば,PHPのメモリの問題は起きませんが,今度はDBサーバ側に負荷がかかってしまいます。また同じ接続上で別のクエリを実行できないというデメリットもあります。バッファクエリでもページネーションを活用すればメモリの問題は解消されるので,デフォルトのバッファクエリ設定で問題ないかと思います。

用語の整理

ここでは以下のような意味で用います。

ページネーション
分割して結果を取得すること。UI的なページ分けという意味ではありません。

カーソルページネーション
LIMIT + OFFSET ではなく,WHERE で範囲を絞り込み取得する方法。

ハイドレート(hydrate)
stdClass オブジェクトを Eloquent Model オブジェクトに変換すること。

モデル取得までの流れ
SELECT 実行
  ↓
PHP プロセスのメモリ(バッファ)にレコードデータを保存
  ↓
PDOStatement::fetch  バッファから fetch する
   ↓
stdClass オブジェクト
  ↓
ハイドレート
  ↓
Eloquent オブジェクト

パフォーマンス

検証環境 Laravel 8.8、PHP 8.0、MySQL 5.7

each(ById)chunk(byId) と同じなので省略します。
※ 全件ループした時の値です。

10,000レコード

Memory (MB) Time (sec)
get() 44 0.17
cursor() 35 0.28
chunk() by 1000 31 0.22
chunkById() by 1000 32 0.16
lazy() by 1000 33 0.22
lazyById() by 1000 33 0.19

100,000レコード

Memory (MB) Time (sec)
get() 198 1.33
cursor() 72 1.93
chunk() by 1000 32 5.38
chunkById() by 1000 32 1.38
lazy() by 1000 34 5.76
lazyById() by 1000 34 1.28

1,000,000レコード

Memory (MB) Time (sec)
chunkById() by 1000 37 18.0
lazyById() by 1000 38 15.8

get()cursor() は HTTP ERROR 500 となり,chunk()lazy() は Time-out となりました。

考察

  • get() が最速
  • cursor()get() と比べるとやや遅いが,メモリ使用量はある程度抑えられる
  • ページネーションするとメモリ使用量は一定に抑えられるが,実行速度は遅くなる
  • カーソルページネーションは通常のページネーションに比べて速い
  • lazy(ById)chunk(ById) の速度はほぼ同じ

以下のようにして測定しました。
Memory は memory_get_peak_usage() の値を参照しました。

計測コード
\DB::enableQueryLog();
$start = hrtime(true);
// 処理
$end = hrtime(true);
logger('現在のメモリ使用量', ['memory_get_usage' => memory_get_usage() / (1024 * 1024) . 'MB']);
logger('最大メモリ使用量', ['memory_get_peak_usage' => memory_get_peak_usage() / (1024 * 1024) . 'MB']);
logger('計測', ['time' => ($end - $start) / 1e9 . '秒']);
logger('クエリ', ['SQL' => \DB::getQueryLog()]);

ページネーションなし

分割せずにレコード全件を一度に取得します。大量のレコードを取得する場合はメモリ不足になる可能性があります。

get()

ハイドレート後の全 Eloquent オブジェクトをメモリに読み込む。

  • Illuminate\Database\Eloquent\Collection を返す
  • 実行速度は高速
  • メモリを多く消費する
  • 内部で fetchAll() している
$posts = Post::query()->get();
foreach ($posts as $post){
    // 処理
}
SELECT * FROM posts
[補足] Post::all() との違いは?

Post::all() との違い

Post::all()Post::query()->get() は全く同じです。

all() は Eloquent Model の静的メソッドです。
get() は Eloquent Builder のメソッドです。

all() は内部で static::query()->get() を呼んでいます。
ここでは Post::query()->get() が実行されます。新しい Eloquent Builder を作成して get() を呼んでいます。

また Post::get() としても動作しますが,Eloquent Model の静的メソッド Model::get() は未定義です。
__callStatic が呼ばれ,インスタンスメソッドとして get() を呼びますが,これもまた未定義です。
今度は __call() を通じて,最終的に Eloquent Builder の get() が呼ばれます。

cursor()

ハイドレート前の全レコードデータをメモリに読み込み,各要素にアクセスした時に初めてハイドレートされる。

※ PDO の設定がデフォルトであると想定します。バッファクエリ

イテレータにアクセスした際に,その要素が初めて Eloquent オブジェクトにインスタンス化されます。つまり遅延してハイドレートされます。ループの度に fetch してハイドレートするので get() に比べて速度は遅くなります。

全レコードデータをメモリに読み込むのは get() と同じですので,件数が多いとメモリ消費は多くなります。

Eloquent オブジェクト化(ハイドレート)前である分,get() に比べるとメモリ消費は少ないですが,全件取得であることには変わりないです。

事前に Eloquent オブジェクト化しないので,Eager Loading は使えません。
N+1 を発生させずに紐づくレコードも取得したい場合は,JOIN する必要があるでしょう。

カーソルという名前ですが,カーソルページネーションをしているわけではありません。

  • Illuminate\Support\LazyCollection を返す
  • Eager Load できない
  • 実行速度はやや遅い
  • バッファクエリだと結局大量のレコードを扱えない
  • 内部で fetch() + yield ループを回している
$posts = Post::query()->cursor();
foreach ($posts as $post){
    // 処理
}
SELECT * FROM posts

get() と cursor() の処理イメージ

処理のイメージを掴むために簡易的なコードを記載します。実際のソースコードではありません。

get()

get() のイメージ
return hydrateAll($statement->fetchAll());
  1. $users = User::query()->get();
  2. SELECT * FROM users; 実行
  3. バッファ(PHP側のメモリ)に全ユーザーのレコードデータを保存
  4. fetchAll() してバッファから全件データを stdClass の配列として取得
  5. 取得した全件の stdClass オブジェクトをハイドレートする

cursor()

cursor() のイメージ
while ($record = $statement->fetch()){
    yield hydrate($record);
}
  1. $users = User::query()->cursor();
  2. foreach でイテレータに初回アクセス
  3. SELECT * FROM users; 実行
  4. バッファ(PHP側のメモリ)に全ユーザーのレコードデータを保存
  5. fetch() してバッファから1件のデータを stdClass として取得
  6. 取得した1件の stdClass オブジェクトをハイドレートする

※ 以降のループでは 5. 6. の繰り返し

ページネーションあり

分割して取得するため,何度も SELECT を発行します。メモリ消費は一定にコントロール可能です。
取得部分に関しては全て get() と同じなので,いずれも遅延ハイドレートはしません。

chunk()

小分けにして get() します。
LIMIT (1回あたりの取得件数) と OFFSET (何番目のレコードから取得スタートするか) を使います。既に取得した件数だけ OFFSET を進めることでページネーションを実現します。

例えば LIMIT 100 OFFSET 1000 とした時,MySQL は 1100件 スキャンした後に,先頭の 1000件 を破棄しています。よって,OFFSET が奥に進むにつれて遅くなります。

  • メモリ消費量を一定にできる
  • 実行速度は遅い
  • 取得しながら同時にレコードを更新する場合は,ページ番号がズレる可能性がある
  • オフセットが奥に行くほど遅くなる性質がある
  • 小さな単位の fetchAll() を何回も呼び出す
Post::query()->chunk(1000, function (Collection $posts, int $page) {
    foreach ($posts as $post) {
        // 処理
    }
});
SELECT * FROM posts ORDER BY posts.id ASC LIMIT 1000 OFFSET 0
SELECT * FROM posts ORDER BY posts.id ASC LIMIT 1000 OFFSET 1000
SELECT * FROM posts ORDER BY posts.id ASC LIMIT 1000 OFFSET 2000
・・・
SELECT * FROM posts ORDER BY posts.id ASC LIMIT 1000 OFFSET 10000

chunkById()

基本は chunk() と同じですが,こちらはカーソルページネーションになります。

基準となるユニークカラム(デフォルトでは id)で結果をソートすることが前提です。
where id > 1000 のようなクエリを付与することで,取得途中でレコードの削除や更新が発生しても,ズレることなく次の結果を取得できます。

  • 取得しながらレコードを更新してもページがズレない
  • chunk() よりも速い
  • ユニークキーで並び替えを行うので,自分で orderBy() を呼び出してはいけない
  • where の条件に指定するカラムは,ユニークかつインデックスを貼る必要がある
Post::query()->chunkById(1000, function (Collection $posts) {
    foreach ($posts as $post) {
        // 処理
    }
}, $column = 'id');
SELECT * FROM posts ORDER BY id ASC LIMIT 1000
SELECT * FROM posts WHERE id > 1000 ORDER BY id ASC LIMIT 1000
SELECT * FROM posts WHERE id > 2000 ORDER BY id ASC LIMIT 1000
・・・
SELECT * FROM posts WHERE id > 10000 ORDER BY id ASC LIMIT 1000

each()

chunk()foreach を省略したバージョンです。

  • chunk() して foreach と等価
Post::query()->each(function (Post $post) {
    // 処理
}, 1000);

eachById()

chunkById()foreach を省略したバージョンです。

  • chunkById() して foreach と等価
Post::query()->eachById(function (Post $post) {
    // 処理
}, 1000, $column = 'id');

lazy()

Laravel 8.34.0 からの機能です。

chunk() と同様に結果を分割取得しますが,こちらは LazyCollection を返します。
each() と同じく,塊を意識することなく扱えます。

LazyCollection を返すので cursor() と混同してしまうかもしれませんが,両者は全く異なります。
cursor() はそもそもページネーションしませんし,遅延してハイドレートされます。

lazy()chunk() をジェネレータ化しただけであり,塊ごとに取得した段階で全件ハイドレートされます。内部で get() が呼ばれているので当然そうなります。

chunk() と違い,イテレータに初回アクセスするまでクエリは発行されません。

  • Illuminate\Support\LazyCollection を返す
  • chunk()each() とほぼ同じだが、内部で yield してる点が異なる
  • chunk() とほぼ同じ速度
$posts = Post::query()->lazy(1000);
foreach ($posts as $post){
    // 処理
}

発行されるクエリは chunk() と同じです。

lazyById()

lazy() のカーソルページネーション版です。
lazy() と同様に Laravel 8.34.0 からの機能です。

$posts = Post::query()->lazyById(1000, $column = 'id');
foreach($posts as $post){
    // 処理
}

発行されるクエリは chunkById() と同じです。

ソースコードを読む

get()

Illuminate/Database/Eloquent/Builder::get()
Illuminate/Database/Eloquent/Builder.php
/**
 * Execute the query as a "select" statement.
 *
 * @param  array|string  $columns
 * @return \Illuminate\Database\Eloquent\Collection|static[]
 */
public function get($columns = ['*'])
{
    $builder = $this->applyScopes();

    // If we actually found models we will also eager load any relationships that
    // have been specified as needing to be eager loaded, which will solve the
    // n+1 query issue for the developers to avoid running a lot of queries.
    if (count($models = $builder->getModels($columns)) > 0) {
        $models = $builder->eagerLoadRelations($models);
    }

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

$models = $builder->getModels($columns) の部分に注目します。

Illuminate/Database/Eloquent/Builder::getModels()
Illuminate/Database/Eloquent/Builder.php
/**
 * Get the hydrated models without eager loading.
 *
 * @param  array|string  $columns
 * @return \Illuminate\Database\Eloquent\Model[]|static[]
 */
public function getModels($columns = ['*'])
{
    // 配列要素を全て Eloquent オブジェクトに変換した上で,コレクションを配列にして返す
    return $this->model->hydrate(
        // Query Builder の get メソッドで stdClass の結果コレクションを取得し,
        // コレクションの all メソッドで配列に変換する
        $this->query->get($columns)->all()
    )->all();
}

stdClass の配列Eloquent オブジェクトの配列 にするのが hydrate() です。

Illuminate/Database/Eloquent/Builder::hydrate()
Illuminate/Database/Eloquent/Builder.php
/**
 * Create a collection of models from plain arrays.
 *
 * @param  array  $items
 * @return \Illuminate\Database\Eloquent\Collection
 */
public function hydrate(array $items)
{
    // 操作用のモデルインスタンス作成
    $instance = $this->newModelInstance();

    // 1 つの $item が stdClass オブジェクトであり,1 レコードに対応している
    return $instance->newCollection(array_map(function ($item) use ($instance) {
        // stdClass を Eloquent オブジェクトに変換
        return $instance->newFromBuilder($item);
    }, $items));
}

ソースコードでは,全要素をモデルインスタンス化する処理が hydrate() に当たります。
当記事では,1つの stdClass オブジェクトをモデルインスタンスにすること,つまりソースコード上の newFromBuilder() にあたる部分を「ハイドレート」と呼んでいます。

$instance->newFromBuilder($item) の部分に注目してください。

Illuminate/Database/Eloquent/Model::newFromBuilder()
Illuminate/Database/Eloquent/Model.php
/**
 * Create a new model instance that is existing.
 *
 * @param  array  $attributes
 * @param  string|null  $connection
 * @return static
 */
public function newFromBuilder($attributes = [], $connection = null)
{
    $model = $this->newInstance([], true);

    // stdClass を連想配列にキャストしてから,モデルの attributes にセットする
    $model->setRawAttributes((array) $attributes, true);

    $model->setConnection($connection ?: $this->getConnectionName());

    $model->fireModelEvent('retrieved', false);

    return $model;
}

新しい Eloquent モデルのオブジェクトを作成しています。

Illuminate/Database/Eloquent/Model::newInstance()
Illuminate/Database/Eloquent/Model.php
/**
 * Create a new instance of the given model.
 *
 * @param  array  $attributes
 * @param  bool  $exists
 * @return static
 */
public function newInstance($attributes = [], $exists = false)
{
    // This method just provides a convenient way for us to generate fresh model
    // instances of this current model. It is particularly useful during the
    // hydration of new objects via the Eloquent query builder instances.
    $model = new static((array) $attributes);

    $model->exists = $exists;

    $model->setConnection(
        $this->getConnectionName()
    );

    $model->setTable($this->getTable());

    $model->mergeCasts($this->casts);

    return $model;
}

hydrate() の引数となる $this->query->get($columns) を追っていくと以下のコードにたどり着きます。

Illuminate/Database/Connection::select()
Illuminate/Database/Connection.php
/**
 * Run a select statement against the database.
 *
 * @param  string  $query
 * @param  array  $bindings
 * @param  bool  $useReadPdo
 * @return array
 */
public function select($query, $bindings = [], $useReadPdo = true)
{
    return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) {
        if ($this->pretending()) {
            return [];
        }

        // For select statements, we'll simply execute the query and return an array
        // of the database result set. Each element in the array will be a single
        // row from the database table, and will either be an array or objects.
        $statement = $this->prepared(
            $this->getPdoForSelect($useReadPdo)->prepare($query)
        );

        $this->bindValues($statement, $this->prepareBindings($bindings));

        $statement->execute();

        return $statement->fetchAll();
    });
}

fetchAll() を実行していることが確認できます。

cursor()

Illuminate/Database/Eloquent/Builder::cursor()
Illuminate/Database/Eloquent/Builder.php
/**
 * Get a lazy collection for the given query.
 *
 * @return \Illuminate\Support\LazyCollection
 */
public function cursor()
{
    // Query Builder の cursor() を呼んでいる。これは LazyCollection を返す。
    // LazyCollection の map() を呼び,一つ一つの要素にハイドレート処理を登録している。
    // 実際にハイドレートされるのは,要素にアクセスしたタイミング。
    return $this->applyScopes()->query->cursor()->map(function ($record) {
        return $this->newModelInstance()->newFromBuilder($record);
    });
}

Query Builder の cursor() を呼んでいます。

Illuminate/Database/Query/Builder::cursor()
Illuminate/Database/Query/Builder.php
/**
 * Get a lazy collection for the given query.
 *
 * @return \Illuminate\Support\LazyCollection
 */
public function cursor()
{
    if (is_null($this->columns)) {
        $this->columns = ['*'];
    }

    return new LazyCollection(function () {
        // $this->connection->cursor() はジェネレータを返す
        yield from $this->connection->cursor(
            $this->toSql(), $this->getBindings(), ! $this->useWritePdo
        );
    });
}

yield from ~ は,~ の部分も yield を返します。~ はジェネレータ関数です。
yield がネストしてるようなイメージですが,フラットになります。

$connectioncursor() がジェネレータを返します。

Illuminate/Database/Connection::cursor()
Illuminate/Database/Connection.php
/**
 * Run a select statement against the database and returns a generator.
 *
 * @param  string  $query
 * @param  array  $bindings
 * @param  bool  $useReadPdo
 * @return \Generator
 */
public function cursor($query, $bindings = [], $useReadPdo = true)
{
    $statement = $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) {
        if ($this->pretending()) {
            return [];
        }

        // First we will create a statement for the query. Then, we will set the fetch
        // mode and prepare the bindings for the query. Once that's done we will be
        // ready to execute the query against the database and return the cursor.
        $statement = $this->prepared($this->getPdoForSelect($useReadPdo)
                          ->prepare($query));

        $this->bindValues(
            $statement, $this->prepareBindings($bindings)
        );

        // Next, we'll execute the query against the database and return the statement
        // so we can return the cursor. The cursor will use a PHP generator to give
        // back one row at a time without using a bunch of memory to render them.
        $statement->execute();

        return $statement;
    });

    while ($record = $statement->fetch()) {
        yield $record;
    }
}

この部分に注目です。

while ($record = $statement->fetch()) {
    yield $record;
}

fetch() をループで回し,それを yield してます。

Connection の cursor() は,fetch() したレコードを yield する
Query Builder の cursor() は,それを LazyCollection でラップする
Eloquent Builder の cursor() は,それにハイドレート処理を登録する

ということをしています。ここがわかれば,Model::cursor() が遅延ハイドレーションの全件取得である意味がわかると思います。

Query/Builder::cursor() が返す LazyCollection に対し,メソッドチェーンで map() を呼び,ハイドレート処理を登録している部分があります。

Illuminate/Collections/LazyCollection::map()
Illuminate/Collections/LazyCollection.php
/**
 * Run a map over each of the items.
 *
 * @param  callable  $callback
 * @return static
 */
public function map(callable $callback)
{
    return new static(function () use ($callback) {
        // $this は Query Builder::cursor() が返す LazyCollection インスタンスを指す
        foreach ($this as $key => $value) {
            yield $key => $callback($value, $key);
        }
    });
}

map() は,現在の LazyCollection の各要素をコールバック関数に通した結果を yield する新たな LazyCollection インスタンスを作成して返します。

ここのコールバック処理がハイドレート処理になります。

yield が何度もでてきて?という方へ

yield の補足

ジェネレータ を使っています。

yield は関数の中でのみ書くことができます。yield する関数をジェネレータ関数と呼びます。
yield は「必要になった時(ループで実際にアクセスした時)にこれを返してね」と事前に定義しておくようなイメージです。

ジェネレータ関数の中で,別のジェネレータ関数を呼びたい場面があります。
そんなときは yield from を使います。

また,ジェネレータ関数Aが yield する値に対し,何らかの処理を加えたいこともあります。
そんなときは,別のジェネレータ関数Bでラップする形でジェネレータ関数Aをループで回し,加工した値を yield すれば良いです。

cursor() も内部では,fetch して yield するジェネレータ関数Aがあり,それを yield from した LazyCollection でラップします。最後に map() を使い内部でループしてハイドレート処理の結果を yield しています。

このように yield を重ねて使用しています。

この場合,「ループしてるからジェネレータ関数Aの要素にアクセスされたことになり,事前に全件 fetch されてしまうのでは?」と思うかもしれません。

しかし,ジェネレータ関数B(上の例だと map())も yield しているだけなので,返す値を事前に定義しているだけなのです。
なのでジェネレータ関数Aを map でループしていますが,処理(上の例だと fetch)が実際に走るわけではありません。

ジェネレータ関数をラップする形でまた yield する


yield from ジェネレータ関数()


foreach (ジェネレータ関数() as $value) {
    yield $value;
}

   は同じです
後者は何らかの加工処理を加えて yield したい場合に便利です

foreach (ジェネレータ関数() as $value) {
    yield something($value);
}

chunk()

トレイトに記述されています。

Illuminate/Database/Concerns/BuildsQueries::chunk()
Illuminate/Database/Concerns/BuildsQueries.php
/**
 * Chunk the results of the query.
 *
 * @param  int  $count
 * @param  callable  $callback
 * @return bool
 */
public function chunk($count, callable $callback)
{
    $this->enforceOrderBy();

    $page = 1;

    do {
        // We'll execute the query for the given page and get the results. If there are
        // no results we can just break and return from here. When there are results
        // we will call the callback with the current chunk of these results here.
        $results = $this->forPage($page, $count)->get();

        $countResults = $results->count();

        if ($countResults == 0) {
            break;
        }

        // On each chunk result set, we will pass them to the callback and then let the
        // developer take care of everything within the callback, which allows us to
        // keep the memory low for spinning through large result sets for working.
        if ($callback($results, $page) === false) {
            return false;
        }

        unset($results);

        $page++;
    } while ($countResults == $count);

    return true;
}

この部分に注目します。

do {
    // コールバック関数の実行
    // 大抵の場合、foreach で回す
    if ($callback($results, $page) === false) {
        return false;
    }
    
    $page++;
} while ($countResults == $count);

取得結果の件数と chunk で指定した塊の数が等しい間,do while ループで SELECT し続けます。
1000件ずつで指定した時に,取得件数が1000件未満になれば最後のループであることがわかります。

Post::query()->chunk(1000, function (Collection $posts, int $page) {
    foreach ($posts as $post) {
        // 処理
    }
});

こんな感じで,クロージャの第二引数に現在のページ数を受け取ることも可能です。

forPage() はこのようになっています。

Illuminate/Database/Query/Builder::forPage()
Illuminate/Database/Query/Builder.php
/**
 * Set the limit and offset for a given page.
 *
 * @param  int  $page
 * @param  int  $perPage
 * @return $this
 */
public function forPage($page, $perPage = 15)
{
    return $this->offset(($page - 1) * $perPage)->limit($perPage);
}

OFFSET と LIMIT を設定しています。カーソルベースではない普通のページネーションの仕組みとなっているのがわかります。

chunkById()

Illuminate/Database/Concerns/BuildsQueries::chunkById()
Illuminate/Database/Concerns/BuildsQueries.php
/**
 * Chunk the results of a query by comparing IDs.
 *
 * @param  int  $count
 * @param  callable  $callback
 * @param  string|null  $column
 * @param  string|null  $alias
 * @return bool
 */
public function chunkById($count, callable $callback, $column = null, $alias = null)
{
    // 第3引数が未指定の場合はデフォルトのキー名になる
    $column = $column ?? $this->defaultKeyName();

    $alias = $alias ?? $column;

    $lastId = null;

    $page = 1;

    do {
        // clone してるのは,前回のループの $lastId の WHERE 条件が追加されないようにするため
        // 同じインスタンスに対して WHERE を付与すると AND で条件結合されてしまう
        $clone = clone $this;

        // We'll execute the query for the given page and get the results. If there are
        // no results we can just break and return from here. When there are results
        // we will call the callback with the current chunk of these results here.
        $results = $clone->forPageAfterId($count, $lastId, $column)->get();

        $countResults = $results->count();

        if ($countResults == 0) {
            break;
        }

        // On each chunk result set, we will pass them to the callback and then let the
        // developer take care of everything within the callback, which allows us to
        // keep the memory low for spinning through large result sets for working.
        if ($callback($results, $page) === false) {
            return false;
        }

        // 取得した最後のレコードの ID
        // 次の SELECT 文の WHERE 条件に使う
        $lastId = $results->last()->{$alias};

        if ($lastId === null) {
            throw new RuntimeException("The chunkById operation was aborted because the [{$alias}] column is not present in the query result.");
        }

        unset($results);

        $page++;
    } while ($countResults == $count);

    return true;
}

ロジックは chunk と同じで,取得結果の数が塊の数と等しい間 do while ループしています。

Illuminate/Database/Query/Builder::forPageAfterId()
Illuminate/Database/Query/Builder.php
/**
 * Constrain the query to the next "page" of results after a given ID.
 *
 * @param  int  $perPage
 * @param  int|null  $lastId
 * @param  string  $column
 * @return $this
 */
public function forPageAfterId($perPage = 15, $lastId = 0, $column = 'id')
{
    // id を並び替え基準にするために,既にある基準カラムを削除する
    $this->orders = $this->removeExistingOrdersFor($column);

    // カーソルページネーション用のクエリ条件を追加
    if (! is_null($lastId)) {
        $this->where($column, '>', $lastId);
    }

    // ユニークカラム(通常は ID)を基準とするので並び替えが必要
    return $this->orderBy($column, 'asc')
                ->limit($perPage);
}

Query Builder は $orders というプロパティを持ちます。
これはクエリの ORDER BY に関する設定値です。

こんな感じの値になってます。
$orders = [
    [
        'column' => 'id',
        'direction' => 'asc',
    ],
    [
        'column' => 'created_at',
        'direction' => 'desc',
    ],
];

each()

Illuminate/Database/Concerns/BuildsQueries::each()
Illuminate/Database/Concerns/BuildsQueries.php
/**
 * Execute a callback over each item while chunking.
 *
 * @param  callable  $callback
 * @param  int  $count
 * @return bool
 *
 * @throws \RuntimeException
 */
public function each(callable $callback, $count = 1000)
{
    return $this->chunk($count, function ($results) use ($callback) {
        // $results に塊の結果が入ってる
        foreach ($results as $key => $value) {
            if ($callback($value, $key) === false) {
                return false;
            }
        }
    });
}

コールバックの第二引数でループのインデックス番号も受け取れます。
塊の中でのインデックス番号です。1000件ずつの場合は 0 ~ 999 が繰り返されます。
chunk() だとここに現在のページ数が入ってきました。

Post::query()->each(function (Post $post, int $key) {
    // 処理
}, 1000);

eachById()

Illuminate/Database/Concerns/BuildsQueries::eachById
Illuminate/Database/Concerns/BuildsQueries.php
/**
 * Execute a callback over each item while chunking by ID.
 *
 * @param  callable  $callback
 * @param  int  $count
 * @param  string|null  $column
 * @param  string|null  $alias
 * @return bool
 */
public function eachById(callable $callback, $count = 1000, $column = null, $alias = null)
{
    return $this->chunkById($count, function ($results, $page) use ($callback, $count) {
        foreach ($results as $key => $value) {
            if ($callback($value, (($page - 1) * $count) + $key) === false) {
                return false;
            }
        }
    }, $column, $alias);
}

コールバックの第二引数でループのインデックス番号も受け取れます。
「塊の中」ではなく全体の中のインデックス番号です。全部で10,000件の場合は 0 ~ 9999 になります。
この点 each() と異なります。(インデックス番号を使う場面はあまりないと思いますが...)

Post::query()->eachById(function (Post $post, int $key) {
    // 処理
}, 1000, $column = 'id');

lazy()

Illuminate/Database/Concerns/BuildsQueries::lazy()
Illuminate/Database/Concerns/BuildsQueries.php
/**
 * Query lazily, by chunks of the given size.
 *
 * @param  int  $chunkSize
 * @return \Illuminate\Support\LazyCollection
 *
 * @throws \InvalidArgumentException
 */
public function lazy($chunkSize = 1000)
{
    if ($chunkSize < 1) {
        throw new InvalidArgumentException('The chunk size should be at least 1');
    }

    $this->enforceOrderBy();

    return LazyCollection::make(function () use ($chunkSize) {
        $page = 1;

        while (true) {
            $results = $this->forPage($page++, $chunkSize)->get();

            foreach ($results as $result) {
                yield $result;
            }

            if ($results->count() < $chunkSize) {
                return;
            }
        }
    });
}

while ループで SELECT の実行を繰り返しながら,その中で foreach して 1モデルインスタンスずつ yield してます。それを LazyCollection でラップして返しています。

LazyCollection はイテレータです。
LazyCollection::make() の引数でジェネレータ関数をクロージャで与えます。

lazyById()

Illuminate/Database/Concerns/BuildsQueries::lazyById
Illuminate/Database/Concerns/BuildsQueries.php
/**
 * Query lazily, by chunking the results of a query by comparing IDs.
 *
 * @param  int  $chunkSize
 * @param  string|null  $column
 * @param  string|null  $alias
 * @return \Illuminate\Support\LazyCollection
 *
 * @throws \InvalidArgumentException
 */
public function lazyById($chunkSize = 1000, $column = null, $alias = null)
{
    return $this->orderedLazyById($chunkSize, $column, $alias);
}
Illuminate/Database/Concerns/BuildsQueries::orderedLazyById()
Illuminate/Database/Concerns/BuildsQueries.php
/**
 * Query lazily, by chunking the results of a query by comparing IDs in a given order.
 *
 * @param  int  $chunkSize
 * @param  string|null  $column
 * @param  string|null  $alias
 * @param  bool  $descending
 * @return \Illuminate\Support\LazyCollection
 *
 * @throws \InvalidArgumentException
 */
protected function orderedLazyById($chunkSize = 1000, $column = null, $alias = null, $descending = false)
{
    if ($chunkSize < 1) {
        throw new InvalidArgumentException('The chunk size should be at least 1');
    }

    $column = $column ?? $this->defaultKeyName();

    $alias = $alias ?? $column;

    return LazyCollection::make(function () use ($chunkSize, $column, $alias, $descending) {
        $lastId = null;

        while (true) {
            $clone = clone $this;

            if ($descending) {
                $results = $clone->forPageBeforeId($chunkSize, $lastId, $column)->get();
            } else {
                // $descending は指定できないので常にこちらが実行される
                $results = $clone->forPageAfterId($chunkSize, $lastId, $column)->get();
            }

            foreach ($results as $result) {
                yield $result;
            }

            if ($results->count() < $chunkSize) {
                return;
            }

            $lastId = $results->last()->{$alias};
        }
    });
}

chunkById() と同じように $clone->forPageAfterId()->get() しています。
それを lazy() と同様に foreach して yield してます。

レコードは stdClass オブジェクトとして取得される

Laravel のデフォルトでは,レコードを stdClass オブジェクトとして取得します。
フェッチモードのデフォルトが PDO::FETCH_OBJ に設定されているためです。

Illuminate/Database/Connection::prepared()
Illuminate/Database/Connection.php
/**
 * The default fetch mode of the connection.
 *
 * @var int
 */
protected $fetchMode = PDO::FETCH_OBJ;

/**
 * Configure the PDO prepared statement.
 *
 * @param  \PDOStatement  $statement
 * @return \PDOStatement
 */
protected function prepared(PDOStatement $statement)
{
    $statement->setFetchMode($this->fetchMode);

    $this->event(new StatementPrepared(
        $this, $statement
    ));

    return $statement;
}

もし PDO の Fetch Mode を変更したければ,サービスプロバイダで StatementPrepared イベントをリッスンすることで実現できます。(そんな場面ない?)

use Illuminate\Database\Events\StatementPrepared;
use Illuminate\Contracts\Events\Dispatcher;
use PDO;

public function boot(Dispatcher $dispatcher)
{
    $dispatcher->listen(StatementPrepared::class, function (StatementPrepared $event) {
        $event->statement->setFetchMode(PDO::FETCH_ASSOC);
    });
}

ファサードを使用する場合

use Illuminate\Database\Events\StatementPrepared;
use Illuminate\Support\Facades\Event;
use PDO;

public function boot()
{
    Event::listen(StatementPrepared::class, function (StatementPrepared $event) {
        $event->statement->setFetchMode(PDO::FETCH_ASSOC);
    });
}
79
63
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
79
63

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?