diff --git a/src/Board.php b/src/Board.php index 467734918..25526d7c1 100644 --- a/src/Board.php +++ b/src/Board.php @@ -136,6 +136,9 @@ public function getViewData(): array protected function getColumnViewData(bool $withItems): array { $allCounts = $this->getBatchedBoardRecordCounts(); + $recordsByColumn = $withItems + ? $this->getBoardRecordsForColumns(array_map(fn (Column $column): string => $column->getName(), $this->getColumns())) + : []; $columns = []; foreach ($this->getColumns() as $column) { @@ -148,8 +151,8 @@ protected function getColumnViewData(bool $withItems): array 'label' => $column->getLabel(), 'color' => $column->getColor(), 'icon' => $column->getIcon(), - 'items' => $withItems - ? $this->getBoardRecords($columnId)->map(fn ($record) => $this->formatBoardRecord($record))->toArray() + 'items' => isset($recordsByColumn[$columnId]) + ? $recordsByColumn[$columnId]->map(fn ($record) => $this->formatBoardRecord($record))->toArray() : [], 'total' => $allCounts[$columnId] ?? 0, 'summary' => $this->getColumnSummary($column), diff --git a/src/Concerns/HasBoardGroupings.php b/src/Concerns/HasBoardGroupings.php index 64020d9e0..55c001d2a 100644 --- a/src/Concerns/HasBoardGroupings.php +++ b/src/Concerns/HasBoardGroupings.php @@ -5,6 +5,7 @@ namespace Relaticle\Flowforge\Concerns; use Closure; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; @@ -83,11 +84,16 @@ public function getLanes(Grouping $grouping): array } public function getLaneRecords(Lane $lane, int $perCellLimit): EloquentCollection + { + return $this->getLaneRecordsQuery($lane, $perCellLimit)?->get() ?? new EloquentCollection; + } + + protected function getLaneRecordsQuery(Lane $lane, int $perCellLimit): ?Builder { $boardQuery = $this->getFilteredBoardQuery(); if ($boardQuery === null) { - return new EloquentCollection; + return null; } $scoped = $lane->applyQuery($boardQuery)->applyScopes(); @@ -131,9 +137,7 @@ public function getLaneRecords(Lane $lane, int $perCellLimit): EloquentCollectio ->where('flowforge_cell_row', '<=', $perCellLimit) ->orderBy('flowforge_cell_row'); - $outer->setEagerLoads($scoped->getEagerLoads()); - - return $outer->get(); + return $outer->setEagerLoads($scoped->getEagerLoads()); } protected function identifierBelongsToModel(Model $model): bool @@ -164,34 +168,57 @@ protected function laneHasCards(Lane $lane): bool */ public function getLaneViewData(Grouping $grouping, int $visibleLaneCount, array $cellCardLimits): array { - $lanes = []; + $columnIds = $this->getColumnIdentifiers(); + $eagerLoads = []; + $visibleLanes = []; + $hasMoreLanes = false; foreach ($this->getLanes($grouping) as $lane) { - if (count($lanes) >= $visibleLaneCount) { + if (count($visibleLanes) >= $visibleLaneCount) { if ($this->laneHasCards($lane)) { - return ['lanes' => $lanes, 'hasMoreLanes' => true]; + $hasMoreLanes = true; + + break; } continue; } - $laneData = $this->formatLane($lane, $cellCardLimits[$lane->getName()] ?? []); + $cellLimits = $cellCardLimits[$lane->getName()] ?? []; + $query = $this->getLaneRecordsQuery($lane, max([$this->getCardsPerColumn(), ...array_values($cellLimits)])); + + if ($query === null) { + continue; + } + + $eagerLoads = $query->getEagerLoads(); + $records = $query->setEagerLoads([])->get(); - if ($laneData['total'] > 0) { - $lanes[] = $laneData; + if ($records->contains(fn (Model $record): bool => in_array($this->getRecordColumnId($record), $columnIds, true))) { + $visibleLanes[] = [$lane, $records, $cellLimits]; } } - return ['lanes' => $lanes, 'hasMoreLanes' => false]; + if ($eagerLoads !== []) { + (new EloquentCollection(array_merge(...array_map( + fn (array $visibleLane): array => $visibleLane[1]->all(), + $visibleLanes, + ))))->load($eagerLoads); + } + + return [ + 'lanes' => array_map(fn (array $visibleLane): array => $this->formatLane(...$visibleLane), $visibleLanes), + 'hasMoreLanes' => $hasMoreLanes, + ]; } /** + * @param EloquentCollection $records * @param array $cellLimits * @return array */ - protected function formatLane(Lane $lane, array $cellLimits): array + protected function formatLane(Lane $lane, EloquentCollection $records, array $cellLimits): array { - $records = $this->getLaneRecords($lane, max([$this->getCardsPerColumn(), ...array_values($cellLimits)])); $cells = []; foreach ($this->getColumnIdentifiers() as $columnId) { diff --git a/src/Concerns/HasBoardRecords.php b/src/Concerns/HasBoardRecords.php index d2badebde..19e2e8026 100644 --- a/src/Concerns/HasBoardRecords.php +++ b/src/Concerns/HasBoardRecords.php @@ -118,11 +118,51 @@ public function getRecordColumnId(Model $record): string * Uses position field for optimal performance with large datasets. */ public function getBoardRecords(string $columnId): Collection + { + return $this->getBoardRecordsQuery($columnId)?->get() ?? new Collection; + } + + /** + * Get the records of several columns, running the query's eager loads once across all + * of them instead of once per column. + * + * @param array $columnIds + * @return array + */ + public function getBoardRecordsForColumns(array $columnIds): array + { + $eagerLoads = []; + $recordsByColumn = []; + + foreach ($columnIds as $columnId) { + $query = $this->getBoardRecordsQuery($columnId); + + if (! $query) { + $recordsByColumn[$columnId] = new Collection; + + continue; + } + + $eagerLoads = $query->getEagerLoads(); + $recordsByColumn[$columnId] = $query->setEagerLoads([])->get(); + } + + if ($eagerLoads !== []) { + (new Collection(array_merge(...array_map( + fn (Collection $records): array => $records->all(), + array_values($recordsByColumn), + ))))->load($eagerLoads); + } + + return $recordsByColumn; + } + + protected function getBoardRecordsQuery(string $columnId): ?Builder { $queryClone = $this->getFilteredColumnQuery($columnId); if (! $queryClone) { - return new Collection; + return null; } $livewire = $this->getLivewire(); @@ -139,7 +179,7 @@ public function getBoardRecords(string $columnId): Collection ->orderBy($keyName, 'asc'); // Tie-breaker for deterministic order } - return $queryClone->limit($limit)->get(); + return $queryClone->limit($limit); } /** diff --git a/tests/Modern/Feature/GroupingTest.php b/tests/Modern/Feature/GroupingTest.php index ae9943c4b..1d35a306a 100644 --- a/tests/Modern/Feature/GroupingTest.php +++ b/tests/Modern/Feature/GroupingTest.php @@ -450,3 +450,57 @@ function cellTitles(array $laneData, string $laneId, string $columnId): array expect($seen)->toBe($card->id); }); + +test('runs the board query eager loads once for every column', function () { + $emma = User::factory()->create(['name' => 'Emma Collins']); + $marcus = User::factory()->create(['name' => 'Marcus Johnson']); + groupedTask('Todo', 'todo', 'low', '65535.0000000000')->assignees()->attach($emma->id); + groupedTask('Doing', 'in_progress', 'high', '65535.0000000000')->assignees()->attach($marcus->id); + + $board = Livewire::test(TestGroupedBoard::class)->instance()->getBoard(); + + DB::enableQueryLog(); + $columns = $board->getViewData()['columns']; + $assigneeQueries = collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'from "users"')); + DB::disableQueryLog(); + + $assigneeNames = collect($columns)->map(fn (array $column): array => array_map( + fn (array $item): array => $item['model']->getRelation('assignees')->pluck('name')->all(), + $column['items'], + )); + + expect($assigneeQueries)->toHaveCount(1) + ->and($assigneeNames->all())->toBe([ + 'todo' => [['Emma Collins']], + 'in_progress' => [['Marcus Johnson']], + ]); +}); + +test('runs the board query eager loads once for every lane', function () { + $emma = User::factory()->create(['name' => 'Emma Collins']); + $marcus = User::factory()->create(['name' => 'Marcus Johnson']); + groupedTask('Low', 'todo', 'low', '65535.0000000000')->assignees()->attach($emma->id); + groupedTask('High', 'in_progress', 'high', '65535.0000000000')->assignees()->attach($marcus->id); + groupedTask('Medium', 'todo', 'medium', '131070.0000000000'); + + $board = Livewire::test(TestGroupedBoard::class)->instance()->getBoard(); + + DB::enableQueryLog(); + $data = $board->getLaneViewData($board->getGrouping('priority'), 10, []); + $assigneeQueries = collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'from "users"')); + DB::disableQueryLog(); + + $assigneeNames = collect($data['lanes'])->mapWithKeys(fn (array $lane): array => [ + $lane['id'] => collect($lane['cells'])->flatMap(fn (array $cell): array => array_map( + fn (array $item): array => $item['model']->getRelation('assignees')->pluck('name')->all(), + $cell['items'], + ))->all(), + ]); + + expect($assigneeQueries)->toHaveCount(1) + ->and($assigneeNames->sortKeys()->all())->toBe([ + 'high' => [['Marcus Johnson']], + 'low' => [['Emma Collins']], + 'medium' => [[]], + ]); +});