Skip to content

Commit

Permalink
Support offset
Browse files Browse the repository at this point in the history
  • Loading branch information
cerbero90 committed Jan 30, 2024
1 parent 070ec30 commit d78b695
Show file tree
Hide file tree
Showing 9 changed files with 83 additions and 53 deletions.
37 changes: 5 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ The variable `$source` in our examples represents any [source](#-sources) that p
```php
$lazyCollection = LazyJsonPages::from($source)
->totalItems('pagination.total_items')
->perPage(20)
->offset()
->collect('results.*');
```
Expand Down Expand Up @@ -157,44 +156,18 @@ LazyJsonPages::from($source)->lastPage('X-Last-Page');

APIs can expose their length information in the form of numbers (`total_pages: 10`) or URIs (`last_page: "https://example.com?page=10"`), Lazy JSON Pages supports both.

When dealing with a lot of data, it may be a good idea to fetch only 1 item on the first page and leverage the length information on that page to calculate the total number of pages/items without having to load all the other items of that page.

We can do that by calling `perPage()` with:
- the number of items that we want to show per page (we can also override the pagination default)
- the query parameter or header that holds the number of items per page

```php
// indicate that the number of items per page is defined by the `limit` query parameter, e.g. ?limit=50
LazyJsonPages::from($source)
->totalItems('pagination.total_items')
->perPage(30, 'limit');

// indicate that the number of items per page is defined by the `X-Limit` header
LazyJsonPages::from($source)
->totalItems('pagination.total_items')
->perPage(30, header: 'X-Limit');
```

Some APIs may not allow to request only 1 item per page, in these cases we can specify how many items should be loaded on the first page as third argument:
If the pagination works with an offset, we can configure it with the `offset()` method. The value of the offset will be calculated based on the number of items present on the first page:

```php
// indicate that the offset is defined by the `offset` query parameter, e.g. ?offset=50
LazyJsonPages::from($source)
->totalItems('pagination.total_items')
->perPage(30, 'limit', 5);
->offset();

// indicate that the offset is defined by the `skip` query parameter, e.g. ?skip=50
LazyJsonPages::from($source)
->totalItems('pagination.total_items')
->perPage(30, header: 'X-Limit', firstPageItems: 5);
```

We can leverage the `perPage()` strategy with all the length-aware methods seen before:

```php
LazyJsonPages::from($source)->totalPages('pagination.total_pages')->perPage(30, 'limit');

LazyJsonPages::from($source)->totalItems('pagination.total_items')->perPage(30, 'limit');

LazyJsonPages::from($source)->lastPage('pagination.last_page')->perPage(30, 'limit');
->offset('skip');
```


Expand Down
8 changes: 7 additions & 1 deletion src/Concerns/ResolvesPages.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace Cerbero\LazyJsonPages\Concerns;

use Cerbero\LazyJsonPages\Exceptions\InvalidPageInPathException;
Expand Down Expand Up @@ -49,6 +51,10 @@ protected function pageFromParsedUri(array $parsedUri, bool $onlyNumerics = true
*/
protected function uriForPage(UriInterface $uri, string $page): UriInterface
{
if ($key = $this->config->offsetKey) {
return Uri::withQueryValue($uri, $key, strval(($page - $this->config->firstPage) * $this->itemsPerPage));
}

if (!$pattern = $this->config->pageInPath) {
return Uri::withQueryValue($uri, $this->config->pageName, $page);
}
Expand All @@ -57,6 +63,6 @@ protected function uriForPage(UriInterface $uri, string $page): UriInterface
throw new InvalidPageInPathException($path, $pattern);
}

return $uri->withPath(substr_replace($path, $page, $matches[1][1], strlen($matches[1][0])));
return $uri->withPath(substr_replace($path, $page, (int) $matches[1][1], strlen($matches[1][0])));
}
}
6 changes: 6 additions & 0 deletions src/Concerns/YieldsPaginatedItems.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace Cerbero\LazyJsonPages\Concerns;

use Cerbero\JsonParser\JsonParser;
Expand All @@ -19,6 +21,7 @@ trait YieldsPaginatedItems
*/
protected function yieldItemsAndReturnKey(ResponseInterface $response, string $key): Generator
{
$itemsPerPage = 0;
$pointers = [$this->config->pointer];

if (($value = $response->getHeaderLine($key)) === '') {
Expand All @@ -30,9 +33,12 @@ protected function yieldItemsAndReturnKey(ResponseInterface $response, string $k
$value = $item->value;
} else {
yield $item;
++$itemsPerPage;
}
}

$this->itemsPerPage ??= $itemsPerPage;

return $value;
}

Expand Down
1 change: 1 addition & 0 deletions src/Dtos/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public function __construct(
public readonly ?Closure $nextPage = null,
public readonly ?string $nextPageKey = null,
public readonly ?int $lastPage = null,
public readonly ?string $offsetKey = null,
public readonly int $async = 3,
public readonly int $attempts = 3,
public readonly ?Closure $backoff = null,
Expand Down
10 changes: 10 additions & 0 deletions src/LazyJsonPages.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,16 @@ public function lastPage(Closure|string $key): self
return $this;
}

/**
* Set the offset.
*/
public function offset(string $key = 'offset'): self
{
$this->config['offsetKey'] = $key;

return $this;
}

/**
* Fetch pages synchronously.
*/
Expand Down
20 changes: 8 additions & 12 deletions src/Paginations/LengthAwarePagination.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
use Closure;
use Generator;
use Illuminate\Support\LazyCollection;
use Psr\Http\Message\UriInterface;

/**
* The abstract implementation of a pagination that is aware of its length.
Expand Down Expand Up @@ -44,11 +43,10 @@ protected function yieldItemsUntilKey(string $key, ?Closure $callback = null): G
*
* @return Generator<int, mixed>
*/
protected function yieldItemsUntilPage(int $page, ?UriInterface $uri = null): Generator
protected function yieldItemsUntilPage(int $page): Generator
{
$uri ??= $this->source->request()->getUri();
$firstPageAlreadyFetched = strval($uri) == strval($this->source->request()->getUri());
$chunkedPages = $this->chunkPages($page, $firstPageAlreadyFetched);
$uri = $this->source->request()->getUri();
$chunkedPages = $this->chunkPages($page);

foreach ($this->fetchPagesAsynchronously($chunkedPages, $uri) as $page) {
yield from $this->yieldItemsFrom($page);
Expand All @@ -60,15 +58,13 @@ protected function yieldItemsUntilPage(int $page, ?UriInterface $uri = null): Ge
*
* @return LazyCollection<int, LazyCollection<int, int>>
*/
protected function chunkPages(int $pages, bool $shouldSkipFirstPage): LazyCollection
protected function chunkPages(int $pages): LazyCollection
{
if ($pages == 0 || ($pages == 1 && $shouldSkipFirstPage)) {
return LazyCollection::empty();
}

$firstPage = $shouldSkipFirstPage ? $this->config->firstPage + 1 : $this->config->firstPage;
$firstPage = $this->config->firstPage + 1;
$lastPage = $this->config->firstPage == 0 ? $pages - 1 : $pages;

return LazyCollection::range($firstPage, $lastPage)->chunk($this->config->async);
return $firstPage > $lastPage
? LazyCollection::empty()
: LazyCollection::range($firstPage, $lastPage)->chunk($this->config->async);
}
}
5 changes: 5 additions & 0 deletions src/Paginations/Pagination.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ abstract class Pagination implements IteratorAggregate
*/
public readonly Book $book;

/**
* The number of items per page.
*/
protected readonly int $itemsPerPage;

/**
* Determine whether the configuration matches this pagination.
*/
Expand Down
10 changes: 2 additions & 8 deletions src/Paginations/TotalItemsAwarePagination.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,13 @@ public function matches(): bool
*/
public function getIterator(): Traversable
{
$perPage = 0;
$generator = $this->yieldItemsAndReturnKey($this->source->response(), $this->config->totalItemsKey);

foreach ($generator as $item) {
yield $item;
++$perPage;
}
yield from $generator = $this->yieldItemsAndReturnKey($this->source->response(), $this->config->totalItemsKey);

if (!is_numeric($totalItems = $generator->getReturn())) {
throw new InvalidKeyException($this->config->totalItemsKey);
}

$totalPages = $perPage > 0 ? (int) ceil(intval($totalItems) / $perPage) : 0;
$totalPages = $this->itemsPerPage > 0 ? (int) ceil(intval($totalItems) / $this->itemsPerPage) : 0;

yield from $this->yieldItemsUntilPage($totalPages);
}
Expand Down
39 changes: 39 additions & 0 deletions tests/Feature/StructureTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,42 @@
'https://example.com/users' => 'lengthAware/page1.json',
]);
})->throws(InvalidPageInPathException::class, 'The pattern [/(\d+)(?!.*\d)/] could not capture any page from the path [/users].');

it('supports paginations with offset', function () {
$lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users')
->offset()
->totalPages('meta.total_pages')
->collect('data.*');

expect($lazyCollection)->toLoadItemsViaRequests([
'https://example.com/api/v1/users' => 'lengthAware/page1.json',
'https://example.com/api/v1/users?offset=5' => 'lengthAware/page2.json',
'https://example.com/api/v1/users?offset=10' => 'lengthAware/page3.json',
]);
});

it('supports paginations with limit and offset', function () {
$lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users?limit=5')
->offset()
->totalPages('meta.total_pages')
->collect('data.*');

expect($lazyCollection)->toLoadItemsViaRequests([
'https://example.com/api/v1/users?limit=5' => 'lengthAware/page1.json',
'https://example.com/api/v1/users?limit=5&offset=5' => 'lengthAware/page2.json',
'https://example.com/api/v1/users?limit=5&offset=10' => 'lengthAware/page3.json',
]);
});

it('supports paginations with custom offset', function () {
$lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users')
->offset('skip')
->totalPages('meta.total_pages')
->collect('data.*');

expect($lazyCollection)->toLoadItemsViaRequests([
'https://example.com/api/v1/users' => 'lengthAware/page1.json',
'https://example.com/api/v1/users?skip=5' => 'lengthAware/page2.json',
'https://example.com/api/v1/users?skip=10' => 'lengthAware/page3.json',
]);
});

0 comments on commit d78b695

Please sign in to comment.