Laravel 13.30: новый метод chunkBy()

В Laravel 13.30 появился метод chunkBy(). Это удобное сокращение для самого распространённого сценария использования chunkWhile():

$products->chunkWhile(fn ($value, $key, $chunk) => $value->parent == $chunk->last()->parent);

Теперь ту же операцию можно записать значительно короче:

$products->chunkBy('parent');

До этого: chunkWhile() и сравнение

До Laravel 13.30 для такой задачи можно было использовать chunkWhile(). Метод принимает callback, которому передаются текущее значение, его ключ и формируемый в данный момент фрагмент. Новый фрагмент начинается, когда callback возвращает false:

$lineItems->chunkWhile(
    fn ($value, $key, $chunk) => $value->order_id == $chunk->last()->order_id
);

Самое интересное здесь это order_id, спрятанное внутри сравнения текущего значения с $chunk->last(). chunkBy() берёт ключ или callback и самостоятельно выполняет это сравнение:

$lineItems->chunkBy('order_id');

Либо можно передать callback:

$lineItems->chunkBy(fn ($item) => $item->order_id);

Ключ извлекается через data_get(), поэтому можно использовать точечную нотацию для доступа к вложенным массивам и объектам:

$users->chunkBy('address.city');

Соседние элементы, а не все элементы с одинаковым значением

Важно не путать chunkBy() с groupBy(). Оба метода возвращают похожую структуру, но принципиально отличаются тем, как учитывают порядок элементов.

Например:

collect([1, 1, 2, 2, 1, 1])->chunkBy(fn ($v) => $v);

// [[1, 1], [2, 2], [1, 1]]

В этом случае chunkBy() создаёт три группы. Две последовательности с 1 находятся в разных местах коллекции, поэтому они остаются отдельными фрагментами. groupBy() работает иначе:

collect([1, 1, 2, 2, 1, 1])->groupBy(fn ($v) => $v);

// [1 => [1, 1, 1, 1], 2 => [2, 2]]

Здесь все элементы с одинаковым значением объединяются независимо от их положения в исходной коллекции.

Это не ограничение chunkBy(), которое нужно обходить. Именно такая работа с соседними элементами позволяет методу быть экономным. Если одинаковые значения, расположенные в разных частях коллекции, должны оказаться в одном фрагменте, исходные данные не соответствуют принципу работы chunkBy(). В таком случае их нужно сначала отсортировать либо использовать groupBy().

Ключи элементов внутри каждого фрагмента сохраняются:

collect(['a' => 1, 'b' => 1, 'c' => 2])->chunkBy(fn ($v) => $v);

// [['a' => 1, 'b' => 1], ['c' => 2]]

Если внутри фрагмента нужен обычный список с последовательными индексами, можно вызвать values().

Потоковая обработка отсортированного запроса

chunkBy() доступен как для обычных коллекций, так и для LazyCollection. Поскольку метод наследует ленивое поведение chunkWhile(), при работе с ленивой коллекцией каждый новый фрагмент отдаётся сразу после изменения значения. В памяти при этом находится только текущий фрагмент.

Представим экспорт нескольких миллионов записей line_items в отдельные CSV-файлы для каждого заказа.

При использовании groupBy() все строки пришлось бы держать в памяти ещё до создания первого файла. В связке с курсором и chunkBy() максимальное потребление памяти будет зависеть только от самого большого отдельного заказа:

use App\Models\LineItem;
use Illuminate\Support\Facades\Storage;

LineItem::query()
    ->orderBy('order_id')
    ->orderBy('id')
    ->cursor()
    ->chunkBy('order_id')
    ->each(function ($items) {
        $orderId = $items->first()->order_id;

        Storage::disk('exports')->put(
            "orders/{$orderId}.csv",
            $items->map(fn ($item) => implode(',', [
                $item->sku,
                $item->quantity,
                $item->unit_price,
            ]))->implode(PHP_EOL)
        );
    });

orderBy('order_id') здесь нужен не для красоты. Это условие, на котором основана работа chunkBy(): база данных выполняет сортировку, используя индекс, а PHP разделяет поток данных на фрагменты по одной строке.

Такой же подход можно использовать при чтении лог-файла:

use Illuminate\Support\LazyCollection;

LazyCollection::make(function () {
    $handle = fopen(storage_path('logs/laravel.log'), 'r');

    while (($line = fgets($handle)) !== false) {
        yield $line;
    }
})
    ->chunkBy(fn ($line) => str_contains($line, 'ERROR') ? 'error' : 'other')
    ->each(function ($block) {
        // Каждый блок содержит последовательную группу строк с ошибками
        // или строк без ошибок.
    });

То же самое подходит для постраничного API или генератора, который читает CSV. Если источник данных упорядочен и его размер превышает доступную память, chunkBy() позволяет превратить обычную группировку в потоковую операцию.

Два важных нюанса

Сравнение выполняется нестрого.

Реализация сравнивает полученные значения через ==, а не ===:

collect(['1', 1, 1.0])->chunkBy(fn ($v) => $v);

// один фрагмент

Для значения, получаемого из столбца базы данных и имеющего стабильный тип, это обычно не проблема. Но при смешанных данных такое поведение может объединить фрагменты, которые вы ожидали получить отдельно.

Если необходимо, в callback можно вернуть нормализованное значение:

$rows->chunkBy(fn ($row) => (string) $row['code']);

Два объекта также считаются равными при нестрогом сравнении, если они принадлежат одному классу и имеют одинаковые свойства. При группировке по value object это обычно соответствует ожидаемому поведению.

Resolver вызывается дважды для каждого элемента.

При каждой проверке границы chunkBy() получает значение текущего элемента, а затем повторно вычисляет значение последнего элемента текущего фрагмента.

Если callback выполняет дорогую операцию, например парсит дату или вычисляет хеш, значение лучше предварительно вычислить:

$entries
    ->map(fn ($entry) => [
        $entry,
        Carbon::parse($entry->logged_at)->toDateString()
    ])
    ->chunkBy(fn ($pair) => $pair[1]);

Для обычного обращения к ключу или свойству это различие не имеет практического значения.

Похожие статьи

Рекомендательные технологии Подробнее
Разработка и инструменты 6 месяцев назад

Распространённые ошибки производительности в Laravel и простые способы их устранения

Практическое руководство по распространённым ошибкам производительности в Laravel и простым способам их устранения. Разбор N+1-запросов, кэширования, оптимизации выборок и других типичных проблем.

Разработка и инструменты 6 месяцев назад

Как добавить SSH-ключ в аккаунт GitHub

Подробное руководство по добавлению SSH-ключа в аккаунт GitHub: генерация ключа, его добавление в агент, копирование и регистрация на сайте GitHub. Практические шаги для безопасного подключения к репозиториям без пароля.