diff --git a/app/Anime.php b/app/Anime.php index 7034aea..a03c6ab 100644 --- a/app/Anime.php +++ b/app/Anime.php @@ -133,6 +133,16 @@ class Anime extends JikanApiSearchableModel ]; } + public function getThemesAttribute() + { + $result = []; + if (array_key_exists("themes", $this->attributes)) { + $result = $this->attributes["themes"]; + } + + return $result; + } + /** @noinspection PhpUnused */ public function filterByType(\Laravel\Scout\Builder|\Illuminate\Database\Eloquent\Builder $query, AnimeTypeEnum $value): \Laravel\Scout\Builder|\Illuminate\Database\Eloquent\Builder { diff --git a/app/Contracts/Repository.php b/app/Contracts/Repository.php index cc2fe8f..d198cc0 100644 --- a/app/Contracts/Repository.php +++ b/app/Contracts/Repository.php @@ -20,7 +20,7 @@ interface Repository extends RepositoryQuery /** * @return ?T */ - public function getByMalId(int $id); + public function getByMalId(int $id): JikanApiModel|array|null; public function getAllByMalId(int $id): Collection; diff --git a/app/Macros/ResponseJikanCacheFlags.php b/app/Macros/ResponseJikanCacheFlags.php index 1851fe7..ec62fe8 100644 --- a/app/Macros/ResponseJikanCacheFlags.php +++ b/app/Macros/ResponseJikanCacheFlags.php @@ -22,7 +22,7 @@ final class ResponseJikanCacheFlags ->header("X-Request-Fingerprint", $cacheKey) ->setTtl(app(CacheOptions::class)->ttl()) ->setExpires(Carbon::createFromTimestamp($scraperResults->expiry())) - ->setLastModified(Carbon::createFromTimestamp($scraperResults->lastModified())); + ->setLastModified(Carbon::createFromTimestamp($scraperResults->lastModified() ?? 0)); }; } } diff --git a/app/Repositories/DatabaseRepository.php b/app/Repositories/DatabaseRepository.php index a894e47..84aa2f4 100644 --- a/app/Repositories/DatabaseRepository.php +++ b/app/Repositories/DatabaseRepository.php @@ -3,6 +3,7 @@ namespace App\Repositories; use App\Contracts\Repository; +use App\JikanApiModel; use App\Support\RepositoryQuery; use Illuminate\Contracts\Database\Query\Builder; use Illuminate\Support\Collection; @@ -17,7 +18,7 @@ class DatabaseRepository extends RepositoryQuery implements Repository return $this->queryable()->newModelInstance(); } - public function getByMalId(int $id) + public function getByMalId(int $id): JikanApiModel|array|null { $results = $this->getAllByMalId($id); diff --git a/app/Services/DefaultCachedScraperService.php b/app/Services/DefaultCachedScraperService.php index 3ea7986..1a31709 100644 --- a/app/Services/DefaultCachedScraperService.php +++ b/app/Services/DefaultCachedScraperService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Contracts\CachedScraperService; use App\Contracts\Repository; use App\Http\HttpHelper; +use App\JikanApiModel; use App\Support\CachedData; use Illuminate\Contracts\Database\Query\Builder; use Illuminate\Support\Carbon; @@ -60,7 +61,8 @@ final class DefaultCachedScraperService implements CachedScraperService */ public function find(int $id, string $cacheKey): CachedData { - $results = CachedData::from($this->repository->getAllByMalId($id)); + $dbResults = $this->repository->getAllByMalId($id); + $results = $this->dbResultSetToCachedData($dbResults); if ($results->isEmpty() || $results->isExpired()) { $response = $this->repository->scrape($id); @@ -77,7 +79,8 @@ final class DefaultCachedScraperService implements CachedScraperService public function findByKey(string $key, mixed $val, string $cacheKey): CachedData { - $results = CachedData::from($this->repository->where($key, $val)->get()); + $dbResults = $this->repository->where($key, $val)->get(); + $results = $this->dbResultSetToCachedData($dbResults); if ($results->isEmpty() || $results->isExpired()) { $scraperResponse = $this->repository->scrape($key); @@ -85,17 +88,18 @@ final class DefaultCachedScraperService implements CachedScraperService $this->raiseNotFoundIfErrors($scraperResponse); $response = $this->prepareScraperResponse($cacheKey, $results->isEmpty(), $scraperResponse); - $response->offsetSet($key, $val); + $response[$key] = $val; if ($results->isEmpty()) { - $this->repository->insert($response->toArray()); + $this->repository->insert($response); } if ($results->isExpired()) { - $this->repository->where($key, $val)->update($response->toArray()); + $this->repository->where($key, $val)->update($response); } - $results = CachedData::from($this->repository->where($key, $val)->get()); + $dbResults = $this->repository->where($key, $val)->get(); + $results = $this->dbResultSetToCachedData($dbResults); } $this->raiseNotFoundIfEmpty($results); @@ -105,7 +109,30 @@ final class DefaultCachedScraperService implements CachedScraperService public function get(string $cacheKey): CachedData { - return CachedData::from($this->getByCacheKey($cacheKey)); + $dbResults = $this->getByCacheKey($cacheKey); + return $this->dbResultSetToCachedData($dbResults); + } + + private function dbResultSetToCachedData(Collection $dbResults): CachedData + { + if (!$dbResults->isEmpty()) { + $item = $dbResults->first(); + return $this->dbRecordToCachedData($item); + } + else { + $item = collect(); + } + + return CachedData::from($item); + } + + private function dbRecordToCachedData(JikanApiModel|array $item): CachedData + { + if ($item instanceof JikanApiModel) { + return CachedData::fromModel($item); + } + + return CachedData::fromArray($item); } private function raiseNotFoundIfEmpty(CachedData $results) @@ -127,14 +154,19 @@ final class DefaultCachedScraperService implements CachedScraperService $response = $this->prepareScraperResponse($cacheKey, $results->isEmpty(), $scraperResponse); if ($results->isEmpty()) { - $this->repository->insert($response->toArray()); + $this->repository->insert($response); } if ($results->isExpired()) { - $this->repository->queryByMalId($id)->update($response->toArray()); + $this->repository->queryByMalId($id)->update($response); } - return CachedData::from(collect($this->repository->getAllByMalId($id))); + $dbResult = $this->repository->getByMalId($id); + if ($dbResult === null) { + return CachedData::from(collect()); + } + + return $this->dbRecordToCachedData($dbResult); } private function updateCacheByKey(string $cacheKey, CachedData $results, array $scraperResponse): CachedData @@ -143,15 +175,15 @@ final class DefaultCachedScraperService implements CachedScraperService // insert cache if resource doesn't exist if ($results->isEmpty()) { - $this->repository->insert($response->toArray()); + $this->repository->insert($response); } else if ($results->isExpired()) { - $this->getQueryableByCacheKey($cacheKey)->update($response->toArray()); + $this->getQueryableByCacheKey($cacheKey)->update($response); } - return CachedData::from($this->getByCacheKey($cacheKey)); + return $this->get($cacheKey); } - private function prepareScraperResponse(string $cacheKey, bool $resultsEmpty, array $scraperResponse): CachedData + private function prepareScraperResponse(string $cacheKey, bool $resultsEmpty, array $scraperResponse): array { $meta = []; if ($resultsEmpty) { @@ -167,7 +199,7 @@ final class DefaultCachedScraperService implements CachedScraperService $meta['modifiedAt'] = new UTCDateTime(Carbon::now()->getPreciseTimestamp(3)); // join meta data with response - return CachedData::from(collect($meta + $scraperResponse)); + return $meta + $scraperResponse; } private function getByCacheKey(string $cacheKey): Collection diff --git a/app/Support/CachedData.php b/app/Support/CachedData.php index 9fe6e6a..f2eb1e7 100644 --- a/app/Support/CachedData.php +++ b/app/Support/CachedData.php @@ -26,6 +26,16 @@ final class CachedData return new self($scraperResult, app(CacheOptions::class)->ttl()); } + public static function fromArray(array $scraperResult): self + { + return self::from(collect($scraperResult)); + } + + public static function fromModel(JikanApiModel $model): self + { + return self::fromArray($model->toArray()); + } + public function collect(): Collection { return $this->scraperResult; @@ -82,20 +92,12 @@ final class CachedData return null; } - $result = $this->scraperResult->first(); + $result = $this->scraperResult; - if ($result instanceof JikanApiModel && null != $modifiedAt = $result->getAttributeValue("modifiedAt")) { + if (null !== $modifiedAt = $result->get("modifiedAt")) { return $this->mixedToTimestamp($modifiedAt); } - if (is_array($result) && array_key_exists("modifiedAt", $result)) { - return $this->mixedToTimestamp($result["modifiedAt"]); - } - - if (is_object($result) && property_exists($result, "modifiedAt")) { - return $this->mixedToTimestamp($result->modifiedAt); - } - return null; } diff --git a/tests/Unit/DefaultCachedScraperServiceTest.php b/tests/Unit/DefaultCachedScraperServiceTest.php index fe7e63d..0c06bb9 100644 --- a/tests/Unit/DefaultCachedScraperServiceTest.php +++ b/tests/Unit/DefaultCachedScraperServiceTest.php @@ -48,11 +48,18 @@ final class DefaultCachedScraperServiceTest extends TestCase public function testIfFindListReturnsNotExpiredItems() { $testRequestHash = $this->requestHash(); + $now = Carbon::now(); + Carbon::setTestNow($now); // the cached data in the database - $dummyResults = collect([ - ["dummy" => "dummy1", "modifiedAt" => new UTCDateTime()], - ["dummy" => "dummy2", "modifiedAt" => new UTCDateTime()] - ]); + // this should be an array of arrays as builder->get() returns multiple items + $dummyResults = collect([[ + "request_hash" => $testRequestHash, + "modifiedAt" => new UTCDateTime($now->getPreciseTimestamp(3)), + "results" => [ + ["dummy" => "dummy1"], + ["dummy" => "dummy2"] + ] + ]]); [$queryBuilderMock, $repositoryMock, $serializerMock] = $this->makeCtorArgMocks(); $queryBuilderMock->expects()->get()->once()->andReturn($dummyResults); @@ -60,7 +67,7 @@ final class DefaultCachedScraperServiceTest extends TestCase $result = $target->findList($testRequestHash, fn() => []); - $this->assertEquals($dummyResults->toArray(), $result->toArray()); + $this->assertEquals($dummyResults->first(), $result->toArray()); } public function testIfFindListUpdatesCacheIfItemsExpired() @@ -70,10 +77,15 @@ final class DefaultCachedScraperServiceTest extends TestCase Carbon::setTestNow($now); // the cached data in the database - $dummyResults = collect([ - ["dummy" => "dummy1", "modifiedAt" => new UTCDateTime($now->sub("2 days")->getPreciseTimestamp(3))], - ["dummy" => "dummy2", "modifiedAt" => new UTCDateTime($now->sub("2 days")->getPreciseTimestamp(3))] - ]); + // this should be an array of arrays as builder->get() returns multiple items + $dummyResults = collect([[ + "request_hash" => $testRequestHash, + "modifiedAt" => new UTCDateTime($now->sub("2 days")->getPreciseTimestamp(3)), + "results" => [ + ["dummy" => "dummy1"], + ["dummy" => "dummy2"] + ] + ]]); // the data returned by the scraper $scraperData = [ @@ -83,8 +95,16 @@ final class DefaultCachedScraperServiceTest extends TestCase ] ]; [$queryBuilderMock, $repositoryMock, $serializerMock] = $this->makeCtorArgMocks(3); - $queryBuilderMock->expects()->get()->twice()->andReturn($dummyResults); - $queryBuilderMock->expects()->update(Mockery::any())->once()->andReturn(1); + $queryBuilderMock->expects()->update(Mockery::capture($updatedData))->once()->andReturn(1); + $queryBuilderMock->shouldReceive("get")->twice()->andReturnUsing( + fn () => $dummyResults, + function () use (&$updatedData) { + // builder->get() returns multiple items + return collect([ + $updatedData + ]); + } + ); $serializerMock->allows([ "toArray" => $scraperData @@ -94,8 +114,11 @@ final class DefaultCachedScraperServiceTest extends TestCase $result = $target->findList($testRequestHash, fn() => []); $this->assertEquals([ - ["dummy" => "dummy1", "modifiedAt" => $dummyResults->toArray()[0]["modifiedAt"]], - ["dummy" => "dummy2", "modifiedAt" => $dummyResults->toArray()[1]["modifiedAt"]] + "modifiedAt" => new UTCDateTime($now->getPreciseTimestamp(3)), + "results" => [ + ["dummy" => "dummy1"], + ["dummy" => "dummy2"] + ] ], $result->toArray()); } @@ -113,17 +136,27 @@ final class DefaultCachedScraperServiceTest extends TestCase ] ]; - $cacheData = [ - ["dummy" => "dummy1", "modifiedAt" => new UTCDateTime($now->getPreciseTimestamp(3))], - ["dummy" => "dummy2", "modifiedAt" => new UTCDateTime($now->getPreciseTimestamp(3))] - ]; + // the cached data in the database + // this should be an array of arrays as builder->get() returns multiple items + $cacheData = collect([[ + "request_hash" => $testRequestHash, + "modifiedAt" => new UTCDateTime($now->getPreciseTimestamp(3)), + "createdAt" => new UTCDateTime($now->getPreciseTimestamp(3)), + "results" => [ + ["dummy" => "dummy1"], + ["dummy" => "dummy2"] + ] + ]]);; [$queryBuilderMock, $repositoryMock, $serializerMock] = $this->makeCtorArgMocks(2); - // at first the cache is empty - $queryBuilderMock->expects()->get()->once()->andReturn(collect()); - // then it gets "inserted" into - $queryBuilderMock->expects()->get()->once()->andReturn(collect($cacheData)); - $repositoryMock->expects()->insert(Mockery::any())->once()->andReturn(true); + $repositoryMock->expects()->insert(Mockery::capture($insertedData))->once()->andReturn(true); + // at first the cache is empty, then it gets "inserted" into + // so, we change the return value of builder->get accordingly + $queryBuilderMock->expects()->get()->twice()->andReturnUsing(fn() => collect(), function () use (&$insertedData) { + return collect([ + $insertedData + ]); + }); $serializerMock->allows([ "toArray" => $scraperData @@ -132,11 +165,6 @@ final class DefaultCachedScraperServiceTest extends TestCase $target = new DefaultCachedScraperService($repositoryMock, new MalClient(), $serializerMock); $result = $target->findList($testRequestHash, fn() => []); - $this->assertEquals($cacheData, $result->toArray()); - } - - public function testIfModifiedAtValueSetCorrectlyDuringCacheUpdate() - { - + $this->assertEquals($cacheData->first(), $result->toArray()); } }