From 3903922116174a2839f5c84f0c631269eb1cb43e Mon Sep 17 00:00:00 2001 From: Manuk Date: Sat, 31 Oct 2026 18:51:53 +0000 Subject: [PATCH 1/3] fix: run the board query's eager loads once across all columns Each column fetched its cards with its own query, so every relation the board query eager loads ran once per column. A board with ten columns loaded the same relations ten times. Columns now fetch their cards without eager loads, and the loads run once over all of them. Amp-Thread-ID: https://ampcode.com/threads/T-01a191af-73a8-74ec-88f0-93868fe01c45 --- src/Board.php | 7 ++-- src/Concerns/HasBoardRecords.php | 44 +++++++++++++++++++++++-- tests/Feature/BoardEagerLoadingTest.php | 35 ++++++++++++++++++++ tests/Fixtures/TestBoard.php | 4 ++- 4 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/BoardEagerLoadingTest.php 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/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/Feature/BoardEagerLoadingTest.php b/tests/Feature/BoardEagerLoadingTest.php new file mode 100644 index 000000000..267a8f6ba --- /dev/null +++ b/tests/Feature/BoardEagerLoadingTest.php @@ -0,0 +1,35 @@ +create(['name' => 'Alpha']); + $beta = Project::factory()->create(['name' => 'Beta']); + Task::factory()->todo()->create(['project_id' => $alpha->id]); + Task::factory()->inProgress()->create(['project_id' => $beta->id]); + Task::factory()->completed()->create(['project_id' => $alpha->id]); + + $board = Livewire::test(TestBoard::class, ['eagerLoadProject' => true])->instance()->getBoard(); + + DB::enableQueryLog(); + $columns = $board->getViewData()['columns']; + $projectQueries = collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'from "projects"')); + DB::disableQueryLog(); + + $projectNames = collect($columns)->map(fn (array $column): array => collect($column['items']) + ->map(fn (array $item): string => $item['model']->getRelation('project')->name) + ->all()); + + expect($projectQueries)->toHaveCount(1) + ->and($projectNames->all())->toBe([ + 'todo' => ['Alpha'], + 'in_progress' => ['Beta'], + 'completed' => ['Alpha'], + ]); +}); diff --git a/tests/Fixtures/TestBoard.php b/tests/Fixtures/TestBoard.php index f525ae134..51a8f03df 100644 --- a/tests/Fixtures/TestBoard.php +++ b/tests/Fixtures/TestBoard.php @@ -20,9 +20,11 @@ class TestBoard extends BoardPage public bool $withHeaderToolbar = false; + public bool $eagerLoadProject = false; + public function getEloquentQuery(): Builder { - return Task::query(); + return Task::query()->when($this->eagerLoadProject, fn (Builder $query): Builder => $query->with('project')); } public function board(Board $board): Board From a53b2cc962c3581d7c37accb973615ba2e8aca71 Mon Sep 17 00:00:00 2001 From: Manuk Date: Sat, 31 Oct 2026 19:06:03 +0000 Subject: [PATCH 2/3] fix: run the board query's eager loads once across all swimlanes Grouped boards fetch each lane with its own query, so the board query's eager loads ran once per lane. Lanes now fetch their cards without eager loads, and the loads run once over every visible lane. Amp-Thread-ID: https://ampcode.com/threads/T-01a191af-73a8-74ec-88f0-93868fe01c45 --- src/Concerns/HasBoardGroupings.php | 53 ++++++++++++++++++++------- tests/Modern/Feature/GroupingTest.php | 29 +++++++++++++++ 2 files changed, 69 insertions(+), 13 deletions(-) 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/tests/Modern/Feature/GroupingTest.php b/tests/Modern/Feature/GroupingTest.php index ae9943c4b..4c79fcc25 100644 --- a/tests/Modern/Feature/GroupingTest.php +++ b/tests/Modern/Feature/GroupingTest.php @@ -450,3 +450,32 @@ function cellTitles(array $laneData, string $laneId, string $columnId): array expect($seen)->toBe($card->id); }); + +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' => [[]], + ]); +}); From 1c89c5c95c8b2db267ec6e536a4b01e8f2b775a8 Mon Sep 17 00:00:00 2001 From: Manuk Date: Sat, 31 Oct 2026 19:09:14 +0000 Subject: [PATCH 3/3] test: pin the column eager loads without touching frozen fixtures Amp-Thread-ID: https://ampcode.com/threads/T-01a191af-73a8-74ec-88f0-93868fe01c45 --- tests/Feature/BoardEagerLoadingTest.php | 35 ------------------------- tests/Fixtures/TestBoard.php | 4 +-- tests/Modern/Feature/GroupingTest.php | 25 ++++++++++++++++++ 3 files changed, 26 insertions(+), 38 deletions(-) delete mode 100644 tests/Feature/BoardEagerLoadingTest.php diff --git a/tests/Feature/BoardEagerLoadingTest.php b/tests/Feature/BoardEagerLoadingTest.php deleted file mode 100644 index 267a8f6ba..000000000 --- a/tests/Feature/BoardEagerLoadingTest.php +++ /dev/null @@ -1,35 +0,0 @@ -create(['name' => 'Alpha']); - $beta = Project::factory()->create(['name' => 'Beta']); - Task::factory()->todo()->create(['project_id' => $alpha->id]); - Task::factory()->inProgress()->create(['project_id' => $beta->id]); - Task::factory()->completed()->create(['project_id' => $alpha->id]); - - $board = Livewire::test(TestBoard::class, ['eagerLoadProject' => true])->instance()->getBoard(); - - DB::enableQueryLog(); - $columns = $board->getViewData()['columns']; - $projectQueries = collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'from "projects"')); - DB::disableQueryLog(); - - $projectNames = collect($columns)->map(fn (array $column): array => collect($column['items']) - ->map(fn (array $item): string => $item['model']->getRelation('project')->name) - ->all()); - - expect($projectQueries)->toHaveCount(1) - ->and($projectNames->all())->toBe([ - 'todo' => ['Alpha'], - 'in_progress' => ['Beta'], - 'completed' => ['Alpha'], - ]); -}); diff --git a/tests/Fixtures/TestBoard.php b/tests/Fixtures/TestBoard.php index 51a8f03df..f525ae134 100644 --- a/tests/Fixtures/TestBoard.php +++ b/tests/Fixtures/TestBoard.php @@ -20,11 +20,9 @@ class TestBoard extends BoardPage public bool $withHeaderToolbar = false; - public bool $eagerLoadProject = false; - public function getEloquentQuery(): Builder { - return Task::query()->when($this->eagerLoadProject, fn (Builder $query): Builder => $query->with('project')); + return Task::query(); } public function board(Board $board): Board diff --git a/tests/Modern/Feature/GroupingTest.php b/tests/Modern/Feature/GroupingTest.php index 4c79fcc25..1d35a306a 100644 --- a/tests/Modern/Feature/GroupingTest.php +++ b/tests/Modern/Feature/GroupingTest.php @@ -451,6 +451,31 @@ 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']);