diff --git a/assets/controllers/infinite_scroll_controller.js b/assets/controllers/infinite_scroll_controller.js index 344a3f2944..b7e89cc7ad 100644 --- a/assets/controllers/infinite_scroll_controller.js +++ b/assets/controllers/infinite_scroll_controller.js @@ -22,9 +22,20 @@ export default class extends Controller { try { this.loadingValue = true; - const paginationElem = this.paginationTarget.getElementsByClassName('pagination__item--current-page')[0].nextElementSibling; - if (paginationElem.classList.contains('pagination__item--disabled')) { - throw new Error('No more pages'); + const cursorPaginationElement = this.paginationTarget.getElementsByClassName('cursor-pagination'); + let paginationElem = null; + if (cursorPaginationElement.length) { + console.log(cursorPaginationElement[0]); + const button = cursorPaginationElement[0].getElementsByTagName('a'); + if (!button.length) { + throw new Error('No more pages'); + } + paginationElem = button[0]; + } else { + paginationElem = this.paginationTarget.getElementsByClassName('pagination__item--current-page')[0].nextElementSibling; + if (paginationElem.classList.contains('pagination__item--disabled')) { + throw new Error('No more pages'); + } } if (window.infiniteScrollUrls.includes(paginationElem.href)) { 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/config/mbin_routes/combined_api.yaml b/config/mbin_routes/combined_api.yaml index 00a93e0780..940e09a882 100644 --- a/config/mbin_routes/combined_api.yaml +++ b/config/mbin_routes/combined_api.yaml @@ -1,3 +1,17 @@ +api_combined_cursor: + controller: App\Controller\Api\Combined\CombinedRetrieveApi::cursorCollection + path: /api/combined/2.0 + methods: [ GET ] + format: json + +api_combined_user_cursor: + controller: App\Controller\Api\Combined\CombinedRetrieveApi::cursorUserCollection + path: /api/combined/2.0/{contentType} + requirements: + contentType: subscribed|moderated|favourited + methods: [ PUT ] + format: json + api_combined: controller: App\Controller\Api\Combined\CombinedRetrieveApi::collection path: /api/combined diff --git a/src/Controller/Api/BaseApi.php b/src/Controller/Api/BaseApi.php index 7aa2ca905f..31a6ac0b55 100644 --- a/src/Controller/Api/BaseApi.php +++ b/src/Controller/Api/BaseApi.php @@ -41,6 +41,7 @@ use App\Factory\PostFactory; use App\Factory\UserFactory; use App\Form\Constraint\ImageConstraint; +use App\Pagination\Cursor\CursorPaginationInterface; use App\Repository\BookmarkListRepository; use App\Repository\BookmarkRepository; use App\Repository\Criteria; @@ -55,6 +56,7 @@ use App\Repository\ReputationRepository; use App\Repository\TagLinkRepository; use App\Repository\UserRepository; +use App\Schema\CursorPaginationSchema; use App\Schema\PaginationSchema; use App\Service\BookmarkManager; use App\Service\InstanceManager; @@ -233,6 +235,14 @@ public function serializePaginated(array $serializedItems, PagerfantaInterface $ ]; } + public function serializeCursorPaginated(array $serializedItems, CursorPaginationInterface $pagerfanta): array + { + return [ + 'items' => $serializedItems, + 'pagination' => new CursorPaginationSchema($pagerfanta), + ]; + } + public function serializeContentInterface(ContentInterface $content, bool $forceVisible = false): mixed { $toReturn = null; diff --git a/src/Controller/Api/Combined/CombinedRetrieveApi.php b/src/Controller/Api/Combined/CombinedRetrieveApi.php index 9286b8f10a..05eb3b40fb 100644 --- a/src/Controller/Api/Combined/CombinedRetrieveApi.php +++ b/src/Controller/Api/Combined/CombinedRetrieveApi.php @@ -11,13 +11,16 @@ use App\Entity\Post; use App\Entity\User; use App\PageView\ContentPageView; +use App\Pagination\Cursor\CursorPaginationInterface; use App\Repository\ContentRepository; use App\Repository\Criteria; +use App\Schema\CursorPaginationSchema; use App\Schema\Errors\TooManyRequestsErrorSchema; use App\Schema\Errors\UnauthorizedErrorSchema; use App\Schema\PaginationSchema; use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; +use Pagerfanta\PagerfantaInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; @@ -125,7 +128,12 @@ public function collection( #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, ): JsonResponse { - return $this->generateResponse($apiReadLimiter, $anonymousApiReadLimiter, $p, $security, $sort, $time, $federation, $perPage, $contentRepository); + $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); + $criteria = $this->getCriteria($p, $security, $sort, $time, $federation, $perPage, $contentRepository, null); + + $content = $contentRepository->findByCriteria($criteria); + + return $this->serializeContent($content, $headers); } #[OA\Response( @@ -228,28 +236,237 @@ public function userCollection( #[MapQueryParameter] ?string $federation, string $collectionType, ): JsonResponse { - return $this->generateResponse($apiReadLimiter, $anonymousApiReadLimiter, $p, $security, $sort, $time, $federation, $perPage, $contentRepository, $collectionType); + $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); + $criteria = $this->getCriteria($p, $security, $sort, $time, $federation, $perPage, $contentRepository, $collectionType); + + $content = $contentRepository->findByCriteria($criteria); + + return $this->serializeContent($content, $headers); } - private function generateResponse( + #[OA\Response( + response: 200, + description: 'A cursor paginated list of combined entries and posts filtered by the query parameters', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: new Model(type: ContentResponseDto::class)) + ), + new OA\Property( + property: 'pagination', + ref: new Model(type: CursorPaginationSchema::class) + ), + ], + type: 'object' + ) + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'cursor', + description: 'The cursor', + in: 'query', + schema: new OA\Schema(type: 'string', default: null) + )] + #[OA\Parameter( + name: 'perPage', + description: 'Number of content items to retrieve per page', + in: 'query', + schema: new OA\Schema(type: 'integer', default: ContentRepository::PER_PAGE, maximum: self::MAX_PER_PAGE, minimum: self::MIN_PER_PAGE) + )] + #[OA\Parameter( + name: 'sort', + description: 'Sort method to use when retrieving content', + in: 'query', + schema: new OA\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS) + )] + #[OA\Parameter( + name: 'time', + description: 'Max age of retrieved content', + in: 'query', + schema: new OA\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN) + )] + #[OA\Parameter( + name: 'lang[]', + description: 'Language(s) of content to return', + in: 'query', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(type: 'string', default: null, maxLength: 3, minLength: 2) + ), + explode: true, + allowReserved: true + )] + #[OA\Parameter( + name: 'usePreferredLangs', + description: 'Filter by a user\'s preferred languages? (Requires authentication and takes precedence over lang[])', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false), + )] + #[OA\Parameter( + name: 'federation', + description: 'What type of federated entries to retrieve', + in: 'query', + schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) + )] + #[OA\Tag(name: 'combined')] + public function cursorCollection( + RateLimiterFactory $apiReadLimiter, + RateLimiterFactory $anonymousApiReadLimiter, + Security $security, + ContentRepository $contentRepository, + #[MapQueryParameter] ?string $cursor, + #[MapQueryParameter] ?int $perPage, + #[MapQueryParameter] ?string $sort, + #[MapQueryParameter] ?string $time, + #[MapQueryParameter] ?string $federation, + ): JsonResponse { + $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); + $criteria = $this->getCriteria(1, $security, $sort, $time, $federation, $perPage, $contentRepository, null); + $currentCursor = $this->getCursor($contentRepository, $criteria, $cursor); + + $content = $contentRepository->findByCriteriaCursored($criteria, $currentCursor); + + return $this->serializeContentCursored($content, $headers); + } + + #[OA\Response( + response: 200, + description: 'A cursor paginated list of combined entries and posts from subscribed magazines and users filtered by the query parameters', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: new Model(type: ContentResponseDto::class)) + ), + new OA\Property( + property: 'pagination', + ref: new Model(type: CursorPaginationSchema::class) + ), + ], + type: 'object' + ) + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'cursor', + description: 'The cursor', + in: 'query', + schema: new OA\Schema(type: 'string', default: null) + )] + #[OA\Parameter( + name: 'perPage', + description: 'Number of content items to retrieve per page', + in: 'query', + schema: new OA\Schema(type: 'integer', default: ContentRepository::PER_PAGE, maximum: self::MAX_PER_PAGE, minimum: self::MIN_PER_PAGE) + )] + #[OA\Parameter( + name: 'sort', + description: 'Sort method to use when retrieving content', + in: 'query', + schema: new OA\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS) + )] + #[OA\Parameter( + name: 'time', + description: 'Max age of retrieved content', + in: 'query', + schema: new OA\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN) + )] + #[OA\Parameter( + name: 'lang[]', + description: 'Language(s) of content to return', + in: 'query', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(type: 'string', default: null, maxLength: 3, minLength: 2) + ), + explode: true, + allowReserved: true + )] + #[OA\Parameter( + name: 'usePreferredLangs', + description: 'Filter by a user\'s preferred languages? (Requires authentication and takes precedence over lang[])', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false), + )] + #[OA\Parameter( + name: 'federation', + description: 'What type of federated entries to retrieve', + in: 'query', + schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) + )] + #[OA\Tag(name: 'combined')] + #[\Nelmio\ApiDocBundle\Attribute\Security(name: 'oauth2', scopes: ['read'])] + #[IsGranted('ROLE_OAUTH2_READ')] + public function cursorUserCollection( RateLimiterFactory $apiReadLimiter, RateLimiterFactory $anonymousApiReadLimiter, - ?int $p, Security $security, - ?string $sort, - ?string $time, - ?string $federation, - ?int $perPage, ContentRepository $contentRepository, - ?string $collectionType = null, + #[MapQueryParameter] ?string $cursor, + #[MapQueryParameter] ?int $perPage, + #[MapQueryParameter] ?string $sort, + #[MapQueryParameter] ?string $time, + #[MapQueryParameter] ?string $federation, + string $collectionType, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); + $criteria = $this->getCriteria(1, $security, $sort, $time, $federation, $perPage, $contentRepository, $collectionType); + $currentCursor = $this->getCursor($contentRepository, $criteria, $cursor); + + $content = $contentRepository->findByCriteriaCursored($criteria, $currentCursor); + + return $this->serializeContentCursored($content, $headers); + } + + private function getCriteria(?int $p, Security $security, ?string $sort, ?string $time, ?string $federation, ?int $perPage, ContentRepository $contentRepository, ?string $collectionType): ContentPageView + { $criteria = new ContentPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); $criteria->setFederation($federation ?? Criteria::AP_ALL); $this->handleLanguageCriteria($criteria); - $criteria->content = Criteria::CONTENT_THREADS; + $criteria->content = Criteria::CONTENT_COMBINED; $criteria->perPage = $perPage; $user = $security->getUser(); if ($user instanceof User) { @@ -268,11 +485,13 @@ private function generateResponse( break; } - $content = $contentRepository->findByCriteria($criteria); - $this->handleLanguageCriteria($criteria); + return $criteria; + } + private function serializeContent(PagerfantaInterface $content, array $headers): JsonResponse + { $result = []; - foreach ($content->getCurrentPageResults() as $item) { + foreach ($content as $item) { if ($item instanceof Entry) { $this->handlePrivateContent($item); $result[] = new ContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); @@ -284,4 +503,37 @@ private function generateResponse( return new JsonResponse($this->serializePaginated($result, $content), headers: $headers); } + + private function serializeContentCursored(CursorPaginationInterface $content, array $headers): JsonResponse + { + $result = []; + foreach ($content as $item) { + if ($item instanceof Entry) { + $this->handlePrivateContent($item); + $result[] = new ContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + } elseif ($item instanceof Post) { + $this->handlePrivateContent($item); + $result[] = new ContentResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + } + } + + return new JsonResponse($this->serializeCursorPaginated($result, $content), headers: $headers); + } + + /** + * @throws \DateMalformedStringException + */ + private function getCursor(ContentRepository $contentRepository, ContentPageView $criteria, ?string $cursor): int|\DateTime|\DateTimeImmutable + { + $initialCursor = $contentRepository->guessInitialCursor($criteria); + if ($initialCursor instanceof \DateTime || $initialCursor instanceof \DateTimeImmutable) { + $currentCursor = null !== $cursor ? new \DateTimeImmutable($cursor) : $initialCursor; + } elseif (\is_int($initialCursor)) { + $currentCursor = null !== $cursor ? \intval($cursor) : $initialCursor; + } else { + throw new \LogicException(\get_class($initialCursor).' is not accounted for'); + } + + return $currentCursor; + } } 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..ae58270bb4 --- /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->hasNextPage() || $this->hasPreviousPage(); + } + + public function hasNextPage(): bool + { + return $this->maxPerPage === \sizeof($this->currentPageResults ?? [...$this->getCurrentPageResults()]); + } + + 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/Schema/CursorPaginationSchema.php b/src/Schema/CursorPaginationSchema.php new file mode 100644 index 0000000000..86e11ea60e --- /dev/null +++ b/src/Schema/CursorPaginationSchema.php @@ -0,0 +1,48 @@ +currentCursor = $this->cursorToString($pagerfanta->getCurrentCursor()); + $this->nextCursor = $pagerfanta->hasNextPage() ? $this->cursorToString($pagerfanta->getNextPage()) : null; + $this->previousCursor = $pagerfanta->hasPreviousPage() ? $this->cursorToString($pagerfanta->getPreviousPage()) : null; + $this->perPage = $pagerfanta->getMaxPerPage(); + } + + private function cursorToString(mixed $cursor): string + { + if ($cursor instanceof \DateTime || $cursor instanceof \DateTimeImmutable) { + return $cursor->format(DATE_ATOM); + } + + return $cursor->__toString(); + } + + public function jsonSerialize(): mixed + { + return [ + 'currentCursor' => $this->currentCursor, + 'nextCursor' => $this->nextCursor, + 'previousCursor' => $this->previousCursor, + 'perPage' => $this->perPage, + ]; + } +} diff --git a/src/Service/FeedManager.php b/src/Service/FeedManager.php index c7bd9b2446..7f41c91b0e 100644 --- a/src/Service/FeedManager.php +++ b/src/Service/FeedManager.php @@ -46,7 +46,7 @@ public function getFeed(Request $request): FeedInterface $criteria = $this->getCriteriaFromRequest($request); $feed = $this->createFeed($criteria); - $content = $this->contentRepository->findByCriteria($criteria); + $content = $this->contentRepository->findByCriteriaCursored($criteria, $this->contentRepository->guessInitialCursor($criteria)); foreach ($this->getItems($content->getCurrentPageResults()) as $item) { $feed->add($item); 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..81b62049fa 100644 --- a/src/Twig/Runtime/UrlExtensionRuntime.php +++ b/src/Twig/Runtime/UrlExtensionRuntime.php @@ -296,4 +296,13 @@ 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; + } } 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..565f0e71d7 --- /dev/null +++ b/templates/components/cursor_pagination.html.twig @@ -0,0 +1,21 @@ +
+ {% if isForward is same as true %} +
+ {% if pagination.hasNextPage() %} + + {{ 'next_page'|trans|capitalize }} + + {% else %} + {{ 'reached_end'|trans }} + {% 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 @@