ランキング収集機能の実装

ランキング収集機能を実装したのでご紹介。

何種類のランキングを保存したいか?
=> 多くても数十種類くらい
=> unsignedTinyIntegerの範囲(0~255)で保存できればよい
=> 実際には、1~255の範囲でランキングIDを割り当てる
上記の内容をコードで表現すると、以下のようになった。
<?php
declare(strict_types=1);
namespace Lulubell\Ranking\Entities;
final readonly class RankingId
{
private const int UINT8_MAX = 255;
public const int MIN = 1;
public const int MAX = self::UINT8_MAX;
public function __construct(
private int $value,
) {
if ($value < self::MIN) {
throw new \InvalidArgumentException('The provided value must be greater than or equal to '.self::MIN.'.');
}
if ($value > self::MAX) {
throw new \InvalidArgumentException('The provided value must be less than or equal to '.self::MAX.'.');
}
}
public function value(): int
{
return $this->value;
}
}

RankingIdクラスについて、次のように動作することをテストしたい。
・入力された値が1~255の範囲内であれば、正常に初期化されること
・下限値1を超えた値が入力されると、例外(InvalidArgumentException)をスローすること
・上限値255を超えた値が入力されると、例外(InvalidArgumentException)をスローすること
・valueメソッドが意図した値を返すこと
上記の内容をコードで表現すると、以下のようになった。
<?php
declare(strict_types=1);
namespace Lulubell\Ranking\Tests\Unit\Entities;
use Lulubell\Ranking\Entities\RankingId;
use PHPUnit\Framework\TestCase;
final class RankingIdTest extends TestCase
{
public function test_instantiate_with_value_at_min(): void
{
$this->assertInstanceOf(
RankingId::class,
new RankingId(RankingId::MIN),
);
}
public function test_instantiate_with_value_at_max(): void
{
$this->assertInstanceOf(
RankingId::class,
new RankingId(RankingId::MAX),
);
}
public function test_instantiate_with_value_less_than_min(): void
{
$this->expectException(\InvalidArgumentException::class);
new RankingId(RankingId::MIN - 1);
}
public function test_instantiate_with_value_greater_than_max(): void
{
$this->expectException(\InvalidArgumentException::class);
new RankingId(RankingId::MAX + 1);
}
public function test_value(): void
{
$this->assertSame(
1,
new RankingId(1)->value(),
);
}
}

ちなみに、ランキングIDを割り当ては以下のようにした。
<?php
declare(strict_types=1);
namespace App\Entities\Ranking;
use App\Repositories\Ranking\XxxxRankingRepository;
use App\Repositories\Ranking\YyyyRankingRepository;
use Lulubell\Ranking\Entities\RankingId;
enum RankingList: string
{
case XxxxRanking = XxxxRankingRepository::class;
case YyyyRanking = YyyyRankingRepository::class;
public function id(): RankingId
{
return new RankingId(match ($this) {
self::XxxxRanking => 1,
self::YyyyRanking => 2,
});
}
}

RankingRepositoryインターフェイスの実装クラスで
RankingList::from(self::class)->id()
あるいは
RankingList::from(static::class)->id()
のように利用する想定。

ランク入りした作品に対して、次のような事柄は最低限知っておきたい。
・何のランキング?
・順位は何位?
・何というタイトル?
・いつ収集した?
また、それ以外の事柄(作者やリンクなど)もオプションで保存できるようにしたい。
上記の内容をコードで表現すると、以下のようになった。
<?php
declare(strict_types=1);
namespace Lulubell\Ranking\Entities;
use Carbon\CarbonImmutable;
final readonly class RankedTitle
{
public function __construct(
private RankingId $rankingId,
private Rank $rank,
private string $title,
private CarbonImmutable $storedAt,
private ?int $id = null,
/** @var ?array<string, mixed> */
private ?array $params = null,
) {}
public function id(): ?int
{
return $this->id;
}
public function rankingId(): int
{
return $this->rankingId->value();
}
public function rank(): int
{
return $this->rank->value();
}
public function title(): string
{
return $this->title;
}
public function storedAt(): CarbonImmutable
{
return $this->storedAt;
}
/** @return ?array<string, mixed> */
public function params(): ?array
{
return $this->params;
}
/** @return array{id: ?int, ranking_id: int, rank: int, title: string, stored_at: string, params: ?array<string, mixed>} */
public function toArray(): array
{
return [
'id' => $this->id(),
'ranking_id' => $this->rankingId(),
'rank' => $this->rank(),
'title' => $this->title(),
'stored_at' => $this->storedAt()->toIso8601String(),
'params' => $this->params(),
];
}
}

RankedTitleクラスについて、次のように動作することをテストしたい。
・各メソッドが意図した値を返すこと
上記の内容をコードで表現すると、以下のようになった。
<?php
declare(strict_types=1);
namespace Lulubell\Ranking\Tests\Unit\Entities;
use Carbon\CarbonImmutable;
use Lulubell\Ranking\Entities\Rank;
use Lulubell\Ranking\Entities\RankedTitle;
use Lulubell\Ranking\Entities\RankingId;
use PHPUnit\Framework\TestCase;
final class RankedTitleTest extends TestCase
{
private RankingId $rankingId;
private CarbonImmutable $storedAt;
protected function setUp(): void
{
parent::setUp();
CarbonImmutable::setTestNow(CarbonImmutable::now());
$this->rankingId = new RankingId(1);
$this->storedAt = CarbonImmutable::now();
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
parent::tearDown();
}
public function test_values(): void
{
$rankedTitle = new RankedTitle(
rankingId: $this->rankingId,
rank: new Rank(1),
title: 'title_1',
storedAt: $this->storedAt,
id: 1,
params: [
'author' => 'author_1',
'link' => 'link_1',
],
);
$this->assertSame(
1,
$rankedTitle->id(),
);
$this->assertSame(
1,
$rankedTitle->rankingId(),
);
$this->assertSame(
1,
$rankedTitle->rank(),
);
$this->assertSame(
'title_1',
$rankedTitle->title(),
);
$this->assertEquals(
CarbonImmutable::now(),
$rankedTitle->storedAt(),
);
$this->assertSame(
[
'author' => 'author_1',
'link' => 'link_1',
],
$rankedTitle->params(),
);
}
public function test_to_array(): void
{
$this->assertSame(
[
'id' => null,
'ranking_id' => 1,
'rank' => 1,
'title' => 'title_1',
'stored_at' => CarbonImmutable::now()->toIso8601String(),
'params' => null,
],
new RankedTitle($this->rankingId, new Rank(1), 'title_1', $this->storedAt)->toArray(),
);
}
}

「順位」もクラスとして表現したほうがいいように思うので、
順番は前後してしまうが、このタイミングで実装する(RankedTitleとRankedTitleTestは対応済み)

最大で何位までの順位を保存したいか?
=> 最大で100位までにしたい
上記の内容をコードで表現すると、以下のようになった。
<?php
declare(strict_types=1);
namespace Lulubell\Ranking\Entities;
final readonly class Rank
{
public const int MIN = 1;
public const int MAX = 100;
public function __construct(
private int $value,
) {
if ($value < self::MIN) {
throw new \InvalidArgumentException('The provided value must be greater than or equal to '.self::MIN.'.');
}
if ($value > self::MAX) {
throw new \InvalidArgumentException('The provided value must be less than or equal to '.self::MAX.'.');
}
}
public function value(): int
{
return $this->value;
}
}

Rankクラスについて、次のように動作することをテストしたい。
・入力された値が1~100の範囲内であれば、正常に初期化されること
・下限値1を超えた値が入力されると、例外(DomainException)をスローすること
・上限値100を超えた値が入力されると、例外(DomainException)をスローすること
・valueメソッドが意図した値を返すこと
上記の内容をコードで表現すると、以下のようになった。
<?php
declare(strict_types=1);
namespace Lulubell\Ranking\Tests\Unit\Entities;
use Lulubell\Ranking\Entities\Rank;
use PHPUnit\Framework\TestCase;
final class RankTest extends TestCase
{
public function test_instantiate_with_value_at_min(): void
{
$this->assertInstanceOf(
Rank::class,
new Rank(Rank::MIN),
);
}
public function test_instantiate_with_value_at_max(): void
{
$this->assertInstanceOf(
Rank::class,
new Rank(Rank::MAX),
);
}
public function test_instantiate_with_value_less_than_min(): void
{
$this->expectException(\InvalidArgumentException::class);
new Rank(Rank::MIN - 1);
}
public function test_instantiate_with_value_greater_than_max(): void
{
$this->expectException(\InvalidArgumentException::class);
new Rank(Rank::MAX + 1);
}
public function test_value(): void
{
$this->assertSame(
1,
new Rank(1)->value(),
);
}
}

<?php
declare(strict_types=1);
namespace Lulubell\Ranking\Entities;
final readonly class RankedTitleList
{
private const int MIN_COUNT = 1;
private const int MAX_COUNT = Rank::MAX - Rank::MIN + 1;
/** @var list<RankedTitle> */
private array $rankedTitles;
private int $count;
/** @no-named-arguments */
public function __construct(
RankedTitle ...$rankedTitles,
) {
$this->rankedTitles = $rankedTitles;
$this->count = count($rankedTitles);
if ($this->count < self::MIN_COUNT) {
throw new \LengthException('The number of ranked titles provided must be greater than or equal to '.self::MIN_COUNT.'.');
}
if ($this->count > self::MAX_COUNT) {
throw new \LengthException('The number of ranked titles provided must be less than or equal to '.self::MAX_COUNT.'.');
}
if (! $this->hasSameRankingId()) {
throw new \DomainException('The provided ranked titles must have the same ranking ID.');
}
if (! $this->hasSequentialRanks()) {
throw new \DomainException('The provided ranked titles must have sequential ranks.');
}
if (! $this->hasUniqueTitles()) {
throw new \DomainException('The provided ranked titles must have unique titles.');
}
if (! $this->hasSameStoredAt()) {
throw new \DomainException('The provided ranked titles must have the same storedAt value.');
}
}
/** @return list<RankedTitle> */
public function all(): array
{
return $this->rankedTitles;
}
/** @return list<array{id: ?int, ranking_id: int, rank: int, title: string, stored_at: string, params: ?array<string, mixed>}> */
public function toArray(): array
{
return array_map(
fn (RankedTitle $rankedTitle) => $rankedTitle->toArray(),
$this->rankedTitles,
);
}
public function count(): int
{
return $this->count;
}
private function hasSameRankingId(): bool
{
return count(array_unique(array_map(
fn (RankedTitle $rankedTitle) => $rankedTitle->rankingId(),
$this->rankedTitles,
))) === 1;
}
private function hasSequentialRanks(): bool
{
return ($ranks = array_map(
fn (RankedTitle $rankedTitle) => $rankedTitle->rank(),
$this->rankedTitles,
)) === range(Rank::MIN, max(Rank::MIN, ...$ranks));
}
private function hasUniqueTitles(): bool
{
return count(array_unique(array_map(
fn (RankedTitle $rankedTitle) => $rankedTitle->title(),
$this->rankedTitles,
))) === $this->count;
}
private function hasSameStoredAt(): bool
{
return count(array_unique(array_map(
fn (RankedTitle $rankedTitle) => $rankedTitle->storedAt(),
$this->rankedTitles,
))) === 1;
}
}

<?php
declare(strict_types=1);
namespace Lulubell\Ranking\Tests\Unit\Entities;
use Carbon\CarbonImmutable;
use Lulubell\Ranking\Entities\Rank;
use Lulubell\Ranking\Entities\RankedTitle;
use Lulubell\Ranking\Entities\RankedTitleList;
use Lulubell\Ranking\Entities\RankingId;
use PHPUnit\Framework\TestCase;
final class RankedTitleListTest extends TestCase
{
private RankingId $rankingId;
private CarbonImmutable $storedAt;
private array $rankedTitles;
protected function setUp(): void
{
parent::setUp();
CarbonImmutable::setTestNow(CarbonImmutable::now());
$this->rankingId = new RankingId(1);
$this->storedAt = CarbonImmutable::now();
$this->rankedTitles = [
new RankedTitle($this->rankingId, new Rank(1), 'title_1', $this->storedAt),
new RankedTitle($this->rankingId, new Rank(2), 'title_2', $this->storedAt),
];
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
parent::tearDown();
}
public function test_instantiate_with_ranked_titles_at_min_count(): void
{
$this->assertInstanceOf(
RankedTitleList::class,
new RankedTitleList(
new RankedTitle($this->rankingId, new Rank(Rank::MIN), 'title_'.Rank::MIN, $this->storedAt),
),
);
}
public function test_instantiate_with_ranked_titles_at_max_count(): void
{
$this->assertInstanceOf(
RankedTitleList::class,
new RankedTitleList(
...array_map(
fn (int $i) => new RankedTitle($this->rankingId, new Rank($i), "title_{$i}", $this->storedAt),
range(Rank::MIN, Rank::MAX),
),
),
);
}
public function test_instantiate_with_ranked_titles_less_than_min_count(): void
{
$this->expectException(\LengthException::class);
new RankedTitleList;
}
public function test_instantiate_with_ranked_titles_greater_than_max_count(): void
{
$this->expectException(\LengthException::class);
new RankedTitleList(
...array_map(
fn (int $i) => new RankedTitle($this->rankingId, new Rank(Rank::MIN), 'title_'.Rank::MIN, $this->storedAt),
range(Rank::MIN, Rank::MAX + 1),
),
);
}
public function test_instantiate_with_ranked_titles_having_non_same_ranking_id(): void
{
$this->expectException(\DomainException::class);
new RankedTitleList(
new RankedTitle(new RankingId(1), new Rank(1), 'title_1', $this->storedAt),
new RankedTitle(new RankingId(2), new Rank(2), 'title_2', $this->storedAt),
);
}
public function test_instantiate_with_ranked_titles_having_non_sequential_ranks(): void
{
$this->expectException(\DomainException::class);
new RankedTitleList(
new RankedTitle($this->rankingId, new Rank(1), 'title_1', $this->storedAt),
new RankedTitle($this->rankingId, new Rank(3), 'title_3', $this->storedAt),
);
}
public function test_instantiate_with_ranked_titles_having_non_unique_titles(): void
{
$this->expectException(\DomainException::class);
new RankedTitleList(
new RankedTitle($this->rankingId, new Rank(1), 'title', $this->storedAt),
new RankedTitle($this->rankingId, new Rank(2), 'title', $this->storedAt),
);
}
public function test_instantiate_with_ranked_titles_having_non_same_stored_at(): void
{
$this->expectException(\DomainException::class);
new RankedTitleList(
new RankedTitle($this->rankingId, new Rank(1), 'title_1', $this->storedAt),
new RankedTitle($this->rankingId, new Rank(2), 'title_2', $this->storedAt->addSeconds()),
);
}
public function test_all(): void
{
$this->assertSame(
$this->rankedTitles,
new RankedTitleList(...$this->rankedTitles)->all(),
);
}
public function test_to_array(): void
{
$this->assertSame(
[
[
'id' => null,
'ranking_id' => 1,
'rank' => 1,
'title' => 'title_1',
'stored_at' => CarbonImmutable::now()->toIso8601String(),
'params' => null,
],
[
'id' => null,
'ranking_id' => 1,
'rank' => 2,
'title' => 'title_2',
'stored_at' => CarbonImmutable::now()->toIso8601String(),
'params' => null,
],
],
new RankedTitleList(...$this->rankedTitles)->toArray(),
);
}
public function test_count(): void
{
$this->assertSame(
count($this->rankedTitles),
new RankedTitleList(...$this->rankedTitles)->count(),
);
}
}

ランキング収集(store)には、ランキングの取得(getAll)と保存(add)が必要。
取得と保存でデータソースが異なるので、Repositoryのインターフェースも分けておく。
上記の内容をコードで表現すると、以下のようになった。
<?php
declare(strict_types=1);
namespace Lulubell\Ranking\Repositories;
use Lulubell\Ranking\Entities\RankedTitleList;
interface RankingRepository
{
public function getAll(): RankedTitleList;
}
<?php
declare(strict_types=1);
namespace Lulubell\Ranking\Repositories;
use Lulubell\Ranking\Entities\RankedTitle;
interface RankedTitleRepository
{
public function add(RankedTitle ...$rankedTitles): void;
}

RankingRepositoryの実装としてXxxxRankingRepositoryを作成したり、
RankedTitleRepositoryの実装としてRankedTitleEloquentRepositoryを作成したりする。

RankingServiceクラスでは、RankingRepositoryのgetAllを実行して、
戻り値をそのまま返す。
<?php
declare(strict_types=1);
namespace Lulubell\Ranking\Services;
use Lulubell\Ranking\Entities\RankedTitleList;
use Lulubell\Ranking\Repositories\RankingRepository;
final readonly class RankingService
{
public function __construct(
private RankingRepository $rankingRepository,
) {}
public function getAll(): RankedTitleList
{
return $this->rankingRepository->getAll();
}
}

RankingServiceクラスのgetAllについて、次のように動作することをテストしたい。
・RankingRepositoryのgetAllを一度だけ実行すること
・RankingRepositoryのgetAllの戻り値をそのまま返すこと
上記の内容をコードで表現すると、以下のようになった。
<?php
declare(strict_types=1);
namespace Lulubell\Ranking\Tests\Unit\Services;
use Carbon\CarbonImmutable;
use Lulubell\Ranking\Entities\Rank;
use Lulubell\Ranking\Entities\RankedTitle;
use Lulubell\Ranking\Entities\RankedTitleList;
use Lulubell\Ranking\Entities\RankingId;
use Lulubell\Ranking\Repositories\RankingRepository;
use Lulubell\Ranking\Services\RankingService;
use PHPUnit\Framework\TestCase;
final class RankingServiceTest extends TestCase
{
private RankedTitleList $rankedTitleList;
protected function setUp(): void
{
parent::setUp();
$rankingId = new RankingId(1);
$storedAt = CarbonImmutable::now();
$this->rankedTitleList = new RankedTitleList(
new RankedTitle($rankingId, new Rank(1), 'title_1', $storedAt),
new RankedTitle($rankingId, new Rank(2), 'title_2', $storedAt),
);
}
public function test_get_all(): void
{
$rankingRepositoryMock = $this->createMock(RankingRepository::class);
$rankingRepositoryMock->expects($this->once())
->method('getAll')
->willReturn($this->rankedTitleList);
$this->assertSame(
$this->rankedTitleList,
new RankingService($rankingRepositoryMock)->getAll(),
);
}
}

<?php
declare(strict_types=1);
namespace Lulubell\Ranking\Services;
use Lulubell\Ranking\Entities\RankedTitleList;
use Lulubell\Ranking\Repositories\RankedTitleRepository;
final readonly class RankedTitleService
{
public function __construct(
private RankedTitleRepository $rankedTitleRepository,
) {}
public function add(RankedTitleList $rankedTitleList): void
{
$this->rankedTitleRepository->add(...$rankedTitleList->all());
}
}

<?php
declare(strict_types=1);
namespace Lulubell\Ranking\Tests\Unit\Services;
use Carbon\CarbonImmutable;
use Lulubell\Ranking\Entities\Rank;
use Lulubell\Ranking\Entities\RankedTitle;
use Lulubell\Ranking\Entities\RankedTitleList;
use Lulubell\Ranking\Entities\RankingId;
use Lulubell\Ranking\Repositories\RankedTitleRepository;
use Lulubell\Ranking\Services\RankedTitleService;
use PHPUnit\Framework\TestCase;
final class RankedTitleServiceTest extends TestCase
{
private RankedTitleList $rankedTitleList;
protected function setUp(): void
{
parent::setUp();
$rankingId = new RankingId(1);
$storedAt = CarbonImmutable::now();
$this->rankedTitleList = new RankedTitleList(
new RankedTitle($rankingId, new Rank(1), 'title_1', $storedAt),
new RankedTitle($rankingId, new Rank(2), 'title_2', $storedAt),
);
}
public function test_add(): void
{
$rankedTitleRepositoryMock = $this->createMock(RankedTitleRepository::class);
$rankedTitleRepositoryMock->expects($this->once())
->method('add');
new RankedTitleService($rankedTitleRepositoryMock)->add($this->rankedTitleList);
}
}

ここまでの(主にPHPに依存した)Lulubell\Rankingモジュールを利用して、
ここからはLaravelに依存したLulubell\LaravelRankingモジュールを実装する。

<?php
declare(strict_types=1);
namespace Lulubell\LaravelRanking\Repositories;
use Lulubell\LaravelRanking\Models\RankedTitle;
use Lulubell\Ranking\Entities\RankedTitle as RankedTitleEntity;
use Lulubell\Ranking\Repositories\RankedTitleRepository;
final class RankedTitleEloquentRepository implements RankedTitleRepository
{
private const int CHUNK_SIZE = 1000;
public function add(RankedTitleEntity ...$rankedTitles): void
{
$rankedTitles = array_map(fn (RankedTitleEntity $rankedTitle) => [
...$rankedTitle->toArray(),
'params' => json_encode($rankedTitle->params(), JSON_THROW_ON_ERROR),
], $rankedTitles);
foreach (array_chunk($rankedTitles, self::CHUNK_SIZE) as $chunk) {
RankedTitle::query()->upsert($chunk, 'id');
}
}
}

<?php
declare(strict_types=1);
use Carbon\CarbonImmutable;
use Lulubell\LaravelRanking\Repositories\RankedTitleEloquentRepository;
use Lulubell\LaravelRanking\Tests\TestCase;
use Lulubell\Ranking\Entities\Rank;
use Lulubell\Ranking\Entities\RankedTitle;
use Lulubell\Ranking\Entities\RankingId;
final class RankedTitleEloquentRepositoryTest extends TestCase
{
private RankedTitleEloquentRepository $rankedTitleRepository;
private RankingId $rankingId;
private CarbonImmutable $storedAt;
private array $rankedTitles;
protected function setUp(): void
{
parent::setUp();
$this->rankedTitleRepository = new RankedTitleEloquentRepository;
$this->rankingId = new RankingId(1);
$this->storedAt = CarbonImmutable::now();
$this->rankedTitles = [
new RankedTitle($this->rankingId, new Rank(1), 'title_1', $this->storedAt),
new RankedTitle($this->rankingId, new Rank(2), 'title_2', $this->storedAt),
];
}
public function test_add(): void
{
$this->rankedTitleRepository->add(...$this->rankedTitles);
$this->assertDatabaseCount(
'ranked_titles',
count($this->rankedTitles),
);
}
public function test_add_with_no_ranked_titles(): void
{
$this->rankedTitleRepository->add();
$this->assertDatabaseEmpty('ranked_titles');
}
public function test_add_with_many_ranked_titles(): void
{
$rankedTitles = array_map(
fn (int $i) => new RankedTitle($this->rankingId, new Rank(Rank::MIN), 'title_'.Rank::MIN, $this->storedAt),
range(1, 100000),
);
$this->rankedTitleRepository->add(...$rankedTitles);
$this->assertDatabaseCount(
'ranked_titles',
count($rankedTitles),
);
}
}

<?php
declare(strict_types=1);
namespace Lulubell\LaravelRanking\UseCases;
interface StoreRankingUseCase
{
public function handle(): void;
}

<?php
declare(strict_types=1);
namespace Lulubell\LaravelRanking\UseCases;
use Illuminate\Support\Facades\DB;
use Lulubell\Ranking\Services\RankedTitleService;
use Lulubell\Ranking\Services\RankingService;
abstract readonly class StoreRankingInteractor implements StoreRankingUseCase
{
public function __construct(
private RankingService $rankingService,
private RankedTitleService $rankedTitleService,
) {}
public function handle(): void
{
$rankedTitleList = $this->rankingService->getAll();
DB::transaction(function () use ($rankedTitleList) {
$this->rankedTitleService->add($rankedTitleList);
});
}
}