From a79cce59d63f3d5df0f7856930cd1e7be64d179e Mon Sep 17 00:00:00 2001 From: Davi Castello Branco Tavares de Oliveira Date: Tue, 15 Sep 2026 22:53:10 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(activity):=20S5=20=E2=80=94=20query=20?= =?UTF-8?q?object=20ReactionSummary=20(#544)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resume as reações de um conjunto de posts em número fixo de consultas: uma agregação `group by (timeline_id, reaction)` e, se houver usuário, uma leitura das linhas dele nos mesmos posts. Sem N+1 conforme a página cresce; a 2ª consulta não roda quando `userId` é null. Cada item vem como DTO `TimelineReactionSummary` (counts só > 0 + `mine` como `TimelineReaction`), seguindo a preferência do projeto por DTOs no lugar de arrays associativos. O agregado do Discord (`activity_reactions`) não entra na soma. Teste feature com `DB::listen` confirma ≤ 2 consultas para 3 e para 30 posts, e 1 consulta sem usuário. Co-Authored-By: Claude Fable 5.1 --- .../Reaction/DTOs/TimelineReactionSummary.php | 33 ++++ .../src/Reaction/Queries/ReactionSummary.php | 70 ++++++++ .../Feature/Reaction/ReactionSummaryTest.php | 164 ++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 app-modules/activity/src/Reaction/DTOs/TimelineReactionSummary.php create mode 100644 app-modules/activity/src/Reaction/Queries/ReactionSummary.php create mode 100644 app-modules/activity/tests/Feature/Reaction/ReactionSummaryTest.php diff --git a/app-modules/activity/src/Reaction/DTOs/TimelineReactionSummary.php b/app-modules/activity/src/Reaction/DTOs/TimelineReactionSummary.php new file mode 100644 index 00000000..ac8c9e98 --- /dev/null +++ b/app-modules/activity/src/Reaction/DTOs/TimelineReactionSummary.php @@ -0,0 +1,33 @@ + 0; `mine` é a reação do usuário consultado naquele post, ou null. + */ +final readonly class TimelineReactionSummary +{ + /** @param array $counts */ + public function __construct( + public string $timelineId, + public array $counts = [], + public ?TimelineReaction $mine = null, + ) {} + + public function total(): int + { + return array_sum($this->counts); + } + + public function countOf(TimelineReaction $reaction): int + { + return $this->counts[$reaction->value] ?? 0; + } +} diff --git a/app-modules/activity/src/Reaction/Queries/ReactionSummary.php b/app-modules/activity/src/Reaction/Queries/ReactionSummary.php new file mode 100644 index 00000000..f1e9e4b9 --- /dev/null +++ b/app-modules/activity/src/Reaction/Queries/ReactionSummary.php @@ -0,0 +1,70 @@ + $timelineIds + * @return Collection indexada por `timeline_id`, com um item para cada id pedido + */ + public function forTimelines(iterable $timelineIds, ?string $userId): Collection + { + /** @var list $ids */ + $ids = Collection::make($timelineIds)->unique()->values()->all(); + + if ($ids === []) { + return Collection::make(); + } + + /** @var array> $counts */ + $counts = []; + + $rows = UserReaction::query() + ->select(['timeline_id', 'reaction']) + ->selectRaw('count(*) as total') + ->whereIn('timeline_id', $ids) + ->groupBy('timeline_id', 'reaction') + ->get(); + + foreach ($rows as $row) { + $counts[$row->timeline_id][$row->reaction->value] = (int) $row->getAttribute('total'); + } + + /** @var array $mine */ + $mine = []; + + if ($userId !== null) { + $mine = UserReaction::query() + ->whereIn('timeline_id', $ids) + ->where('user_id', $userId) + ->get(['timeline_id', 'reaction']) + ->mapWithKeys(static fn (UserReaction $row): array => [$row->timeline_id => $row->reaction]) + ->all(); + } + + return Collection::make($ids)->mapWithKeys( + static fn (string $id): array => [$id => new TimelineReactionSummary( + timelineId: $id, + counts: $counts[$id] ?? [], + mine: $mine[$id] ?? null, + )], + ); + } +} diff --git a/app-modules/activity/tests/Feature/Reaction/ReactionSummaryTest.php b/app-modules/activity/tests/Feature/Reaction/ReactionSummaryTest.php new file mode 100644 index 00000000..99c7521b --- /dev/null +++ b/app-modules/activity/tests/Feature/Reaction/ReactionSummaryTest.php @@ -0,0 +1,164 @@ + $users + * @return EloquentCollection + */ +function seedReactedPosts(int $count, EloquentCollection $users): EloquentCollection +{ + $posts = Timeline::factory()->count($count)->create(); + $cases = TimelineReaction::cases(); + + foreach ($posts as $post) { + foreach ($users->values() as $index => $user) { + UserReaction::factory() + ->for($post) + ->for($user) + ->create(['reaction' => $cases[$index % count($cases)]]); + } + } + + return $posts; +} + +/** + * Conta as consultas executadas durante $callback. + */ +function countQueries(Closure $callback): int +{ + $queries = 0; + + DB::listen(static function () use (&$queries): void { + $queries++; + }); + + $callback(); + + return $queries; +} + +beforeEach(function (): void { + $this->me = User::factory()->create(); +}); + +test('devolve o breakdown por post só com contagens positivas e a minha reação', function (): void { + [$a, $b, $c] = Timeline::factory()->count(3)->create(); + [$alice, $bob, $carol] = User::factory()->count(3)->create(); + + // Post A: 👍 x2 (eu + alice), ❤️ x1 (bob) + UserReaction::factory()->for($a)->for($this->me)->create(['reaction' => TimelineReaction::Like]); + UserReaction::factory()->for($a)->for($alice)->create(['reaction' => TimelineReaction::Like]); + UserReaction::factory()->for($a)->for($bob)->create(['reaction' => TimelineReaction::Love]); + + // Post B: 🔥 x1 (carol) — eu não reagi + UserReaction::factory()->for($b)->for($carol)->create(['reaction' => TimelineReaction::Fire]); + + // Post C: sem reações + + $summary = new ReactionSummary()->forTimelines([$a->id, $b->id, $c->id], $this->me->id); + + expect($summary)->toHaveCount(3) + ->and($summary->keys()->all())->toEqualCanonicalizing([$a->id, $b->id, $c->id]) + ->and($summary->every(fn ($item): bool => $item instanceof TimelineReactionSummary))->toBeTrue(); + + expect($summary[$a->id]->timelineId)->toBe($a->id) + ->and($summary[$a->id]->counts)->toBe([ + TimelineReaction::Like->value => 2, + TimelineReaction::Love->value => 1, + ]) + ->and($summary[$a->id]->mine)->toBe(TimelineReaction::Like) + ->and($summary[$a->id]->total())->toBe(3) + ->and($summary[$a->id]->countOf(TimelineReaction::Like))->toBe(2) + ->and($summary[$a->id]->countOf(TimelineReaction::Sad))->toBe(0); + + expect($summary[$b->id]->counts)->toBe([TimelineReaction::Fire->value => 1]) + ->and($summary[$b->id]->mine)->toBeNull(); + + expect($summary[$c->id]->counts)->toBeEmpty() + ->and($summary[$c->id]->mine)->toBeNull() + ->and($summary[$c->id]->total())->toBe(0); +}); + +test('ignora reações de posts que não foram pedidos', function (): void { + [$wanted, $other] = Timeline::factory()->count(2)->create(); + + UserReaction::factory()->for($wanted)->for($this->me)->create(['reaction' => TimelineReaction::Laugh]); + UserReaction::factory()->for($other)->for($this->me)->create(['reaction' => TimelineReaction::Sad]); + + $summary = new ReactionSummary()->forTimelines([$wanted->id], $this->me->id); + + expect($summary)->toHaveCount(1) + ->and($summary->has($other->id))->toBeFalse() + ->and($summary[$wanted->id]->counts)->toBe([TimelineReaction::Laugh->value => 1]) + ->and($summary[$wanted->id]->mine)->toBe(TimelineReaction::Laugh); +}); + +test('sem userId, mine é sempre null e a segunda consulta não roda', function (): void { + $posts = seedReactedPosts(3, User::factory()->count(2)->create()); + + $queries = countQueries(function () use ($posts, &$summary): void { + $summary = new ReactionSummary()->forTimelines($posts->pluck('id'), userId: null); + }); + + expect($queries)->toBe(1) + ->and($summary)->toHaveCount(3) + ->and($summary->every(fn (TimelineReactionSummary $item): bool => !$item->mine instanceof TimelineReaction))->toBeTrue() + ->and($summary->every(fn (TimelineReactionSummary $item): bool => $item->total() === 2))->toBeTrue(); +}); + +test('com userId usa no máximo duas consultas', function (): void { + $posts = seedReactedPosts(3, User::factory()->count(2)->create()->push($this->me)); + + $queries = countQueries(function () use ($posts, &$summary): void { + $summary = new ReactionSummary()->forTimelines($posts->pluck('id'), $this->me->id); + }); + + expect($queries)->toBeLessThanOrEqual(2) + ->and($summary->every(fn (TimelineReactionSummary $item): bool => $item->mine instanceof TimelineReaction))->toBeTrue(); +}); + +test('o número de consultas não cresce com a quantidade de posts', function (): void { + $users = User::factory()->count(4)->create()->push($this->me); + + $few = seedReactedPosts(3, $users); + $many = seedReactedPosts(30, $users); + + $queriesForFew = countQueries(fn () => new ReactionSummary()->forTimelines($few->pluck('id'), $this->me->id)); + $queriesForMany = countQueries(fn () => new ReactionSummary()->forTimelines($many->pluck('id'), $this->me->id)); + + expect($queriesForFew)->toBeLessThanOrEqual(2) + ->and($queriesForMany)->toBe($queriesForFew); +}); + +test('lista vazia de ids devolve coleção vazia sem consultar o banco', function (): void { + $queries = countQueries(function () use (&$summary): void { + $summary = new ReactionSummary()->forTimelines([], $this->me->id); + }); + + expect($queries)->toBe(0) + ->and($summary)->toBeEmpty(); +}); + +test('ids repetidos são consolidados num único item', function (): void { + $post = Timeline::factory()->create(); + UserReaction::factory()->for($post)->for($this->me)->create(['reaction' => TimelineReaction::Celebrate]); + + $summary = new ReactionSummary()->forTimelines([$post->id, $post->id], $this->me->id); + + expect($summary)->toHaveCount(1) + ->and($summary[$post->id]->counts)->toBe([TimelineReaction::Celebrate->value => 1]); +}); From 79d50bf34b82d3039bb9ef5a5f02f1e2852255a2 Mon Sep 17 00:00:00 2001 From: Davi Castello Branco Tavares de Oliveira Date: Tue, 15 Sep 2026 23:28:44 -0400 Subject: [PATCH 2/3] refactor(activity): aplica review do ReactionSummary (#544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A agregação `group by` agora usa `toBase()`: a linha é um agregado, não um `UserReaction`, e hidratar o model produzia registros parciais (sem id) fáceis de confundir com linhas reais. O tipo do enum volta via `TimelineReaction::from()`, com narrowing explícito das colunas. - Helpers globais do teste ganham prefixo (`seedReactionSummaryPosts`, `reactionSummaryQueries`) para não colidir com outros arquivos Pest, que compartilham o mesmo processo. - A contagem de consultas usa o query log da conexão, com reset em `finally`, no lugar de um `DB::listen` que nunca era removido; o helper devolve `[$result, $count]` e elimina o `use (&$summary)`. - A asserção de `mine` nulo sem usuário lê como o critério de aceite (`pluck('mine')->filter()` vazio) sem disputar com o Rector. Co-Authored-By: Claude Fable 5.1 --- .../src/Reaction/Queries/ReactionSummary.php | 25 +++++++- .../Feature/Reaction/ReactionSummaryTest.php | 61 +++++++++++-------- 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/app-modules/activity/src/Reaction/Queries/ReactionSummary.php b/app-modules/activity/src/Reaction/Queries/ReactionSummary.php index f1e9e4b9..8c222f7e 100644 --- a/app-modules/activity/src/Reaction/Queries/ReactionSummary.php +++ b/app-modules/activity/src/Reaction/Queries/ReactionSummary.php @@ -8,6 +8,7 @@ use He4rt\Activity\Reaction\Enums\TimelineReaction; use He4rt\Activity\Reaction\Models\UserReaction; use Illuminate\Support\Collection; +use UnexpectedValueException; /** * Resume as reações de um conjunto de posts da timeline web em número fixo de @@ -36,7 +37,11 @@ public function forTimelines(iterable $timelineIds, ?string $userId): Collection /** @var array> $counts */ $counts = []; + // toBase(): a linha é um agregado, não um UserReaction — hidratar o model + // aqui produziria registros parciais (sem id) fáceis de confundir com + // linhas reais. $rows = UserReaction::query() + ->toBase() ->select(['timeline_id', 'reaction']) ->selectRaw('count(*) as total') ->whereIn('timeline_id', $ids) @@ -44,7 +49,9 @@ public function forTimelines(iterable $timelineIds, ?string $userId): Collection ->get(); foreach ($rows as $row) { - $counts[$row->timeline_id][$row->reaction->value] = (int) $row->getAttribute('total'); + $reaction = TimelineReaction::from($this->stringOf($row->reaction)); + + $counts[$this->stringOf($row->timeline_id)][$reaction->value] = $this->countOf($row->total); } /** @var array $mine */ @@ -67,4 +74,20 @@ public function forTimelines(iterable $timelineIds, ?string $userId): Collection )], ); } + + /** + * Linhas de agregado chegam sem tipo; as colunas lidas aqui são NOT NULL + * no schema, então qualquer outra coisa é um bug e não um caso a tratar. + */ + private function stringOf(mixed $value): string + { + return is_string($value) + ? $value + : throw new UnexpectedValueException('Esperava string na linha do agregado de reações.'); + } + + private function countOf(mixed $value): int + { + return is_numeric($value) ? (int) $value : 0; + } } diff --git a/app-modules/activity/tests/Feature/Reaction/ReactionSummaryTest.php b/app-modules/activity/tests/Feature/Reaction/ReactionSummaryTest.php index 99c7521b..8fa2b561 100644 --- a/app-modules/activity/tests/Feature/Reaction/ReactionSummaryTest.php +++ b/app-modules/activity/tests/Feature/Reaction/ReactionSummaryTest.php @@ -18,7 +18,7 @@ * @param EloquentCollection $users * @return EloquentCollection */ -function seedReactedPosts(int $count, EloquentCollection $users): EloquentCollection +function seedReactionSummaryPosts(int $count, EloquentCollection $users): EloquentCollection { $posts = Timeline::factory()->count($count)->create(); $cases = TimelineReaction::cases(); @@ -36,19 +36,28 @@ function seedReactedPosts(int $count, EloquentCollection $users): EloquentCollec } /** - * Conta as consultas executadas durante $callback. + * Executa $callback com o query log ligado e devolve o resultado dele junto + * com o número de consultas executadas. + * + * @template TResult + * + * @param Closure(): TResult $callback + * @return array{0: TResult, 1: int} */ -function countQueries(Closure $callback): int +function reactionSummaryQueries(Closure $callback): array { - $queries = 0; + $connection = DB::connection(); + $connection->flushQueryLog(); + $connection->enableQueryLog(); - DB::listen(static function () use (&$queries): void { - $queries++; - }); + try { + $result = $callback(); - $callback(); - - return $queries; + return [$result, count($connection->getQueryLog())]; + } finally { + $connection->disableQueryLog(); + $connection->flushQueryLog(); + } } beforeEach(function (): void { @@ -108,24 +117,24 @@ function countQueries(Closure $callback): int }); test('sem userId, mine é sempre null e a segunda consulta não roda', function (): void { - $posts = seedReactedPosts(3, User::factory()->count(2)->create()); + $posts = seedReactionSummaryPosts(3, User::factory()->count(2)->create()); - $queries = countQueries(function () use ($posts, &$summary): void { - $summary = new ReactionSummary()->forTimelines($posts->pluck('id'), userId: null); - }); + [$summary, $queries] = reactionSummaryQueries( + fn () => new ReactionSummary()->forTimelines($posts->pluck('id'), userId: null), + ); expect($queries)->toBe(1) ->and($summary)->toHaveCount(3) - ->and($summary->every(fn (TimelineReactionSummary $item): bool => !$item->mine instanceof TimelineReaction))->toBeTrue() + ->and($summary->pluck('mine')->filter())->toBeEmpty() ->and($summary->every(fn (TimelineReactionSummary $item): bool => $item->total() === 2))->toBeTrue(); }); test('com userId usa no máximo duas consultas', function (): void { - $posts = seedReactedPosts(3, User::factory()->count(2)->create()->push($this->me)); + $posts = seedReactionSummaryPosts(3, User::factory()->count(2)->create()->push($this->me)); - $queries = countQueries(function () use ($posts, &$summary): void { - $summary = new ReactionSummary()->forTimelines($posts->pluck('id'), $this->me->id); - }); + [$summary, $queries] = reactionSummaryQueries( + fn () => new ReactionSummary()->forTimelines($posts->pluck('id'), $this->me->id), + ); expect($queries)->toBeLessThanOrEqual(2) ->and($summary->every(fn (TimelineReactionSummary $item): bool => $item->mine instanceof TimelineReaction))->toBeTrue(); @@ -134,20 +143,20 @@ function countQueries(Closure $callback): int test('o número de consultas não cresce com a quantidade de posts', function (): void { $users = User::factory()->count(4)->create()->push($this->me); - $few = seedReactedPosts(3, $users); - $many = seedReactedPosts(30, $users); + $few = seedReactionSummaryPosts(3, $users); + $many = seedReactionSummaryPosts(30, $users); - $queriesForFew = countQueries(fn () => new ReactionSummary()->forTimelines($few->pluck('id'), $this->me->id)); - $queriesForMany = countQueries(fn () => new ReactionSummary()->forTimelines($many->pluck('id'), $this->me->id)); + [, $queriesForFew] = reactionSummaryQueries(fn () => new ReactionSummary()->forTimelines($few->pluck('id'), $this->me->id)); + [, $queriesForMany] = reactionSummaryQueries(fn () => new ReactionSummary()->forTimelines($many->pluck('id'), $this->me->id)); expect($queriesForFew)->toBeLessThanOrEqual(2) ->and($queriesForMany)->toBe($queriesForFew); }); test('lista vazia de ids devolve coleção vazia sem consultar o banco', function (): void { - $queries = countQueries(function () use (&$summary): void { - $summary = new ReactionSummary()->forTimelines([], $this->me->id); - }); + [$summary, $queries] = reactionSummaryQueries( + fn () => new ReactionSummary()->forTimelines([], $this->me->id), + ); expect($queries)->toBe(0) ->and($summary)->toBeEmpty(); From 3c21442deb8f5869c3a44452efb2d4335b7d1936 Mon Sep 17 00:00:00 2001 From: Davi Castello Branco Tavares de Oliveira Date: Wed, 16 Sep 2026 09:25:03 -0400 Subject: [PATCH 3/3] Refactor user reaction query in ReactionSummary Refactor query to map user reactions to timeline reactions. Signed-off-by: Davi Castello Branco Tavares de Oliveira --- .../activity/src/Reaction/Queries/ReactionSummary.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app-modules/activity/src/Reaction/Queries/ReactionSummary.php b/app-modules/activity/src/Reaction/Queries/ReactionSummary.php index 8c222f7e..aade01ee 100644 --- a/app-modules/activity/src/Reaction/Queries/ReactionSummary.php +++ b/app-modules/activity/src/Reaction/Queries/ReactionSummary.php @@ -59,10 +59,16 @@ public function forTimelines(iterable $timelineIds, ?string $userId): Collection if ($userId !== null) { $mine = UserReaction::query() + ->toBase() + ->select(['timeline_id', 'reaction']) ->whereIn('timeline_id', $ids) ->where('user_id', $userId) - ->get(['timeline_id', 'reaction']) - ->mapWithKeys(static fn (UserReaction $row): array => [$row->timeline_id => $row->reaction]) + ->get() + ->mapWithKeys(fn ($row): array => [ + $this->stringOf($row->timeline_id) => TimelineReaction::from( + $this->stringOf($row->reaction) + ), + ]) ->all(); }