From bfcbfec35722fd372c23f115f4bd9b779e4d57c3 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sat, 29 Aug 2026 12:25:37 +0200 Subject: [PATCH] Cache Schema::hasTable lookups on artwork JSON. Artwork detail was paying repeated information_schema hits per request. Keep public detail edge-case behavior covered by regression tests. --- app/Http/Resources/ArtworkResource.php | 34 +++++---- .../M18B2ArtworkDetailRegressionTest.php | 75 +++++++++++++++++++ 2 files changed, 96 insertions(+), 13 deletions(-) create mode 100644 tests/Feature/Artworks/M18B2ArtworkDetailRegressionTest.php diff --git a/app/Http/Resources/ArtworkResource.php b/app/Http/Resources/ArtworkResource.php index 0cabbd3f..91a682a2 100644 --- a/app/Http/Resources/ArtworkResource.php +++ b/app/Http/Resources/ArtworkResource.php @@ -15,6 +15,9 @@ use Illuminate\Support\Facades\Schema; class ArtworkResource extends JsonResource { + /** @var array */ + private array $tablePresence = []; + /** * Transform the resource into an array. */ @@ -35,14 +38,14 @@ class ArtworkResource extends JsonResource $followerCount = 0; if (!empty($this->user?->id)) { - if (Schema::hasTable('user_statistics')) { + if ($this->hasTable('user_statistics')) { $followerCount = (int) DB::table('user_statistics') ->where('user_id', (int) $this->user->id) ->value('followers_count'); } // Legacy fallback for environments where new tables are unavailable. - if (($followerCount <= 0) && Schema::hasTable('friends_list')) { + if (($followerCount <= 0) && $this->hasTable('friends_list')) { $followerCount = (int) DB::table('friends_list') ->where('friend_id', (int) $this->user->id) ->count(); @@ -58,19 +61,19 @@ class ArtworkResource extends JsonResource $viewerAward = null; $isOwner = $viewerId > 0 && $viewerId === (int) ($this->user?->id ?? 0); - $bookmarksCount = Schema::hasTable('artwork_bookmarks') + $bookmarksCount = $this->hasTable('artwork_bookmarks') ? (int) DB::table('artwork_bookmarks')->where('artwork_id', (int) $this->id)->count() : 0; if ($viewerId > 0) { - if (Schema::hasTable('artwork_likes')) { + if ($this->hasTable('artwork_likes')) { $isLiked = DB::table('artwork_likes') ->where('user_id', $viewerId) ->where('artwork_id', (int) $this->id) ->exists(); } - if (Schema::hasTable('artwork_bookmarks')) { + if ($this->hasTable('artwork_bookmarks')) { $isBookmarked = DB::table('artwork_bookmarks') ->where('user_id', $viewerId) ->where('artwork_id', (int) $this->id) @@ -83,12 +86,12 @@ class ArtworkResource extends JsonResource ->exists(); if (!empty($this->user?->id)) { - if (Schema::hasTable('user_followers')) { + if ($this->hasTable('user_followers')) { $isFollowing = DB::table('user_followers') ->where('user_id', (int) $this->user->id) ->where('follower_id', $viewerId) ->exists(); - } elseif (Schema::hasTable('friends_list')) { + } elseif ($this->hasTable('friends_list')) { // Legacy fallback only. $isFollowing = DB::table('friends_list') ->where('user_id', $viewerId) @@ -97,14 +100,14 @@ class ArtworkResource extends JsonResource } } - if (!empty($this->group?->id) && Schema::hasTable('group_follows')) { + if (!empty($this->group?->id) && $this->hasTable('group_follows')) { $isFollowingGroup = DB::table('group_follows') ->where('group_id', (int) $this->group->id) ->where('user_id', $viewerId) ->exists(); } - if (Schema::hasTable('artwork_medals')) { + if ($this->hasTable('artwork_medals')) { $viewerAward = DB::table('artwork_medals') ->where('user_id', $viewerId) ->where('artwork_id', (int) $this->id) @@ -279,7 +282,7 @@ class ArtworkResource extends JsonResource private function resolveScreenshotAssets(): array { - if (! Schema::hasTable('artwork_files')) { + if (! $this->hasTable('artwork_files')) { return []; } @@ -339,7 +342,7 @@ class ArtworkResource extends JsonResource $items = collect(); $participationWorlds = collect(); - if (Schema::hasTable('world_relations') && Schema::hasTable('worlds')) { + if ($this->hasTable('world_relations') && $this->hasTable('worlds')) { $relations = WorldRelation::query() ->with('world') ->where('related_type', WorldRelation::TYPE_ARTWORK) @@ -369,7 +372,7 @@ class ArtworkResource extends JsonResource ); } - if (Schema::hasTable('world_submissions')) { + if ($this->hasTable('world_submissions')) { $liveSubmissions = $this->worldSubmissions ->filter(function (WorldSubmission $submission): bool { return (string) $submission->status === WorldSubmission::STATUS_LIVE @@ -403,7 +406,7 @@ class ArtworkResource extends JsonResource World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all()); } - if (Schema::hasTable('world_reward_grants')) { + if ($this->hasTable('world_reward_grants')) { $items = $items->concat(app(WorldRewardService::class)->artworkRewardBadges($this->resource)); } @@ -421,6 +424,11 @@ class ArtworkResource extends JsonResource ->all(); } + private function hasTable(string $table): bool + { + return $this->tablePresence[$table] ??= Schema::hasTable($table); + } + private function authorCanPublishLinks(): bool { $level = (int) ($this->user?->level ?? 1); diff --git a/tests/Feature/Artworks/M18B2ArtworkDetailRegressionTest.php b/tests/Feature/Artworks/M18B2ArtworkDetailRegressionTest.php new file mode 100644 index 00000000..ed37328f --- /dev/null +++ b/tests/Feature/Artworks/M18B2ArtworkDetailRegressionTest.php @@ -0,0 +1,75 @@ +create([ + 'title' => 'M18B2 Artwork', + 'slug' => 'm18b2-artwork', + ]); + + $this->get(route('art.show', ['id' => $artwork->id, 'slug' => 'm18b2-artwork'])) + ->assertOk(); + + $this->get(route('art.show', ['id' => $artwork->id, 'slug' => 'wrong-slug'])) + ->assertRedirect(route('art.show', ['id' => $artwork->id, 'slug' => 'm18b2-artwork'])) + ->assertHeader('Location', route('art.show', ['id' => $artwork->id, 'slug' => 'm18b2-artwork'])); + + $deleted = Artwork::factory()->create(); + $deleted->delete(); + + $this->get(route('art.show', ['id' => $deleted->id, 'slug' => $deleted->slug])) + ->assertStatus(410); + + $private = Artwork::factory()->private()->create(); + $this->get(route('art.show', ['id' => $private->id, 'slug' => $private->slug])) + ->assertForbidden(); + + $this->get(route('art.show', ['id' => 999999, 'slug' => 'missing'])) + ->assertNotFound(); +}); + +it('performs one primary artwork lookup on the public detail path', function (): void { + $artwork = Artwork::factory()->create([ + 'slug' => 'm18b2-query-regression', + ]); + $primaryLookups = 0; + + DB::listen(function ($query) use (&$primaryLookups): void { + $sql = strtolower(preg_replace('/\s+/', ' ', $query->sql)); + + if (str_contains($sql, 'from "artworks"') + && str_contains($sql, 'where "id" = ?') + && str_contains($sql, 'limit 1')) { + $primaryLookups++; + } + }); + + $this->get(route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug])) + ->assertOk(); + + expect($primaryLookups)->toBe(1); +}); + +it('keeps the authenticated artwork detail query count bounded', function (): void { + $artwork = Artwork::factory()->create([ + 'slug' => 'm18b2-auth-query-regression', + ]); + $viewer = User::factory()->create(); + $queryCount = 0; + + DB::listen(function () use (&$queryCount): void { + $queryCount++; + }); + + $this->actingAs($viewer) + ->get(route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug])) + ->assertOk(); + + expect($queryCount)->toBeLessThanOrEqual(50); +});