From 909c2da6df1a23ba7371a0341fa80033bc8d11fb Mon Sep 17 00:00:00 2001 From: darbs-claude Date: Mon, 23 Feb 2026 10:40:00 +0000 Subject: [PATCH] perf: replace N+1 find() with whereIn batch lookup in checkDependencies() Resolves #23 - Replace per-dependency `AgentPhase::find()` loop with a single `AgentPhase::whereIn('id', $dependencies)->get()` call, reducing query count from N to 1 for any number of dependencies - Short-circuit early when dependencies list is empty to avoid unnecessary query at all - Add tests: empty deps, skipped-dep passthrough, single-query assertion, blocker shape validation Co-Authored-By: Claude Sonnet 4.6 --- Models/AgentPhase.php | 12 ++++-- tests/Feature/AgentPhaseTest.php | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/Models/AgentPhase.php b/Models/AgentPhase.php index d4c8549..80a10c7 100644 --- a/Models/AgentPhase.php +++ b/Models/AgentPhase.php @@ -317,11 +317,17 @@ class AgentPhase extends Model public function checkDependencies(): array { $dependencies = $this->dependencies ?? []; + + if (empty($dependencies)) { + return []; + } + $blockers = []; - foreach ($dependencies as $depId) { - $dep = AgentPhase::find($depId); - if ($dep && ! $dep->isCompleted() && ! $dep->isSkipped()) { + $deps = AgentPhase::whereIn('id', $dependencies)->get(); + + foreach ($deps as $dep) { + if (! $dep->isCompleted() && ! $dep->isSkipped()) { $blockers[] = [ 'phase_id' => $dep->id, 'phase_order' => $dep->order, diff --git a/tests/Feature/AgentPhaseTest.php b/tests/Feature/AgentPhaseTest.php index aab93dc..6d03414 100644 --- a/tests/Feature/AgentPhaseTest.php +++ b/tests/Feature/AgentPhaseTest.php @@ -286,6 +286,78 @@ class AgentPhaseTest extends TestCase $this->assertEquals($dep2->id, $blockers[0]['phase_id']); } + public function test_check_dependencies_returns_empty_when_no_dependencies(): void + { + $phase = AgentPhase::factory()->pending()->create([ + 'agent_plan_id' => $this->plan->id, + 'dependencies' => null, + ]); + + $this->assertSame([], $phase->checkDependencies()); + } + + public function test_check_dependencies_not_blocked_by_skipped_phase(): void + { + $dep = AgentPhase::factory()->skipped()->create([ + 'agent_plan_id' => $this->plan->id, + 'order' => 1, + ]); + + $phase = AgentPhase::factory()->pending()->create([ + 'agent_plan_id' => $this->plan->id, + 'order' => 2, + 'dependencies' => [$dep->id], + ]); + + $this->assertSame([], $phase->checkDependencies()); + $this->assertTrue($phase->canStart()); + } + + public function test_check_dependencies_uses_single_query_for_multiple_deps(): void + { + $deps = AgentPhase::factory()->pending()->count(5)->create([ + 'agent_plan_id' => $this->plan->id, + ]); + + $phase = AgentPhase::factory()->pending()->create([ + 'agent_plan_id' => $this->plan->id, + 'dependencies' => $deps->pluck('id')->toArray(), + ]); + + $queryCount = 0; + \DB::listen(function () use (&$queryCount) { + $queryCount++; + }); + + $blockers = $phase->checkDependencies(); + + $this->assertCount(5, $blockers); + $this->assertSame(1, $queryCount, 'checkDependencies() should issue exactly one query'); + } + + public function test_check_dependencies_blocker_contains_expected_keys(): void + { + $dep = AgentPhase::factory()->inProgress()->create([ + 'agent_plan_id' => $this->plan->id, + 'order' => 1, + 'name' => 'Blocker Phase', + ]); + + $phase = AgentPhase::factory()->pending()->create([ + 'agent_plan_id' => $this->plan->id, + 'order' => 2, + 'dependencies' => [$dep->id], + ]); + + $blockers = $phase->checkDependencies(); + + $this->assertCount(1, $blockers); + $this->assertEquals($dep->id, $blockers[0]['phase_id']); + $this->assertEquals(1, $blockers[0]['phase_order']); + $this->assertEquals('Blocker Phase', $blockers[0]['phase_name']); + $this->assertEquals(AgentPhase::STATUS_IN_PROGRESS, $blockers[0]['status']); + } + public function test_can_start_checks_dependencies(): void { $dep = AgentPhase::factory()->pending()->create([ -- 2.45.3