diff --git a/data/migrations/Version20230303164233.php b/data/migrations/Version20230303164233.php new file mode 100644 index 00000000..3e64c03c --- /dev/null +++ b/data/migrations/Version20230303164233.php @@ -0,0 +1,28 @@ +getTable('visits'); + $this->skipIf($visits->hasIndex(self::INDEX_NAME)); + + $visits->dropIndex('IDX_visits_potential_bot'); // Old index + $visits->addIndex(['potential_bot'], self::INDEX_NAME); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 12adcd57..c89c4fbf 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -9,6 +9,7 @@ use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Rest\ApiKey\Role; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -99,7 +100,7 @@ class GenerateKeyCommand extends Command $io = new SymfonyStyle($input, $output); $io->success(sprintf('Generated API key: "%s"', $apiKey->toString())); - if (! $apiKey->isAdmin()) { + if (! ApiKey::isAdmin($apiKey)) { ShlinkTable::default($io)->render( ['Role name', 'Role metadata'], $apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, 0)]), diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 59f5b534..c7e31819 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -59,7 +59,7 @@ class ListKeysCommand extends Command $rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey)); } $rowData[] = $expiration?->toAtomString() ?? '-'; - $rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles( + $rowData[] = ApiKey::isAdmin($apiKey) ? 'Admin' : implode("\n", $apiKey->mapRoles( fn (Role $role, array $meta) => empty($meta) ? $role->toFriendlyName() diff --git a/module/Core/src/Tag/Model/OrderableField.php b/module/Core/src/Tag/Model/OrderableField.php index 818099de..b7a9509f 100644 --- a/module/Core/src/Tag/Model/OrderableField.php +++ b/module/Core/src/Tag/Model/OrderableField.php @@ -4,8 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Tag\Model; -use function Shlinkio\Shlink\Core\camelCaseToSnakeCase; - enum OrderableField: string { case TAG = 'tag'; @@ -15,20 +13,12 @@ enum OrderableField: string /** @deprecated Use VISITS instead */ case VISITS_COUNT = 'visitsCount'; - public static function isAggregateField(string $field): bool + public static function toSnakeCaseValidField(?string $field): self { - $parsed = self::tryFrom($field); - return $parsed !== null && $parsed !== self::TAG; - } - - public static function toSnakeCaseValidField(?string $field): string - { - $parsed = $field !== null ? self::tryFrom($field) : self::VISITS; - $normalized = match ($parsed) { + $parsed = $field !== null ? self::tryFrom($field) : self::TAG; + return match ($parsed) { self::VISITS_COUNT, null => self::VISITS, default => $parsed, }; - - return camelCaseToSnakeCase($normalized->value); } } diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index 4e9eafac..68e5df4b 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -15,10 +15,11 @@ use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering; use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; -use Shlinkio\Shlink\Rest\ApiKey\Spec\WithInlinedApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use function Functional\each; use function Functional\map; +use function Shlinkio\Shlink\Core\camelCaseToSnakeCase; use const PHP_INT_MAX; @@ -42,106 +43,90 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito */ public function findTagsWithInfo(?TagsListFiltering $filtering = null): array { - $orderField = $filtering?->orderBy?->field; - $orderDir = $filtering?->orderBy?->direction; - $orderMainQuery = $orderField !== null && OrderableField::isAggregateField($orderField); - - $subQb = $this->createQueryBuilder('t'); - $subQb->select('t.id', 't.name'); - - if (! $orderMainQuery) { - $subQb->orderBy('t.name', $orderDir ?? 'ASC') - ->setMaxResults($filtering?->limit ?? PHP_INT_MAX) - ->setFirstResult($filtering?->offset ?? 0); - // TODO Check if applying limit/offset ot visits sub-queries is needed with large amounts of tags - } - - $conn = $this->getEntityManager()->getConnection(); - $buildVisitsSubQuery = static function (bool $excludeBots, string $aggregateAlias) use ($conn) { - $visitsSubQuery = $conn->createQueryBuilder(); - $commonJoinCondition = $visitsSubQuery->expr()->eq('v.short_url_id', 's.id'); - $visitsJoin = ! $excludeBots - ? $commonJoinCondition - : $visitsSubQuery->expr()->and( - $commonJoinCondition, - $visitsSubQuery->expr()->eq('v.potential_bot', $conn->quote('0')), - ); - - return $visitsSubQuery - ->select('st.tag_id AS tag_id', 'COUNT(DISTINCT v.id) AS ' . $aggregateAlias) - ->from('visits', 'v') - ->join('v', 'short_urls', 's', $visitsJoin) // @phpstan-ignore-line - ->join('s', 'short_urls_in_tags', 'st', $visitsSubQuery->expr()->eq('st.short_url_id', 's.id')) - ->groupBy('st.tag_id'); - }; - $allVisitsSubQuery = $buildVisitsSubQuery(false, 'visits'); - $nonBotVisitsSubQuery = $buildVisitsSubQuery(true, 'non_bot_visits'); - - $searchTerm = $filtering?->searchTerm; - if ($searchTerm !== null) { - $subQb->andWhere($subQb->expr()->like('t.name', $conn->quote('%' . $searchTerm . '%'))); - // TODO Check if applying this to all sub-queries makes it faster or slower - } - + $orderField = OrderableField::toSnakeCaseValidField($filtering?->orderBy?->field); + $orderDir = $filtering?->orderBy?->direction ?? 'ASC'; $apiKey = $filtering?->apiKey; - $applyApiKeyToNativeQuery = static fn (?ApiKey $apiKey, NativeQueryBuilder $nativeQueryBuilder) => + $conn = $this->getEntityManager()->getConnection(); + + $applyApiKeyToNativeQb = static fn (NativeQueryBuilder $qb) => $apiKey?->mapRoles(static fn (Role $role, array $meta) => match ($role) { - Role::DOMAIN_SPECIFIC => $nativeQueryBuilder->andWhere( - $nativeQueryBuilder->expr()->eq('s.domain_id', $conn->quote(Role::domainIdFromMeta($meta))), + Role::DOMAIN_SPECIFIC => $qb->andWhere( + $qb->expr()->eq('s.domain_id', $conn->quote(Role::domainIdFromMeta($meta))), ), - Role::AUTHORED_SHORT_URLS => $nativeQueryBuilder->andWhere( - $nativeQueryBuilder->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())), + Role::AUTHORED_SHORT_URLS => $qb->andWhere( + $qb->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())), ), }); + // For admins and when no API key is present, we'll return tags which are not linked to any short URL + $joiningMethod = ApiKey::isAdmin($apiKey) ? 'leftJoin' : 'join'; + $tagsSubQb = $conn->createQueryBuilder(); + $tagsSubQb + ->select('t.id AS tag_id', 't.name AS tag', 'COUNT(DISTINCT s.id) AS short_urls_count') + ->from('tags', 't') + ->groupBy('t.id', 't.name') + ->{$joiningMethod}('t', 'short_urls_in_tags', 'st', $tagsSubQb->expr()->eq('st.tag_id', 't.id')) + ->{$joiningMethod}('st', 'short_urls', 's', $tagsSubQb->expr()->eq('st.short_url_id', 's.id')); + + $searchTerm = $filtering?->searchTerm; + if ($searchTerm !== null) { + $tagsSubQb->andWhere($tagsSubQb->expr()->like('t.name', $conn->quote('%' . $searchTerm . '%'))); + } + + $buildVisitsSubQb = static function (bool $excludeBots, string $aggregateAlias) use ($conn) { + $visitsSubQb = $conn->createQueryBuilder(); + $commonJoinCondition = $visitsSubQb->expr()->eq('v.short_url_id', 's.id'); + $visitsJoin = ! $excludeBots + ? $commonJoinCondition + : $visitsSubQb->expr()->and( + $commonJoinCondition, + $visitsSubQb->expr()->eq('v.potential_bot', $conn->quote('0')), + ); + + return $visitsSubQb + ->select('st.tag_id AS tag_id', 'COUNT(DISTINCT v.id) AS ' . $aggregateAlias) + ->from('visits', 'v') + ->join('v', 'short_urls', 's', $visitsJoin) // @phpstan-ignore-line + ->join('s', 'short_urls_in_tags', 'st', $visitsSubQb->expr()->eq('st.short_url_id', 's.id')) + ->groupBy('st.tag_id'); + }; + $allVisitsSubQb = $buildVisitsSubQb(false, 'visits'); + $nonBotVisitsSubQb = $buildVisitsSubQb(true, 'non_bot_visits'); + // Apply API key specification to all sub-queries - $this->applySpecification($subQb, new WithInlinedApiKeySpecsEnsuringJoin($apiKey), 't'); - $applyApiKeyToNativeQuery($apiKey, $allVisitsSubQuery); - $applyApiKeyToNativeQuery($apiKey, $nonBotVisitsSubQuery); + each([$tagsSubQb, $allVisitsSubQb, $nonBotVisitsSubQb], $applyApiKeyToNativeQb); // A native query builder needs to be used here, because DQL and ORM query builders do not support // sub-queries at "from" and "join" level. // If no sub-query is used, the whole list is loaded even with pagination, making it very inefficient. - $nativeQb = $conn->createQueryBuilder(); - $nativeQb + $mainQb = $conn->createQueryBuilder(); + $mainQb ->select( - 't.id_0 AS id', - 't.name_1 AS name', + 't.tag AS tag', 'COALESCE(v.visits, 0) AS visits', // COALESCE required for postgres to properly order - 'COALESCE(v2.non_bot_visits, 0) AS non_bot_visits', // COALESCE required for postgres to properly order - 'COUNT(DISTINCT s.id) AS short_urls_count', + 'COALESCE(b.non_bot_visits, 0) AS non_bot_visits', + 'COALESCE(t.short_urls_count, 0) AS short_urls_count', ) - ->from('(' . $subQb->getQuery()->getSQL() . ')', 't') // @phpstan-ignore-line - ->leftJoin('t', 'short_urls_in_tags', 'st', $nativeQb->expr()->eq('t.id_0', 'st.tag_id')) - ->leftJoin('st', 'short_urls', 's', $nativeQb->expr()->eq('s.id', 'st.short_url_id')) - ->leftJoin('t', '(' . $allVisitsSubQuery->getSQL() . ')', 'v', $nativeQb->expr()->eq('t.id_0', 'v.tag_id')) - ->leftJoin('t', '(' . $nonBotVisitsSubQuery->getSQL() . ')', 'v2', $nativeQb->expr()->eq( - 't.id_0', - 'v2.tag_id', - )) - ->groupBy('t.id_0', 't.name_1', 'v.visits', 'v2.non_bot_visits'); + ->from('(' . $tagsSubQb->getSQL() . ')', 't') + ->leftJoin('t', '(' . $allVisitsSubQb->getSQL() . ')', 'v', $mainQb->expr()->eq('t.tag_id', 'v.tag_id')) + ->leftJoin('t', '(' . $nonBotVisitsSubQb->getSQL() . ')', 'b', $mainQb->expr()->eq('t.tag_id', 'b.tag_id')) + ->setMaxResults($filtering?->limit ?? PHP_INT_MAX) + ->setFirstResult($filtering?->offset ?? 0); - // Apply API key role conditions to the native query too, as they will affect the amounts on the aggregates - $applyApiKeyToNativeQuery($apiKey, $nativeQb); - - if ($orderMainQuery) { - $nativeQb - ->orderBy(OrderableField::toSnakeCaseValidField($orderField), $orderDir ?? 'ASC') - ->setMaxResults($filtering?->limit ?? PHP_INT_MAX) - ->setFirstResult($filtering?->offset ?? 0); + $mainQb->orderBy(camelCaseToSnakeCase($orderField->value), $orderDir); + if ($orderField !== OrderableField::TAG) { + // Add ordering by tag name, as a fallback in case of same amounts + $mainQb->addOrderBy('tag', 'ASC'); } - // Add ordering by tag name, as a fallback in case of same amount, or as default ordering - $nativeQb->addOrderBy('t.name_1', $orderMainQuery || $orderDir === null ? 'ASC' : $orderDir); - $rsm = new ResultSetMappingBuilder($this->getEntityManager()); - $rsm->addScalarResult('name', 'tag'); + $rsm->addScalarResult('tag', 'tag'); $rsm->addScalarResult('visits', 'visits'); $rsm->addScalarResult('non_bot_visits', 'nonBotVisits'); $rsm->addScalarResult('short_urls_count', 'shortUrlsCount'); return map( - $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(), + $this->getEntityManager()->createNativeQuery($mainQb->getSQL(), $rsm)->getResult(), TagInfo::fromRawData(...), ); } diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index d50ced75..ea9a4e8b 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -59,7 +59,7 @@ class TagService implements TagServiceInterface */ public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void { - if ($apiKey !== null && ! $apiKey->isAdmin()) { + if (! ApiKey::isAdmin($apiKey)) { throw ForbiddenTagOperationException::forDeletion(); } @@ -75,7 +75,7 @@ class TagService implements TagServiceInterface */ public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag { - if ($apiKey !== null && ! $apiKey->isAdmin()) { + if (! ApiKey::isAdmin($apiKey)) { throw ForbiddenTagOperationException::forRenaming(); } diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php index f2cce0f7..da1755ac 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php @@ -395,7 +395,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase #[Test] public function importedShortUrlsAreFoundWhenExpected(): void { - $buildImported = static fn (string $shortCode, ?String $domain = null) => + $buildImported = static fn (string $shortCode, ?string $domain = null) => new ImportedShlinkUrl(ImportSource::BITLY, 'foo', [], Chronos::now(), $domain, $shortCode, null); $shortUrlWithoutDomain = ShortUrl::fromImport($buildImported('my-cool-slug'), true); diff --git a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php index 1f8c2fd3..9a8f8056 100644 --- a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -11,14 +11,14 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class WithApiKeySpecsEnsuringJoin extends BaseSpecification { - public function __construct(private ?ApiKey $apiKey, private string $fieldToJoin = 'shortUrls') + public function __construct(private readonly ?ApiKey $apiKey, private readonly string $fieldToJoin = 'shortUrls') { parent::__construct(); } protected function getSpec(): Specification { - return $this->apiKey === null || $this->apiKey->isAdmin() ? Spec::andX() : Spec::andX( + return $this->apiKey === null || ApiKey::isAdmin($this->apiKey) ? Spec::andX() : Spec::andX( Spec::join($this->fieldToJoin, 's'), $this->apiKey->spec($this->fieldToJoin), ); diff --git a/module/Rest/src/ApiKey/Spec/WithInlinedApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithInlinedApiKeySpecsEnsuringJoin.php deleted file mode 100644 index 2e84d835..00000000 --- a/module/Rest/src/ApiKey/Spec/WithInlinedApiKeySpecsEnsuringJoin.php +++ /dev/null @@ -1,26 +0,0 @@ -apiKey === null || $this->apiKey->isAdmin() ? Spec::andX() : Spec::andX( - Spec::join($this->fieldToJoin, 's'), - $this->apiKey->inlinedSpec(), - ); - } -} diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 57fecdd0..88cfa27e 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -114,9 +114,12 @@ class ApiKey extends AbstractEntity return Spec::andX(...$specs); } - public function isAdmin(): bool + /** + * @return ($apiKey is null ? true : boolean) + */ + public static function isAdmin(?ApiKey $apiKey): bool { - return $this->roles->isEmpty(); + return $apiKey === null || $apiKey->roles->isEmpty(); } public function hasRole(Role $role): bool