-
Notifications
You must be signed in to change notification settings - Fork 49
feat(activity): S5 — query object ReactionSummary (#544) #563
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
davicbtoliveira
wants to merge
3
commits into
feat/timeline-reaction
Choose a base branch
from
feat/reaction-summary
base: feat/timeline-reaction
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+305
−0
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
33 changes: 33 additions & 0 deletions
33
app-modules/activity/src/Reaction/DTOs/TimelineReactionSummary.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace He4rt\Activity\Reaction\DTOs; | ||
|
|
||
| use He4rt\Activity\Reaction\Enums\TimelineReaction; | ||
|
|
||
| /** | ||
| * Breakdown das reações de um único post da timeline web. | ||
| * | ||
| * `counts` é indexado pelo value de TimelineReaction e traz só reações com | ||
| * contagem > 0; `mine` é a reação do usuário consultado naquele post, ou null. | ||
| */ | ||
| final readonly class TimelineReactionSummary | ||
| { | ||
| /** @param array<string, int> $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; | ||
| } | ||
| } |
99 changes: 99 additions & 0 deletions
99
app-modules/activity/src/Reaction/Queries/ReactionSummary.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace He4rt\Activity\Reaction\Queries; | ||
|
|
||
| use He4rt\Activity\Reaction\DTOs\TimelineReactionSummary; | ||
| 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 | ||
| * consultas: uma agregação `group by (timeline_id, reaction)` para as | ||
| * contagens e, quando há usuário, uma leitura das linhas dele nos mesmos | ||
| * posts. O custo não cresce com o tamanho da página. | ||
| * | ||
| * Lê só `activity_user_reactions`; o agregado do Discord (`activity_reactions`) | ||
| * nunca entra na soma. | ||
| */ | ||
| final readonly class ReactionSummary | ||
| { | ||
| /** | ||
| * @param iterable<array-key, string> $timelineIds | ||
| * @return Collection<string, TimelineReactionSummary> indexada por `timeline_id`, com um item para cada id pedido | ||
| */ | ||
| public function forTimelines(iterable $timelineIds, ?string $userId): Collection | ||
| { | ||
| /** @var list<string> $ids */ | ||
| $ids = Collection::make($timelineIds)->unique()->values()->all(); | ||
|
|
||
| if ($ids === []) { | ||
| return Collection::make(); | ||
| } | ||
|
|
||
| /** @var array<string, array<string, int>> $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) | ||
| ->groupBy('timeline_id', 'reaction') | ||
| ->get(); | ||
|
|
||
| foreach ($rows as $row) { | ||
| $reaction = TimelineReaction::from($this->stringOf($row->reaction)); | ||
|
|
||
| $counts[$this->stringOf($row->timeline_id)][$reaction->value] = $this->countOf($row->total); | ||
| } | ||
|
|
||
| /** @var array<string, TimelineReaction> $mine */ | ||
| $mine = []; | ||
|
|
||
| if ($userId !== null) { | ||
| $mine = UserReaction::query() | ||
| ->toBase() | ||
| ->select(['timeline_id', 'reaction']) | ||
| ->whereIn('timeline_id', $ids) | ||
| ->where('user_id', $userId) | ||
| ->get() | ||
| ->mapWithKeys(fn ($row): array => [ | ||
| $this->stringOf($row->timeline_id) => TimelineReaction::from( | ||
| $this->stringOf($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, | ||
| )], | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * 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; | ||
| } | ||
| } | ||
173 changes: 173 additions & 0 deletions
173
app-modules/activity/tests/Feature/Reaction/ReactionSummaryTest.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use He4rt\Activity\Reaction\DTOs\TimelineReactionSummary; | ||
| use He4rt\Activity\Reaction\Enums\TimelineReaction; | ||
| use He4rt\Activity\Reaction\Models\UserReaction; | ||
| use He4rt\Activity\Reaction\Queries\ReactionSummary; | ||
| use He4rt\Activity\Timeline\Timeline; | ||
| use He4rt\Identity\User\Models\User; | ||
| use Illuminate\Database\Eloquent\Collection as EloquentCollection; | ||
| use Illuminate\Support\Facades\DB; | ||
|
|
||
| /** | ||
| * Cria $count posts e, em cada um, uma reação de cada usuário informado, | ||
| * ciclando pelos cases do enum. Devolve a coleção de posts. | ||
| * | ||
| * @param EloquentCollection<int, User> $users | ||
| * @return EloquentCollection<int, Timeline> | ||
| */ | ||
| function seedReactionSummaryPosts(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; | ||
| } | ||
|
|
||
| /** | ||
| * 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 reactionSummaryQueries(Closure $callback): array | ||
| { | ||
| $connection = DB::connection(); | ||
| $connection->flushQueryLog(); | ||
| $connection->enableQueryLog(); | ||
|
|
||
| try { | ||
| $result = $callback(); | ||
|
|
||
| return [$result, count($connection->getQueryLog())]; | ||
| } finally { | ||
| $connection->disableQueryLog(); | ||
| $connection->flushQueryLog(); | ||
| } | ||
| } | ||
|
|
||
| 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 = seedReactionSummaryPosts(3, User::factory()->count(2)->create()); | ||
|
|
||
| [$summary, $queries] = reactionSummaryQueries( | ||
| fn () => new ReactionSummary()->forTimelines($posts->pluck('id'), userId: null), | ||
| ); | ||
|
|
||
| expect($queries)->toBe(1) | ||
| ->and($summary)->toHaveCount(3) | ||
| ->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 = seedReactionSummaryPosts(3, User::factory()->count(2)->create()->push($this->me)); | ||
|
|
||
| [$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(); | ||
| }); | ||
|
|
||
| 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 = seedReactionSummaryPosts(3, $users); | ||
| $many = seedReactionSummaryPosts(30, $users); | ||
|
|
||
| [, $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 { | ||
| [$summary, $queries] = reactionSummaryQueries( | ||
| fn () => 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]); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.