From cd873c89764583168c871cb88eb710961afbc580 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 2 Sep 2026 19:30:04 +0930 Subject: [PATCH] Compute Year-in-Review averages in SQL instead of in PHP SeasonalController::getData computed the average posts/likes per profile by grouping in SQL, then pulling every grouped row into a collection and calling ->pluck('count')->avg() in PHP. This loaded one row per profile into memory just to average. Wrap the grouped per-profile counts in a subquery and let the database compute AVG(count), returning a single value. Also drops the invalid SELECT * with GROUP BY (ONLY_FULL_GROUP_BY) by selecting count(*) only. Adds a test verifying the average-of-per-profile-counts and its exclusions (remote, wrong type, out-of-range date), plus the empty case. --- app/Http/Controllers/SeasonalController.php | 35 ++++---- tests/Feature/SeasonalAggregationTest.php | 92 +++++++++++++++++++++ 2 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 tests/Feature/SeasonalAggregationTest.php diff --git a/app/Http/Controllers/SeasonalController.php b/app/Http/Controllers/SeasonalController.php index a9b02ccac..dff14b004 100644 --- a/app/Http/Controllers/SeasonalController.php +++ b/app/Http/Controllers/SeasonalController.php @@ -13,6 +13,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; class SeasonalController extends Controller { @@ -50,21 +51,25 @@ class SeasonalController extends Controller $shared = Cache::remember($siteKey, $siteTtl, function () use ($epochStart, $epochEnd) { return [ 'average' => [ - 'posts' => round(Status::selectRaw('*, count(profile_id) as count') - ->whereNull('uri') - ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']) - ->where('created_at', '>', $epochStart) - ->where('created_at', '<', $epochEnd) - ->groupBy('profile_id') - ->pluck('count') - ->avg()), - - 'likes' => round(Like::selectRaw('*, count(profile_id) as count') - ->where('created_at', '>', $epochStart) - ->where('created_at', '<', $epochEnd) - ->groupBy('profile_id') - ->pluck('count') - ->avg()), + 'posts' => round((float) DB::query()->fromSub( + Status::query() + ->whereNull('uri') + ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']) + ->where('created_at', '>', $epochStart) + ->where('created_at', '<', $epochEnd) + ->groupBy('profile_id') + ->selectRaw('count(*) as count'), + 'per_profile' + )->avg('count')), + + 'likes' => round((float) DB::query()->fromSub( + Like::query() + ->where('created_at', '>', $epochStart) + ->where('created_at', '<', $epochEnd) + ->groupBy('profile_id') + ->selectRaw('count(*) as count'), + 'per_profile' + )->avg('count')), ], 'popular' => [ diff --git a/tests/Feature/SeasonalAggregationTest.php b/tests/Feature/SeasonalAggregationTest.php new file mode 100644 index 000000000..9992fc33a --- /dev/null +++ b/tests/Feature/SeasonalAggregationTest.php @@ -0,0 +1,92 @@ +pluck('count')->avg(). This verifies the SQL-side replacement: +| AVG over a subquery of per-profile counts returns the same value +| without materialising every group in memory. +| +*/ + +class SeasonalAggregationTest extends TestCase +{ + use RefreshDatabase; + + /** + * Mirrors the aggregation used in SeasonalController::getData for + * average posts per profile. + */ + private function averagePostsPerProfile(string $epochStart, string $epochEnd): float + { + return (float) DB::query()->fromSub( + Status::query() + ->whereNull('uri') + ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']) + ->where('created_at', '>', $epochStart) + ->where('created_at', '<', $epochEnd) + ->groupBy('profile_id') + ->selectRaw('count(*) as count'), + 'per_profile' + )->avg('count'); + } + + #[Test] + public function it_averages_matching_posts_per_profile_in_sql() + { + $epochStart = '2020-01-01 00:00:00'; + $epochEnd = '2020-12-31 23:59:59'; + $inRange = '2020-06-01 12:00:00'; + + // Profile 1: 2 matching photos. Profile 2: 4 matching photos. + // Average per profile = 3. + Status::factory()->count(2)->photo()->create([ + 'profile_id' => 1001, + 'created_at' => $inRange, + ]); + Status::factory()->count(4)->photo()->create([ + 'profile_id' => 1002, + 'created_at' => $inRange, + ]); + + // Noise that must be excluded from the average: + // remote (uri set), wrong type, and out-of-range date. + Status::factory()->photo()->create([ + 'profile_id' => 1003, + 'uri' => 'https://remote.example/statuses/1', + 'created_at' => $inRange, + ]); + Status::factory()->create([ + 'profile_id' => 1003, + 'type' => 'text', + 'created_at' => $inRange, + ]); + Status::factory()->photo()->create([ + 'profile_id' => 1004, + 'created_at' => '2019-01-01 00:00:00', + ]); + + $this->assertSame(3.0, $this->averagePostsPerProfile($epochStart, $epochEnd)); + } + + #[Test] + public function it_returns_zero_when_no_posts_match() + { + $average = $this->averagePostsPerProfile('2020-01-01 00:00:00', '2020-12-31 23:59:59'); + + // No rows -> AVG returns null -> cast to 0.0 + $this->assertSame(0.0, $average); + } +}