From 382db650c305e335d022649844afb4e92fea001a Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Sat, 31 Jan 2026 20:59:14 +0100 Subject: [PATCH 1/6] Move the front page to cursor based pagination - instead of the usual `LIMIT`/`OFFSET` pagination use cursor based one based on the current ordering - Write classes that are inspired by the pagination of doctrine for cursor based pagination - make the `ContentRepository` work with the new pagination, while still working with the regular one --- assets/styles/layout/_section.scss | 11 + src/Controller/Entry/EntryFrontController.php | 25 +- .../Cursor/CursorAdapterInterface.php | 32 + src/Pagination/Cursor/CursorPagination.php | 213 +++++++ .../Cursor/CursorPaginationInterface.php | 63 ++ .../Cursor/NativeQueryCursorAdapter.php | 78 +++ src/Pagination/NativeQueryAdapter.php | 20 +- src/Repository/ContentRepository.php | 572 +++++++++++------- .../Components/CursorPaginationComponent.php | 16 + src/Twig/Extension/UrlExtension.php | 1 + src/Twig/Runtime/NavbarExtensionRuntime.php | 6 +- src/Twig/Runtime/UrlExtensionRuntime.php | 10 + src/Utils/SqlHelpers.php | 34 +- .../components/cursor_pagination.html.twig | 19 + templates/entry/_options.html.twig | 50 +- templates/layout/_subject_list.html.twig | 23 + .../Entry/EntryFrontControllerTest.php | 5 +- tests/Unit/CursorPaginationTest.php | 129 ++++ 18 files changed, 1022 insertions(+), 285 deletions(-) create mode 100644 src/Pagination/Cursor/CursorAdapterInterface.php create mode 100644 src/Pagination/Cursor/CursorPagination.php create mode 100644 src/Pagination/Cursor/CursorPaginationInterface.php create mode 100644 src/Pagination/Cursor/NativeQueryCursorAdapter.php create mode 100644 src/Twig/Components/CursorPaginationComponent.php create mode 100644 templates/components/cursor_pagination.html.twig create mode 100644 tests/Unit/CursorPaginationTest.php diff --git a/assets/styles/layout/_section.scss b/assets/styles/layout/_section.scss index 54bfa561d0..6ef66ad47e 100644 --- a/assets/styles/layout/_section.scss +++ b/assets/styles/layout/_section.scss @@ -71,3 +71,14 @@ color: var(--kbin-alert-danger-text-color); } } + +.cursor-pagination .section { + text-align: center; + padding: .25rem; + + a { + display: inline-block; + width: 25%; + min-width: 250px; + } +} diff --git a/src/Controller/Entry/EntryFrontController.php b/src/Controller/Entry/EntryFrontController.php index adceff7113..a644eb1f1d 100644 --- a/src/Controller/Entry/EntryFrontController.php +++ b/src/Controller/Entry/EntryFrontController.php @@ -41,6 +41,7 @@ public function front( #[MapQueryParameter] ?string $type, Request $request, + #[MapQueryParameter] ?string $cursor = null, ): Response { $user = $this->getUser(); @@ -61,7 +62,8 @@ public function front( $criteria->fetchCachedItems($this->contentRepository, $user); } - $entities = $this->contentRepository->findByCriteria($criteria); + $entities = $this->contentRepository->findByCriteriaCursored($criteria, $this->getCursorByCriteria($criteria, $cursor)); + $page = $entities->getCurrentPageResults(); $templatePath = 'content/'; $dataKey = 'results'; @@ -106,6 +108,7 @@ public function magazine( #[MapQueryParameter] ?string $type, Request $request, + #[MapQueryParameter] ?string $cursor = null, ): Response { $user = $this->getUser(); $response = new Response(); @@ -132,7 +135,7 @@ public function magazine( return $this->renderResponse( $request, $criteria, - ['results' => $this->contentRepository->findByCriteria($criteria), 'magazine' => $magazine], + ['results' => $this->contentRepository->findByCriteriaCursored($criteria, $this->getCursorByCriteria($criteria, $cursor)), 'magazine' => $magazine], 'content/', $user ); @@ -299,4 +302,22 @@ private function handleCrossposts($pagination): PagerfantaInterface return $pagerfanta; } + + /** + * @throws \DateMalformedStringException + */ + private function getCursorByCriteria(Criteria $criteria, ?string $cursor): int|\DateTimeImmutable + { + $guessedCursor = $this->contentRepository->guessInitialCursor($criteria); + if ($guessedCursor instanceof \DateTimeImmutable) { + $currentCursor = null !== $cursor ? new \DateTimeImmutable($cursor) : $guessedCursor; + // $currentCursor = null !== $cursor ? (new \DateTimeImmutable)->setTimestamp(intval($cursor)) : $guessedCursor; + } elseif (\is_int($guessedCursor)) { + $currentCursor = null !== $cursor ? \intval($cursor) : $guessedCursor; + } else { + throw new \LogicException(\get_class($guessedCursor).' is not accounted for'); + } + + return $currentCursor; + } } diff --git a/src/Pagination/Cursor/CursorAdapterInterface.php b/src/Pagination/Cursor/CursorAdapterInterface.php new file mode 100644 index 0000000000..0715dd2d0b --- /dev/null +++ b/src/Pagination/Cursor/CursorAdapterInterface.php @@ -0,0 +1,32 @@ + $length + * + * @return iterable + */ + public function getSlice(mixed $cursor, int $length): iterable; + + /** + * Returns a slice of the results representing the previous page of items in reverse. + * + * @param TCursor $cursor + * @param int<0, max> $length + * + * @return iterable + */ + public function getPreviousSlice(mixed $cursor, int $length): iterable; +} diff --git a/src/Pagination/Cursor/CursorPagination.php b/src/Pagination/Cursor/CursorPagination.php new file mode 100644 index 0000000000..3240c73ca3 --- /dev/null +++ b/src/Pagination/Cursor/CursorPagination.php @@ -0,0 +1,213 @@ +|null + */ + private ?array $currentPageResults = null; + + /** + * @var array|null + */ + private ?array $previousPageResults = null; + + /** + * @var TCursor|null + */ + private mixed $currentCursor = null; + + /** + * @var TCursor|null + */ + private mixed $nextCursor = null; + + /** + * @param CursorAdapterInterface $adapter + */ + public function __construct( + private readonly CursorAdapterInterface $adapter, + private readonly string $cursorFieldName, + private int $maxPerPage, + ) { + } + + public function getIterator(): \Traversable + { + $results = $this->getCurrentPageResults(); + + if ($results instanceof \Iterator) { + return $results; + } + + if ($results instanceof \IteratorAggregate) { + return $results->getIterator(); + } + + if (\is_array($results)) { + return new \ArrayIterator($results); + } + + throw new \InvalidArgumentException(\sprintf('Cannot create iterator with page results of type "%s".', \get_class($results))); + } + + public function getAdapter(): CursorAdapterInterface + { + return $this->adapter; + } + + public function setMaxPerPage(int $maxPerPage): CursorPaginationInterface + { + $this->maxPerPage = $maxPerPage; + + return $this; + } + + public function getMaxPerPage(): int + { + return $this->maxPerPage; + } + + public function getCurrentPageResults(): iterable + { + if (null !== $this->currentPageResults) { + return $this->currentPageResults; + } + $results = $this->adapter->getSlice($this->currentCursor, $this->maxPerPage); + $this->currentPageResults = [...$results]; + + return $this->currentPageResults; + } + + public function haveToPaginate(): bool + { + return $this->maxPerPage === \sizeof($this->currentPageResults ?? [...$this->getCurrentPageResults()]); + } + + public function hasNextPage(): bool + { + return $this->haveToPaginate(); + } + + public function getNextPage(): mixed + { + if (null !== $this->nextCursor) { + return $this->nextCursor; + } + + $cursorFieldName = $this->cursorFieldName; + $array = $this->getCurrentPageResults(); + $nextCursor = null; + $i = 0; + foreach ($array as $item) { + if (\is_object($item)) { + $nextCursor = $item->$cursorFieldName; + } elseif (\is_array($item)) { + $nextCursor = $item[$cursorFieldName]; + } else { + throw new \LogicException('Item has to be an object or array.'); + } + ++$i; + } + if ($this->maxPerPage === $i) { + $this->nextCursor = $nextCursor; + + return $nextCursor; + } + throw new \LogicException('There is no next page'); + } + + /** + * Generates an iterator to automatically iterate over all pages in a result set. + * + * @return \Generator + */ + public function autoPagingIterator(): \Generator + { + while (true) { + foreach ($this->getCurrentPageResults() as $item) { + yield $item; + } + + if (!$this->hasNextPage()) { + break; + } + + $this->setCurrentPage($this->getNextPage()); + } + } + + public function setCurrentPage(mixed $cursor): CursorPaginationInterface + { + if ($cursor !== $this->currentCursor) { + $this->previousPageResults = null; + $this->currentCursor = $cursor; + $this->currentPageResults = null; + $this->nextCursor = null; + } + + return $this; + } + + public function getCurrentCursor(): mixed + { + return $this->currentCursor; + } + + public function hasPreviousPage(): bool + { + return \sizeof($this->getPreviousPageResults()) > 0; + } + + public function getPreviousPage(): mixed + { + $cursorFieldName = $this->cursorFieldName; + $array = $this->getPreviousPageResults(); + $key = array_key_last($array); + + $item = $array[$key]; + if (\is_object($item)) { + $cursor = $item->$cursorFieldName; + } elseif (\is_array($item)) { + $cursor = $item[$cursorFieldName]; + } else { + throw new \LogicException('Item has to be an object or array.'); + } + + $currentCursor = $this->getCurrentCursor(); + // we need to modify the value to include the last result of the previous page in reverse, + // otherwise we will always be missing one result when going back + if ($currentCursor > $cursor) { + if ($cursor instanceof \DateTime || $cursor instanceof \DateTimeImmutable) { + return (new \DateTimeImmutable())->setTimestamp($cursor->getTimestamp() - 1); + } elseif (\is_int($cursor)) { + return --$cursor; + } + } else { + if ($cursor instanceof \DateTime || $cursor instanceof \DateTimeImmutable) { + return (new \DateTimeImmutable())->setTimestamp($cursor->getTimestamp() + 1); + } elseif (\is_int($cursor)) { + return ++$cursor; + } + } + + return $cursor; + } + + private function getPreviousPageResults(): array + { + if (null === $this->previousPageResults) { + $this->previousPageResults = [...$this->adapter->getPreviousSlice($this->currentCursor, $this->maxPerPage)]; + } + + return $this->previousPageResults; + } +} diff --git a/src/Pagination/Cursor/CursorPaginationInterface.php b/src/Pagination/Cursor/CursorPaginationInterface.php new file mode 100644 index 0000000000..82bac2c661 --- /dev/null +++ b/src/Pagination/Cursor/CursorPaginationInterface.php @@ -0,0 +1,63 @@ + + * + * @method \Generator autoPagingIterator() + */ +interface CursorPaginationInterface extends \IteratorAggregate +{ + /** + * @return CursorAdapterInterface + */ + public function getAdapter(): CursorAdapterInterface; + + public function setMaxPerPage(int $maxPerPage): self; + + /** + * @param TCursor $cursor + */ + public function setCurrentPage(mixed $cursor): self; + + public function getMaxPerPage(): int; + + /** + * @return iterable + */ + public function getCurrentPageResults(): iterable; + + public function haveToPaginate(): bool; + + public function hasNextPage(): bool; + + /** + * @return TCursor + * + * @throws LogicException if there is no next page + */ + public function getNextPage(): mixed; + + public function hasPreviousPage(): bool; + + /** + * @return TCursor + * + * @throws LogicException if there is no previous page + */ + public function getPreviousPage(): mixed; + + /** + * @return TCursor + */ + public function getCurrentCursor(): mixed; +} diff --git a/src/Pagination/Cursor/NativeQueryCursorAdapter.php b/src/Pagination/Cursor/NativeQueryCursorAdapter.php new file mode 100644 index 0000000000..b070dd5ad2 --- /dev/null +++ b/src/Pagination/Cursor/NativeQueryCursorAdapter.php @@ -0,0 +1,78 @@ + $parameters parameter name as key, parameter value as the value + * @param ResultTransformer $transformer defaults to the VoidTransformer which does not transform the result in any way + * + * @throws Exception + */ + public function __construct( + private readonly Connection $conn, + private string $sql, + private string $forwardCursorCondition, + private string $backwardCursorCondition, + private string $forwardCursorSort, + private string $backwardCursorSort, + private readonly array $parameters, + private readonly ResultTransformer $transformer = new VoidTransformer(), + ) { + } + + /** + * @param TCursor $cursor + * + * @return iterable + * + * @throws Exception + */ + public function getSlice(mixed $cursor, int $length): iterable + { + $replacedCursorSortSql = str_replace('%cursorSort%', $this->forwardCursorSort, $this->sql); + $replacedCursorSql = str_replace('%cursor%', $this->forwardCursorCondition, $replacedCursorSortSql); + $sql = $replacedCursorSql.' LIMIT :limit'; + + return $this->query($sql, $cursor, $length); + } + + public function getPreviousSlice(mixed $cursor, int $length): iterable + { + $replacedCursorSortSql = str_replace('%cursorSort%', $this->backwardCursorSort, $this->sql); + $replacedCursorSql = str_replace('%cursor%', $this->backwardCursorCondition, $replacedCursorSortSql); + $sql = $replacedCursorSql.' LIMIT :limit'; + + return $this->query($sql, $cursor, $length); + } + + /** + * @throws Exception + */ + private function query(string $sql, mixed $cursor, int $length): iterable + { + $statement = $this->conn->prepare($sql); + foreach ($this->parameters as $key => $value) { + $statement->bindValue($key, $value, SqlHelpers::getSqlType($value)); + } + $statement->bindValue('cursor', $cursor, SqlHelpers::getSqlType($cursor)); + $statement->bindValue('limit', $length); + + return $this->transformer->transform($statement->executeQuery()->fetchAllAssociative()); + } +} diff --git a/src/Pagination/NativeQueryAdapter.php b/src/Pagination/NativeQueryAdapter.php index 1668b66a62..a4aedd6b3d 100644 --- a/src/Pagination/NativeQueryAdapter.php +++ b/src/Pagination/NativeQueryAdapter.php @@ -6,11 +6,10 @@ use App\Pagination\Transformation\ResultTransformer; use App\Pagination\Transformation\VoidTransformer; +use App\Utils\SqlHelpers; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception; -use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Statement; -use Doctrine\DBAL\Types\Types; use Pagerfanta\Adapter\AdapterInterface; use Psr\Cache\CacheItemInterface; use Symfony\Contracts\Cache\CacheInterface; @@ -42,7 +41,7 @@ public function __construct( $this->statement = $this->conn->prepare($sql.' LIMIT :limit OFFSET :offset'); foreach ($this->parameters as $key => $value) { - $this->statement->bindValue($key, $value, $this->getSqlType($value)); + $this->statement->bindValue($key, $value, SqlHelpers::getSqlType($value)); } } @@ -73,7 +72,7 @@ private function calculateNumOfResults(string $sql, array $parameters): int $sql2 = 'SELECT COUNT(*) as cnt FROM ('.$sql.') sub'; $stmt2 = $this->conn->prepare($sql2); foreach ($parameters as $key => $value) { - $stmt2->bindValue($key, $value, $this->getSqlType($value)); + $stmt2->bindValue($key, $value, SqlHelpers::getSqlType($value)); } $result = $stmt2->executeQuery()->fetchAllAssociative(); @@ -92,17 +91,4 @@ public function getSlice(int $offset, int $length): iterable return $this->transformer->transform($this->statement->executeQuery()->fetchAllAssociative()); } - - private function getSqlType(mixed $value): mixed - { - if ($value instanceof \DateTimeImmutable) { - return Types::DATETIMETZ_IMMUTABLE; - } elseif ($value instanceof \DateTime) { - return Types::DATETIMETZ_MUTABLE; - } elseif (\is_int($value)) { - return Types::INTEGER; - } - - return ParameterType::STRING; - } } diff --git a/src/Repository/ContentRepository.php b/src/Repository/ContentRepository.php index 6eb1c38f2d..542de2536a 100644 --- a/src/Repository/ContentRepository.php +++ b/src/Repository/ContentRepository.php @@ -5,7 +5,12 @@ namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; +use App\Entity\Entry; +use App\Entity\Post; use App\Entity\User; +use App\Pagination\Cursor\CursorPagination; +use App\Pagination\Cursor\CursorPaginationInterface; +use App\Pagination\Cursor\NativeQueryCursorAdapter; use App\Pagination\NativeQueryAdapter; use App\Pagination\Pagerfanta; use App\Pagination\Transformation\ContentPopulationTransformer; @@ -43,6 +48,272 @@ public function __construct( } public function findByCriteria(Criteria $criteria): PagerfantaInterface + { + $query = $this->getQueryAndParameters($criteria, false); + $conn = $this->entityManager->getConnection(); + + $numResults = null; + if ('test' !== $this->kernel->getEnvironment() && !$criteria->magazine && !$criteria->moderated && !$criteria->favourite && Criteria::TIME_ALL === $criteria->time && Criteria::AP_ALL === $criteria->federation && 'all' === $criteria->type) { + // pre-set the results to 1000 pages for queries not very limited by the parameters so the count query is not being executed + $numResults = 1000 * ($criteria->perPage ?? self::PER_PAGE); + } + $fanta = new Pagerfanta(new NativeQueryAdapter($conn, $query['sql'], $query['parameters'], numOfResults: $numResults, transformer: $this->contentPopulationTransformer, cache: $this->cache)); + $fanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE); + $fanta->setCurrentPage($criteria->page); + + return $fanta; + } + + /** + * @template-covariant TCursor + * + * @param TCursor|null $currentCursor + * + * @return CursorPaginationInterface + * + * @throws Exception + */ + public function findByCriteriaCursored(Criteria $criteria, mixed $currentCursor): CursorPaginationInterface + { + $query = $this->getQueryAndParameters($criteria, true); + $conn = $this->entityManager->getConnection(); + $orderings = $this->getOrderings($criteria); + + $fanta = new CursorPagination( + new NativeQueryCursorAdapter( + $conn, + $query['sql'], + $this->getCursorWhereFromCriteria($criteria), + $this->getCursorWhereInvertedFromCriteria($criteria), + join(',', $orderings), + join(',', SqlHelpers::invertOrderings($orderings)), + $query['parameters'], + transformer: $this->contentPopulationTransformer, + ), + $this->getCursorFieldFromCriteria($criteria), + $criteria->perPage ?? self::PER_PAGE, + ); + $fanta->setCurrentPage($currentCursor ?? $this->guessInitialCursor($criteria)); + + return $fanta; + } + + /** + * @return int[] the ids of the users $user follows + * + * @throws InvalidArgumentException|Exception + */ + public function getCachedUserFollows(User $user): array + { + $sql = 'SELECT following_id FROM user_follow WHERE follower_id = :uId'; + if ('test' === $this->kernel->getEnvironment()) { + return $this->fetchSingleColumnAsArray($sql, $user); + } + + return $this->cache->get(self::USER_FOLLOWS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { + return $this->fetchSingleColumnAsArray($sql, $user); + }); + } + + public function clearCachedUserFollows(User $user): void + { + $this->logger->debug('Clearing cached user follows for user {u}', ['u' => $user->username]); + try { + $this->cache->delete(self::USER_FOLLOWS_KEY.$user->getId()); + } catch (InvalidArgumentException $exception) { + $this->logger->warning('There was an error clearing the cached user follows of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + } + } + + /** + * @return int[] the ids of the magazines $user is subscribed to + * + * @throws InvalidArgumentException|Exception + */ + public function getCachedUserSubscribedMagazines(User $user): array + { + $sql = 'SELECT magazine_id FROM magazine_subscription WHERE user_id = :uId'; + if ('test' === $this->kernel->getEnvironment()) { + return $this->fetchSingleColumnAsArray($sql, $user); + } + + return $this->cache->get(self::USER_MAGAZINE_SUBSCRIPTION_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { + return $this->fetchSingleColumnAsArray($sql, $user); + }); + } + + public function clearCachedUserSubscribedMagazines(User $user): void + { + $this->logger->debug('Clearing cached magazine subscriptions for user {u}', ['u' => $user->username]); + try { + $this->cache->delete(self::USER_MAGAZINE_SUBSCRIPTION_KEY.$user->getId()); + } catch (InvalidArgumentException $exception) { + $this->logger->warning('There was an error clearing the cached subscribed Magazines of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + } + } + + /** + * @return int[] the ids of the magazines $user moderates + * + * @throws InvalidArgumentException|Exception + */ + public function getCachedUserModeratedMagazines(User $user): array + { + $sql = 'SELECT magazine_id FROM moderator WHERE user_id = :uId'; + if ('test' === $this->kernel->getEnvironment()) { + return $this->fetchSingleColumnAsArray($sql, $user); + } + + return $this->cache->get(self::USER_MAGAZINE_MODERATION_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { + return $this->fetchSingleColumnAsArray($sql, $user); + }); + } + + public function clearCachedUserModeratedMagazines(User $user): void + { + $this->logger->debug('Clearing cached moderated magazines for user {u}', ['u' => $user->username]); + try { + $this->cache->delete(self::USER_MAGAZINE_MODERATION_KEY.$user->getId()); + } catch (InvalidArgumentException $exception) { + $this->logger->warning('There was an error clearing the cached moderated magazines of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + } + } + + /** + * @return int[] the ids of the domains $user is subscribed to + * + * @throws InvalidArgumentException|Exception + */ + public function getCachedUserSubscribedDomains(User $user): array + { + $sql = 'SELECT domain_id FROM domain_subscription WHERE user_id = :uId'; + if ('test' === $this->kernel->getEnvironment()) { + return $this->fetchSingleColumnAsArray($sql, $user); + } + + return $this->cache->get(self::USER_DOMAIN_SUBSCRIPTION_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { + return $this->fetchSingleColumnAsArray($sql, $user); + }); + } + + public function clearCachedUserSubscribedDomains(User $user): void + { + $this->logger->debug('Clearing cached domain subscriptions for user {u}', ['u' => $user->username]); + try { + $this->cache->delete(self::USER_DOMAIN_SUBSCRIPTION_KEY.$user->getId()); + } catch (InvalidArgumentException $exception) { + $this->logger->warning('There was an error clearing the cached subscribed domains of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + } + } + + /** + * @return int[] the ids of the domains $user is subscribed to + * + * @throws InvalidArgumentException|Exception + */ + public function getCachedUserBlocks(User $user): array + { + $sql = 'SELECT blocked_id FROM user_block WHERE blocker_id = :uId'; + if ('test' === $this->kernel->getEnvironment()) { + return $this->fetchSingleColumnAsArray($sql, $user); + } + + return $this->cache->get(self::USER_BLOCKS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { + return $this->fetchSingleColumnAsArray($sql, $user); + }); + } + + public function clearCachedUserBlocks(User $user): void + { + $this->logger->debug('Clearing cached user blocks for user {u}', ['u' => $user->username]); + try { + $this->cache->delete(self::USER_BLOCKS_KEY.$user->getId()); + } catch (InvalidArgumentException $exception) { + $this->logger->warning('There was an error clearing the cached blocked user of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + } + } + + /** + * @return int[] the ids of the domains $user is subscribed to + * + * @throws InvalidArgumentException|Exception + */ + public function getCachedUserMagazineBlocks(User $user): array + { + $sql = 'SELECT magazine_id FROM magazine_block WHERE user_id = :uId'; + if ('test' === $this->kernel->getEnvironment()) { + return $this->fetchSingleColumnAsArray($sql, $user); + } + + return $this->cache->get(self::USER_MAGAZINE_BLOCKS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { + return $this->fetchSingleColumnAsArray($sql, $user); + }); + } + + public function clearCachedUserMagazineBlocks(User $user): void + { + $this->logger->debug('Clearing cached magazine blocks for user {u}', ['u' => $user->username]); + try { + $this->cache->delete(self::USER_MAGAZINE_BLOCKS_KEY.$user->getId()); + } catch (InvalidArgumentException $exception) { + $this->logger->warning('There was an error clearing the cached blocked magazines of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + } + } + + /** + * @return int[] the ids of the domains $user is subscribed to + * + * @throws InvalidArgumentException|Exception + */ + public function getCachedUserDomainBlocks(User $user): array + { + $sql = 'SELECT domain_id FROM domain_block WHERE user_id = :uId'; + if ('test' === $this->kernel->getEnvironment()) { + return $this->fetchSingleColumnAsArray($sql, $user); + } + + return $this->cache->get(self::USER_DOMAIN_BLOCKS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { + return $this->fetchSingleColumnAsArray($sql, $user); + }); + } + + public function clearCachedUserDomainBlocks(User $user): void + { + $this->logger->debug('Clearing cached domain blocks for user {u}', ['u' => $user->username]); + try { + $this->cache->delete(self::USER_DOMAIN_BLOCKS_KEY.$user->getId()); + } catch (InvalidArgumentException $exception) { + $this->logger->warning('There was an error clearing the cached blocked domains of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + } + } + + /** + * @param string $sql the sql to fetch the single column, should contain a 'uId' Parameter + * + * @return int[] + * + * @throws Exception + */ + public function fetchSingleColumnAsArray(string $sql, User $user): array + { + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $result = $stmt->executeQuery(['uId' => $user->getId()]); + $rows = $result->fetchAllAssociative(); + $result = []; + foreach ($rows as $row) { + $result[] = $row[array_key_first($row)]; + } + + $this->logger->debug('Fetching single column row from {sql}: {res}', ['sql' => $sql, 'res' => $result]); + + return $result; + } + + /** + * @return array{sql: string, parameters: array}> + */ + private function getQueryAndParameters(Criteria $criteria, bool $addCursor): array { $includeEntries = Criteria::CONTENT_COMBINED === $criteria->content || Criteria::CONTENT_THREADS === $criteria->content; $parameters = [ @@ -235,6 +506,7 @@ public function findByCriteria(Criteria $criteria): PagerfantaInterface $visibilityClauseM, $visibilityClauseC, $allClause, + $addCursor ? 'c.%cursor%' : '', ]); $postWhere = SqlHelpers::makeWhereString([ @@ -255,44 +527,17 @@ public function findByCriteria(Criteria $criteria): PagerfantaInterface $visibilityClauseM, $visibilityClauseC, $allClause, + $addCursor ? 'c.%cursor%' : '', ]); $outerWhere = SqlHelpers::makeWhereString([ $visibilityClauseU, $deletedClause, $allClauseU, + $addCursor ? 'content.%cursor%' : '', ]); - $orderings = []; - - if ($criteria->stickiesFirst) { - $orderings[] = 'sticky DESC'; - } - - switch ($criteria->sortOption) { - case Criteria::SORT_TOP: - $orderings[] = 'score DESC'; - break; - case Criteria::SORT_HOT: - $orderings[] = 'ranking DESC'; - break; - case Criteria::SORT_COMMENTED: - $orderings[] = 'comment_count DESC'; - break; - case Criteria::SORT_ACTIVE: - $orderings[] = 'last_active DESC'; - break; - default: - } - - switch ($criteria->sortOption) { - case Criteria::SORT_OLD: - $orderings[] = 'created_at ASC'; - break; - case Criteria::SORT_NEW: - default: - $orderings[] = 'created_at DESC'; - } + $orderings = $addCursor ? ['%cursorSort%'] : $this->getOrderings($criteria); $orderBy = 'ORDER BY '.join(', ', $orderings); // only join domain if we are explicitly looking at one @@ -306,13 +551,14 @@ public function findByCriteria(Criteria $criteria): PagerfantaInterface LEFT JOIN magazine m ON c.magazine_id = m.id $postWhere"; + $innerLimit = $addCursor ? 'LIMIT :limit' : ''; $innerSql = ''; if (Criteria::CONTENT_THREADS === $criteria->content) { - $innerSql = "$entrySql $orderBy"; + $innerSql = "$entrySql $orderBy $innerLimit"; } elseif (Criteria::CONTENT_MICROBLOG === $criteria->content) { - $innerSql = "$postSql $orderBy"; + $innerSql = "$postSql $orderBy $innerLimit"; } else { - $innerSql = "$entrySql UNION ALL $postSql"; + $innerSql = "($entrySql $orderBy $innerLimit) UNION ALL ($postSql $orderBy $innerLimit)"; } $sql = "SELECT content.* FROM ($innerSql) content @@ -325,232 +571,90 @@ public function findByCriteria(Criteria $criteria): PagerfantaInterface } $rewritten = SqlHelpers::rewriteArrayParameters($parameters, $sql); - $conn = $this->entityManager->getConnection(); $this->logger->debug('{s} | {p}', ['s' => $sql, 'p' => $parameters]); $this->logger->debug('Rewritten to: {s} | {p}', ['p' => $rewritten['parameters'], 's' => $rewritten['sql']]); - $numResults = null; - if ('test' !== $this->kernel->getEnvironment() && !$criteria->magazine && !$criteria->moderated && !$criteria->favourite && Criteria::TIME_ALL === $criteria->time && Criteria::AP_ALL === $criteria->federation && 'all' === $criteria->type) { - // pre-set the results to 1000 pages for queries not very limited by the parameters so the count query is not being executed - $numResults = 1000 * ($criteria->perPage ?? self::PER_PAGE); - } - $fanta = new Pagerfanta(new NativeQueryAdapter($conn, $rewritten['sql'], $rewritten['parameters'], numOfResults: $numResults, transformer: $this->contentPopulationTransformer, cache: $this->cache)); - $fanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE); - $fanta->setCurrentPage($criteria->page); - - return $fanta; - } - - /** - * @return int[] the ids of the users $user follows - * - * @throws InvalidArgumentException|Exception - */ - public function getCachedUserFollows(User $user): array - { - $sql = 'SELECT following_id FROM user_follow WHERE follower_id = :uId'; - if ('test' === $this->kernel->getEnvironment()) { - return $this->fetchSingleColumnAsArray($sql, $user); - } - - return $this->cache->get(self::USER_FOLLOWS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { - return $this->fetchSingleColumnAsArray($sql, $user); - }); - } - - public function clearCachedUserFollows(User $user): void - { - $this->logger->debug('Clearing cached user follows for user {u}', ['u' => $user->username]); - try { - $this->cache->delete(self::USER_FOLLOWS_KEY.$user->getId()); - } catch (InvalidArgumentException $exception) { - $this->logger->warning('There was an error clearing the cached user follows of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); - } - } - - /** - * @return int[] the ids of the magazines $user is subscribed to - * - * @throws InvalidArgumentException|Exception - */ - public function getCachedUserSubscribedMagazines(User $user): array - { - $sql = 'SELECT magazine_id FROM magazine_subscription WHERE user_id = :uId'; - if ('test' === $this->kernel->getEnvironment()) { - return $this->fetchSingleColumnAsArray($sql, $user); - } - - return $this->cache->get(self::USER_MAGAZINE_SUBSCRIPTION_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { - return $this->fetchSingleColumnAsArray($sql, $user); - }); + return $rewritten; } - public function clearCachedUserSubscribedMagazines(User $user): void + private function getCursorFieldFromCriteria(Criteria $criteria): string { - $this->logger->debug('Clearing cached magazine subscriptions for user {u}', ['u' => $user->username]); - try { - $this->cache->delete(self::USER_MAGAZINE_SUBSCRIPTION_KEY.$user->getId()); - } catch (InvalidArgumentException $exception) { - $this->logger->warning('There was an error clearing the cached subscribed Magazines of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); - } + return match ($criteria->sortOption) { + Criteria::SORT_TOP => 'score', + Criteria::SORT_HOT => 'ranking', + Criteria::SORT_COMMENTED => 'commentCount', + Criteria::SORT_ACTIVE => 'lastActive', + default => 'createdAt', + }; } - /** - * @return int[] the ids of the magazines $user moderates - * - * @throws InvalidArgumentException|Exception - */ - public function getCachedUserModeratedMagazines(User $user): array + private function getCursorWhereFromCriteria(Criteria $criteria): string { - $sql = 'SELECT magazine_id FROM moderator WHERE user_id = :uId'; - if ('test' === $this->kernel->getEnvironment()) { - return $this->fetchSingleColumnAsArray($sql, $user); - } - - return $this->cache->get(self::USER_MAGAZINE_MODERATION_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { - return $this->fetchSingleColumnAsArray($sql, $user); - }); + return match ($criteria->sortOption) { + Criteria::SORT_TOP => 'score < :cursor', + Criteria::SORT_HOT => 'ranking < :cursor', + Criteria::SORT_COMMENTED => 'comment_count < :cursor', + Criteria::SORT_ACTIVE => 'last_active < :cursor', + Criteria::SORT_OLD => 'created_at > :cursor', + default => 'created_at < :cursor', + }; } - public function clearCachedUserModeratedMagazines(User $user): void + private function getCursorWhereInvertedFromCriteria(Criteria $criteria): string { - $this->logger->debug('Clearing cached moderated magazines for user {u}', ['u' => $user->username]); - try { - $this->cache->delete(self::USER_MAGAZINE_MODERATION_KEY.$user->getId()); - } catch (InvalidArgumentException $exception) { - $this->logger->warning('There was an error clearing the cached moderated magazines of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); - } + return match ($criteria->sortOption) { + Criteria::SORT_TOP => 'score >= :cursor', + Criteria::SORT_HOT => 'ranking >= :cursor', + Criteria::SORT_COMMENTED => 'comment_count >= :cursor', + Criteria::SORT_ACTIVE => 'last_active >= :cursor', + Criteria::SORT_OLD => 'created_at <= :cursor', + default => 'created_at >= :cursor', + }; } - /** - * @return int[] the ids of the domains $user is subscribed to - * - * @throws InvalidArgumentException|Exception - */ - public function getCachedUserSubscribedDomains(User $user): array + public function guessInitialCursor(Criteria $criteria): mixed { - $sql = 'SELECT domain_id FROM domain_subscription WHERE user_id = :uId'; - if ('test' === $this->kernel->getEnvironment()) { - return $this->fetchSingleColumnAsArray($sql, $user); - } - - return $this->cache->get(self::USER_DOMAIN_SUBSCRIPTION_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { - return $this->fetchSingleColumnAsArray($sql, $user); - }); + return match ($criteria->sortOption) { + Criteria::SORT_TOP, Criteria::SORT_HOT, Criteria::SORT_COMMENTED => 2147483647, // postgresql max int + Criteria::SORT_OLD => (new \DateTimeImmutable())->setTimestamp(0), + default => new \DateTimeImmutable('now + 1 minute'), + }; } - public function clearCachedUserSubscribedDomains(User $user): void + private function getOrderings(Criteria $criteria): array { - $this->logger->debug('Clearing cached domain subscriptions for user {u}', ['u' => $user->username]); - try { - $this->cache->delete(self::USER_DOMAIN_SUBSCRIPTION_KEY.$user->getId()); - } catch (InvalidArgumentException $exception) { - $this->logger->warning('There was an error clearing the cached subscribed domains of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); - } - } - - /** - * @return int[] the ids of the domains $user is subscribed to - * - * @throws InvalidArgumentException|Exception - */ - public function getCachedUserBlocks(User $user): array - { - $sql = 'SELECT blocked_id FROM user_block WHERE blocker_id = :uId'; - if ('test' === $this->kernel->getEnvironment()) { - return $this->fetchSingleColumnAsArray($sql, $user); - } - - return $this->cache->get(self::USER_BLOCKS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { - return $this->fetchSingleColumnAsArray($sql, $user); - }); - } - - public function clearCachedUserBlocks(User $user): void - { - $this->logger->debug('Clearing cached user blocks for user {u}', ['u' => $user->username]); - try { - $this->cache->delete(self::USER_BLOCKS_KEY.$user->getId()); - } catch (InvalidArgumentException $exception) { - $this->logger->warning('There was an error clearing the cached blocked user of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); - } - } - - /** - * @return int[] the ids of the domains $user is subscribed to - * - * @throws InvalidArgumentException|Exception - */ - public function getCachedUserMagazineBlocks(User $user): array - { - $sql = 'SELECT magazine_id FROM magazine_block WHERE user_id = :uId'; - if ('test' === $this->kernel->getEnvironment()) { - return $this->fetchSingleColumnAsArray($sql, $user); - } - - return $this->cache->get(self::USER_MAGAZINE_BLOCKS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { - return $this->fetchSingleColumnAsArray($sql, $user); - }); - } - - public function clearCachedUserMagazineBlocks(User $user): void - { - $this->logger->debug('Clearing cached magazine blocks for user {u}', ['u' => $user->username]); - try { - $this->cache->delete(self::USER_MAGAZINE_BLOCKS_KEY.$user->getId()); - } catch (InvalidArgumentException $exception) { - $this->logger->warning('There was an error clearing the cached blocked magazines of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); - } - } + $orderings = []; - /** - * @return int[] the ids of the domains $user is subscribed to - * - * @throws InvalidArgumentException|Exception - */ - public function getCachedUserDomainBlocks(User $user): array - { - $sql = 'SELECT domain_id FROM domain_block WHERE user_id = :uId'; - if ('test' === $this->kernel->getEnvironment()) { - return $this->fetchSingleColumnAsArray($sql, $user); + if ($criteria->stickiesFirst) { + $orderings[] = 'sticky DESC'; } - return $this->cache->get(self::USER_DOMAIN_BLOCKS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { - return $this->fetchSingleColumnAsArray($sql, $user); - }); - } - - public function clearCachedUserDomainBlocks(User $user): void - { - $this->logger->debug('Clearing cached domain blocks for user {u}', ['u' => $user->username]); - try { - $this->cache->delete(self::USER_DOMAIN_BLOCKS_KEY.$user->getId()); - } catch (InvalidArgumentException $exception) { - $this->logger->warning('There was an error clearing the cached blocked domains of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + switch ($criteria->sortOption) { + case Criteria::SORT_TOP: + $orderings[] = 'score DESC'; + break; + case Criteria::SORT_HOT: + $orderings[] = 'ranking DESC'; + break; + case Criteria::SORT_COMMENTED: + $orderings[] = 'comment_count DESC'; + break; + case Criteria::SORT_ACTIVE: + $orderings[] = 'last_active DESC'; + break; + default: } - } - /** - * @param string $sql the sql to fetch the single column, should contain a 'uId' Parameter - * - * @return int[] - * - * @throws Exception - */ - public function fetchSingleColumnAsArray(string $sql, User $user): array - { - $conn = $this->entityManager->getConnection(); - $stmt = $conn->prepare($sql); - $result = $stmt->executeQuery(['uId' => $user->getId()]); - $rows = $result->fetchAllAssociative(); - $result = []; - foreach ($rows as $row) { - $result[] = $row[array_key_first($row)]; + switch ($criteria->sortOption) { + case Criteria::SORT_OLD: + $orderings[] = 'created_at ASC'; + break; + case Criteria::SORT_NEW: + default: + $orderings[] = 'created_at DESC'; } - $this->logger->debug('Fetching single column row from {sql}: {res}', ['sql' => $sql, 'res' => $result]); - - return $result; + return $orderings; } } diff --git a/src/Twig/Components/CursorPaginationComponent.php b/src/Twig/Components/CursorPaginationComponent.php new file mode 100644 index 0000000000..7461e1de30 --- /dev/null +++ b/src/Twig/Components/CursorPaginationComponent.php @@ -0,0 +1,16 @@ +frontExtension->frontOptionsUrl( 'content', 'threads', $magazine instanceof Magazine ? 'front_magazine' : 'front', - ['name' => $magazine?->name, 'p' => null], + ['name' => $magazine?->name, 'p' => null, 'cursor' => null], ); } @@ -61,7 +61,7 @@ public function navbarCombinedUrl(?Magazine $magazine): string return $this->frontExtension->frontOptionsUrl( 'content', 'combined', $magazine instanceof Magazine ? 'front_magazine' : 'front', - ['name' => $magazine?->name, 'p' => null], + ['name' => $magazine?->name, 'p' => null, 'cursor' => null], ); } @@ -99,7 +99,7 @@ public function navbarPostsUrl(?Magazine $magazine): string return $this->frontExtension->frontOptionsUrl( 'content', 'microblog', $magazine instanceof Magazine ? 'front_magazine' : 'front', - ['name' => $magazine?->name, 'p' => null, 'type' => null], + ['name' => $magazine?->name, 'p' => null, 'cursor' => null, 'type' => null], ); } diff --git a/src/Twig/Runtime/UrlExtensionRuntime.php b/src/Twig/Runtime/UrlExtensionRuntime.php index d8ee916ee3..90e47c4db1 100644 --- a/src/Twig/Runtime/UrlExtensionRuntime.php +++ b/src/Twig/Runtime/UrlExtensionRuntime.php @@ -296,4 +296,14 @@ public function mentionUrl(string $username): string { return $this->mentionManager->getRoute([$username])[0]; } + + public function getCursorUrlValue(mixed $cursor): mixed + { + if ($cursor instanceof \DateTime || $cursor instanceof \DateTimeImmutable) { + return $cursor->format(DATE_ATOM); + // return $cursor->getTimestamp(); + } + + return $cursor; + } } diff --git a/src/Utils/SqlHelpers.php b/src/Utils/SqlHelpers.php index c23b178846..fd8d8f1c5c 100644 --- a/src/Utils/SqlHelpers.php +++ b/src/Utils/SqlHelpers.php @@ -7,6 +7,8 @@ use App\Entity\MagazineBlock; use App\Entity\User; use App\Entity\UserBlock; +use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\EntityManagerInterface; class SqlHelpers @@ -44,7 +46,7 @@ public static function makeWhereString(array $whereClauses): string * which are not supported by sql directly. Keep in mind that postgresql has a limit of 65k parameters * and each one of the array values counts as one parameter (because it only works that way). * - * @return array{'sql': string, 'parameters': array}> + * @return array{sql: string, parameters: array}> */ public static function rewriteArrayParameters(array $parameters, string $sql): array { @@ -77,6 +79,23 @@ public static function rewriteArrayParameters(array $parameters, string $sql): a ]; } + public static function invertOrderings(array $orderings): array + { + $newOrderings = []; + foreach ($orderings as $ordering) { + if (str_contains($ordering, 'DESC')) { + $newOrderings[] = str_replace('DESC', 'ASC', $ordering); + } elseif (str_contains($ordering, 'ASC')) { + $newOrderings[] = str_replace('ASC', 'DESC', $ordering); + } else { + // neither ASC nor DESC means ASC + $newOrderings[] = $ordering.' DESC'; + } + } + + return $newOrderings; + } + public function getBlockedMagazinesDql(User $user): string { return $this->entityManager->createQueryBuilder() @@ -98,4 +117,17 @@ public function getBlockedUsersDql(User $user): string ->setParameter('user', $user) ->getDql(); } + + public static function getSqlType(mixed $value): mixed + { + if ($value instanceof \DateTimeImmutable) { + return Types::DATETIMETZ_IMMUTABLE; + } elseif ($value instanceof \DateTime) { + return Types::DATETIMETZ_MUTABLE; + } elseif (\is_int($value)) { + return Types::INTEGER; + } + + return ParameterType::STRING; + } } diff --git a/templates/components/cursor_pagination.html.twig b/templates/components/cursor_pagination.html.twig new file mode 100644 index 0000000000..817ec89e57 --- /dev/null +++ b/templates/components/cursor_pagination.html.twig @@ -0,0 +1,19 @@ +
+ {% if isForward is same as true %} + {% if pagination.haveToPaginate %} + + {% endif %} + {% else %} + {% if pagination.hasPreviousPage() %} + + {% endif %} + {% endif %} +
diff --git a/templates/entry/_options.html.twig b/templates/entry/_options.html.twig index 0562dfe537..7df0c479fe 100644 --- a/templates/entry/_options.html.twig +++ b/templates/entry/_options.html.twig @@ -11,31 +11,31 @@