Added happyr/doctrine-specification to support dunamically applying specs to queries

This commit is contained in:
Alejandro Celaya 2021-01-02 17:14:42 +01:00
parent 90551ff3bc
commit ecf22ae4b6
11 changed files with 48 additions and 28 deletions

View File

@ -25,6 +25,7 @@
"endroid/qr-code": "^3.6", "endroid/qr-code": "^3.6",
"geoip2/geoip2": "^2.9", "geoip2/geoip2": "^2.9",
"guzzlehttp/guzzle": "^7.0", "guzzlehttp/guzzle": "^7.0",
"happyr/doctrine-specification": "2.0.x-dev as 2.0",
"laminas/laminas-config": "^3.3", "laminas/laminas-config": "^3.3",
"laminas/laminas-config-aggregator": "^1.1", "laminas/laminas-config-aggregator": "^1.1",
"laminas/laminas-diactoros": "^2.1.3", "laminas/laminas-diactoros": "^2.1.3",
@ -125,13 +126,7 @@
], ],
"test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox", "test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
"test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml", "test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml",
"test:db": [ "test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
"@test:db:sqlite:ci",
"@test:db:mysql",
"@test:db:maria",
"@test:db:postgres",
"@test:db:ms"
],
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml", "test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml", "test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml",
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite", "test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",

View File

@ -4,13 +4,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository; namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\ORM\EntityRepository; use Happyr\DoctrineSpecification\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use function Functional\map; use function Functional\map;
class TagRepository extends EntityRepository implements TagRepositoryInterface class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface
{ {
public function deleteByName(array $names): int public function deleteByName(array $names): int
{ {
@ -28,17 +29,16 @@ class TagRepository extends EntityRepository implements TagRepositoryInterface
/** /**
* @return TagInfo[] * @return TagInfo[]
*/ */
public function findTagsWithInfo(): array public function findTagsWithInfo(?Specification $spec = null): array
{ {
$dql = <<<DQL $qb = $this->getQueryBuilder($spec, 't');
SELECT t AS tag, COUNT(DISTINCT s.id) AS shortUrlsCount, COUNT(DISTINCT v.id) AS visitsCount $qb->select('t AS tag', 'COUNT(DISTINCT s.id) AS shortUrlsCount', 'COUNT(DISTINCT v.id) AS visitsCount')
FROM Shlinkio\Shlink\Core\Entity\Tag t ->leftJoin('t.shortUrls', 's')
LEFT JOIN t.shortUrls s ->leftJoin('s.visits', 'v')
LEFT JOIN s.visits v ->groupBy('t')
GROUP BY t ->orderBy('t.name', 'ASC');
ORDER BY t.name ASC
DQL; $query = $qb->getQuery();
$query = $this->getEntityManager()->createQuery($dql);
return map( return map(
$query->getResult(), $query->getResult(),

View File

@ -5,14 +5,16 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository; namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Persistence\ObjectRepository; use Doctrine\Persistence\ObjectRepository;
use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
interface TagRepositoryInterface extends ObjectRepository interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{ {
public function deleteByName(array $names): int; public function deleteByName(array $names): int;
/** /**
* @return TagInfo[] * @return TagInfo[]
*/ */
public function findTagsWithInfo(): array; public function findTagsWithInfo(?Specification $spec = null): array;
} }

View File

@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface; use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class TagService implements TagServiceInterface class TagService implements TagServiceInterface
{ {
@ -38,11 +39,11 @@ class TagService implements TagServiceInterface
/** /**
* @return TagInfo[] * @return TagInfo[]
*/ */
public function tagsInfo(): array public function tagsInfo(?ApiKey $apiKey = null): array
{ {
/** @var TagRepositoryInterface $repo */ /** @var TagRepositoryInterface $repo */
$repo = $this->em->getRepository(Tag::class); $repo = $this->em->getRepository(Tag::class);
return $repo->findTagsWithInfo(); return $repo->findTagsWithInfo($apiKey !== null ? $apiKey->spec() : null);
} }
/** /**

View File

@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface TagServiceInterface interface TagServiceInterface
{ {
@ -20,7 +21,7 @@ interface TagServiceInterface
/** /**
* @return TagInfo[] * @return TagInfo[]
*/ */
public function tagsInfo(): array; public function tagsInfo(?ApiKey $apiKey = null): array;
/** /**
* @param string[] $tagNames * @param string[] $tagNames

View File

@ -51,7 +51,7 @@ class TagServiceTest extends TestCase
{ {
$expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)]; $expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)];
$find = $this->repo->findTagsWithInfo()->willReturn($expected); $find = $this->repo->findTagsWithInfo(null)->willReturn($expected);
$result = $this->service->tagsInfo(); $result = $this->service->tagsInfo();

View File

@ -10,6 +10,7 @@ use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
use function Functional\map; use function Functional\map;
@ -38,7 +39,8 @@ class ListTagsAction extends AbstractRestAction
]); ]);
} }
$tagsInfo = $this->tagService->tagsInfo(); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$tagsInfo = $this->tagService->tagsInfo($apiKey);
$data = map($tagsInfo, fn (TagInfo $info) => (string) $info->tag()); $data = map($tagsInfo, fn (TagInfo $info) => (string) $info->tag());
return new JsonResponse([ return new JsonResponse([

View File

@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Entity; namespace Shlinkio\Shlink\Rest\Entity;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\Specification;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Common\Entity\AbstractEntity;
@ -59,4 +61,11 @@ class ApiKey extends AbstractEntity
{ {
return $this->key; return $this->key;
} }
/**
*/
public function spec(): Specification
{
return Spec::andX();
}
} }

View File

@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction; use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ListTagsActionTest extends TestCase class ListTagsActionTest extends TestCase
{ {
@ -62,10 +63,13 @@ class ListTagsActionTest extends TestCase
new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('foo'), 1, 1),
new TagInfo(new Tag('bar'), 3, 10), new TagInfo(new Tag('bar'), 3, 10),
]; ];
$tagsInfo = $this->tagService->tagsInfo()->willReturn($stats); $apiKey = new ApiKey();
$tagsInfo = $this->tagService->tagsInfo($apiKey)->willReturn($stats);
$req = ServerRequestFactory::fromGlobals()->withQueryParams(['withStats' => 'true'])
->withAttribute(ApiKey::class, $apiKey);
/** @var JsonResponse $resp */ /** @var JsonResponse $resp */
$resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams(['withStats' => 'true'])); $resp = $this->action->handle($req);
$payload = $resp->getPayload(); $payload = $resp->getPayload();
self::assertEquals([ self::assertEquals([

View File

@ -16,6 +16,9 @@
<directory suffix=".php">./module/*/src/Repository</directory> <directory suffix=".php">./module/*/src/Repository</directory>
<directory suffix=".php">./module/*/src/**/Repository</directory> <directory suffix=".php">./module/*/src/**/Repository</directory>
<directory suffix=".php">./module/*/src/**/**/Repository</directory> <directory suffix=".php">./module/*/src/**/**/Repository</directory>
<directory suffix=".php">./module/*/src/Spec</directory>
<directory suffix=".php">./module/*/src/**/Spec</directory>
<directory suffix=".php">./module/*/src/**/**/Spec</directory>
</include> </include>
</coverage> </coverage>
</phpunit> </phpunit>

View File

@ -25,6 +25,9 @@
<directory suffix=".php">./module/Core/src/Repository</directory> <directory suffix=".php">./module/Core/src/Repository</directory>
<directory suffix=".php">./module/Core/src/**/Repository</directory> <directory suffix=".php">./module/Core/src/**/Repository</directory>
<directory suffix=".php">./module/Core/src/**/**/Repository</directory> <directory suffix=".php">./module/Core/src/**/**/Repository</directory>
<directory suffix=".php">./module/Core/src/Spec</directory>
<directory suffix=".php">./module/Core/src/**/Spec</directory>
<directory suffix=".php">./module/Core/src/**/**/Spec</directory>
</exclude> </exclude>
</coverage> </coverage>
</phpunit> </phpunit>