Batch-load comment reaction counts on artwork comments.
Avoid per-comment reaction queries on the deferred comments API by aggregating counts in one service call.
This commit is contained in:
@@ -11,6 +11,7 @@ use App\Models\UserMention;
|
||||
use App\Notifications\ArtworkCommentedNotification;
|
||||
use App\Notifications\ArtworkMentionedNotification;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\CommentReactionService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -30,6 +31,8 @@ class ArtworkCommentController extends Controller
|
||||
{
|
||||
private const MAX_LENGTH = 10_000;
|
||||
|
||||
public function __construct(private readonly CommentReactionService $commentReactions) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// List
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -53,7 +56,9 @@ class ArtworkCommentController extends Controller
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$userId = $request->user()?->id;
|
||||
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true));
|
||||
$commentIds = $this->commentIds($comments->getCollection());
|
||||
$reactionTotals = $this->commentReactions->forComments($commentIds, $userId);
|
||||
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true, $reactionTotals));
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
@@ -138,7 +143,9 @@ class ArtworkCommentController extends Controller
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)], 201);
|
||||
$reactionTotals = $this->commentReactions->forComments([$comment->id], $request->user()->id);
|
||||
|
||||
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false, $reactionTotals)], 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -197,7 +204,7 @@ class ArtworkCommentController extends Controller
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false): array
|
||||
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false, array $reactionTotals = []): array
|
||||
{
|
||||
$user = $c->user;
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
@@ -221,12 +228,13 @@ class ArtworkCommentController extends Controller
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
],
|
||||
'reactions' => $reactionTotals[(int) $c->id] ?? [],
|
||||
];
|
||||
|
||||
if ($includeReplies && $c->relationLoaded('approvedReplies')) {
|
||||
$data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
|
||||
$data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true, $reactionTotals))->values()->toArray();
|
||||
} elseif ($includeReplies && $c->relationLoaded('replies')) {
|
||||
$data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
|
||||
$data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true, $reactionTotals))->values()->toArray();
|
||||
} else {
|
||||
$data['replies'] = [];
|
||||
}
|
||||
@@ -234,6 +242,24 @@ class ArtworkCommentController extends Controller
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** @param iterable<int, ArtworkComment> $comments */
|
||||
private function commentIds(iterable $comments): array
|
||||
{
|
||||
$ids = [];
|
||||
$walk = function (iterable $items) use (&$walk, &$ids): void {
|
||||
foreach ($items as $comment) {
|
||||
$ids[] = (int) $comment->id;
|
||||
if ($comment->relationLoaded('approvedReplies')) {
|
||||
$walk($comment->approvedReplies);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$walk($comments);
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
private function renderCommentContent(ArtworkComment $comment): string
|
||||
{
|
||||
$rawContent = (string) ($comment->raw_content ?? $comment->content ?? '');
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\ReactionType;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Bulk reaction read model for artwork comment trees.
|
||||
*
|
||||
* The returned value intentionally matches ReactionController's `totals`
|
||||
* contract so SSR and API comment payloads can hydrate ReactionBar directly.
|
||||
*/
|
||||
final class CommentReactionService
|
||||
{
|
||||
/**
|
||||
* @param iterable<int|string> $commentIds
|
||||
* @return array<int, array<string, array{emoji:string, label:string, count:int, mine:bool}>>
|
||||
*/
|
||||
public function forComments(iterable $commentIds, ?int $userId = null): array
|
||||
{
|
||||
$ids = collect($commentIds)
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->filter(static fn (int $id): bool => $id > 0)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$counts = DB::table('comment_reactions')
|
||||
->whereIn('comment_id', $ids)
|
||||
->selectRaw('comment_id, reaction, COUNT(*) as total')
|
||||
->groupBy('comment_id', 'reaction')
|
||||
->get()
|
||||
->groupBy('comment_id');
|
||||
|
||||
$mine = collect();
|
||||
if ($userId !== null) {
|
||||
$mine = DB::table('comment_reactions')
|
||||
->whereIn('comment_id', $ids)
|
||||
->where('user_id', $userId)
|
||||
->get(['comment_id', 'reaction'])
|
||||
->groupBy('comment_id');
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($ids as $commentId) {
|
||||
$commentCounts = $counts->get($commentId, collect())->keyBy('reaction');
|
||||
$commentMine = $mine->get($commentId, collect())->pluck('reaction')->flip();
|
||||
|
||||
$totals = [];
|
||||
foreach (ReactionType::cases() as $type) {
|
||||
$slug = $type->value;
|
||||
$totals[$slug] = [
|
||||
'emoji' => $type->emoji(),
|
||||
'label' => $type->label(),
|
||||
'count' => (int) ($commentCounts->get($slug)->total ?? 0),
|
||||
'mine' => $commentMine->has($slug),
|
||||
];
|
||||
}
|
||||
|
||||
$result[$commentId] = $totals;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\ReactionType;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\CommentReaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function m18b1ArtworkCommentTree(int $topLevelCount = 2): array
|
||||
{
|
||||
$artwork = Artwork::factory()->create();
|
||||
$author = User::factory()->create();
|
||||
$viewer = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$comments = [];
|
||||
|
||||
for ($i = 0; $i < $topLevelCount; $i++) {
|
||||
$comment = ArtworkComment::factory()->create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $author->id,
|
||||
'parent_id' => null,
|
||||
]);
|
||||
$reply = ArtworkComment::factory()->create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $other->id,
|
||||
'parent_id' => $comment->id,
|
||||
]);
|
||||
|
||||
CommentReaction::create([
|
||||
'comment_id' => $comment->id,
|
||||
'user_id' => $other->id,
|
||||
'reaction' => ReactionType::Heart->value,
|
||||
]);
|
||||
CommentReaction::create([
|
||||
'comment_id' => $reply->id,
|
||||
'user_id' => $viewer->id,
|
||||
'reaction' => ReactionType::Fire->value,
|
||||
]);
|
||||
CommentReaction::create([
|
||||
'comment_id' => $reply->id,
|
||||
'user_id' => $other->id,
|
||||
'reaction' => ReactionType::Clap->value,
|
||||
]);
|
||||
|
||||
$comments[] = [$comment, $reply];
|
||||
}
|
||||
|
||||
return [$artwork, $viewer, $comments];
|
||||
}
|
||||
|
||||
function m18b1ReactionQueryCount(array $queries): int
|
||||
{
|
||||
return collect($queries)
|
||||
->filter(fn (array $query): bool => str_contains(strtolower((string) $query['query']), 'comment_reactions'))
|
||||
->count();
|
||||
}
|
||||
|
||||
it('includes bulk reaction totals for guests with one reaction query', function (): void {
|
||||
[$artwork, , $comments] = m18b1ArtworkCommentTree();
|
||||
|
||||
DB::enableQueryLog();
|
||||
DB::flushQueryLog();
|
||||
|
||||
$response = $this->getJson("/api/artworks/{$artwork->id}/comments")->assertOk();
|
||||
$payload = $response->json('data');
|
||||
$withoutReactions = json_decode(json_encode($payload, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
|
||||
$stripReactions = function (array &$items) use (&$stripReactions): void {
|
||||
foreach ($items as &$item) {
|
||||
unset($item['reactions']);
|
||||
if (isset($item['replies']) && is_array($item['replies'])) {
|
||||
$stripReactions($item['replies']);
|
||||
}
|
||||
}
|
||||
};
|
||||
$stripReactions($withoutReactions);
|
||||
$commentCount = 0;
|
||||
$countComments = function (array $items) use (&$countComments, &$commentCount): void {
|
||||
foreach ($items as $item) {
|
||||
$commentCount++;
|
||||
if (isset($item['replies']) && is_array($item['replies'])) {
|
||||
$countComments($item['replies']);
|
||||
}
|
||||
}
|
||||
};
|
||||
$countComments($payload);
|
||||
|
||||
fwrite(STDERR, sprintf(
|
||||
"M18B1 guest payload bytes: before=%d after=%d added=%d comments/replies=%d\n",
|
||||
strlen(json_encode($withoutReactions, JSON_THROW_ON_ERROR)),
|
||||
strlen(json_encode($payload, JSON_THROW_ON_ERROR)),
|
||||
strlen(json_encode($payload, JSON_THROW_ON_ERROR)) - strlen(json_encode($withoutReactions, JSON_THROW_ON_ERROR)),
|
||||
$commentCount,
|
||||
));
|
||||
|
||||
expect(m18b1ReactionQueryCount(DB::getQueryLog()))->toBe(1)
|
||||
->and($payload[0])->toHaveKey('reactions')
|
||||
->and($payload[0]['reactions'][ReactionType::Heart->value]['count'])->toBe(1)
|
||||
->and($payload[0]['reactions'][ReactionType::Heart->value]['mine'])->toBeFalse()
|
||||
->and($payload[0]['replies'][0]['reactions'][ReactionType::Fire->value]['count'])->toBe(1)
|
||||
->and(count($comments))->toBe(2);
|
||||
|
||||
DB::disableQueryLog();
|
||||
});
|
||||
|
||||
it('includes viewer reaction state for comments and replies with one extra query', function (): void {
|
||||
[$artwork, $viewer] = m18b1ArtworkCommentTree();
|
||||
|
||||
DB::enableQueryLog();
|
||||
DB::flushQueryLog();
|
||||
|
||||
$response = $this->actingAs($viewer)
|
||||
->getJson("/api/artworks/{$artwork->id}/comments")
|
||||
->assertOk();
|
||||
$payload = $response->json('data');
|
||||
|
||||
expect(m18b1ReactionQueryCount(DB::getQueryLog()))->toBe(2)
|
||||
->and($payload[0]['reactions'][ReactionType::Heart->value]['mine'])->toBeFalse()
|
||||
->and($payload[0]['replies'][0]['reactions'][ReactionType::Fire->value]['mine'])->toBeTrue();
|
||||
|
||||
DB::disableQueryLog();
|
||||
});
|
||||
|
||||
it('keeps reaction query count constant as the comment tree grows', function (): void {
|
||||
[$smallArtwork] = m18b1ArtworkCommentTree(2);
|
||||
[$largeArtwork] = m18b1ArtworkCommentTree(20);
|
||||
|
||||
DB::enableQueryLog();
|
||||
|
||||
DB::flushQueryLog();
|
||||
$this->getJson("/api/artworks/{$smallArtwork->id}/comments")->assertOk();
|
||||
$smallCount = m18b1ReactionQueryCount(DB::getQueryLog());
|
||||
|
||||
DB::flushQueryLog();
|
||||
$this->getJson("/api/artworks/{$largeArtwork->id}/comments")->assertOk();
|
||||
$largeCount = m18b1ReactionQueryCount(DB::getQueryLog());
|
||||
|
||||
DB::disableQueryLog();
|
||||
|
||||
expect($smallCount)->toBe(1)->and($largeCount)->toBe(1);
|
||||
});
|
||||
Reference in New Issue
Block a user