Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,30 @@ while a binary collation matches exactly. Declare the collation the endpoint pro
`ValueKind` is the kind a value is validated against. For the multivalued operators (`=in=`, `=out=`) every value is
checked.

| Kind | Matches |
|-----------------------|----------------------------------------------------------------------------------|
| `ValueKind::UUID` | A hyphenated UUID in either letter case, `01900000-0000-7099-8000-000000000001`. |
| `ValueKind::STRING` | A non-empty string. |
| `ValueKind::INTEGER` | An optionally signed sequence of digits, `-7` or `42`. |
| `ValueKind::DATETIME` | An ISO-8601 date or date-time, `2023-01-15T10:30:00Z`. |
| Kind | Matches |
|-----------------------|-----------------------------------------------------------------------------------|
| `ValueKind::UUID` | A hyphenated UUID in either letter case, `01900000-0000-7099-8000-000000000001`. |
| `ValueKind::STRING` | A non-empty string. |
| `ValueKind::INTEGER` | An optionally signed sequence of digits that fits in a PHP `int`, `-7` or `42`. |
| `ValueKind::DATETIME` | A calendar date or date-time in ISO-8601, `2023-01-15` or `2023-01-15T10:30:00Z`. |

No kind accepts a trailing line break, so `42\n` or a UUID followed by `\n` is refused, and a UUID in URN form
(`urn:uuid:...`) is refused as well. An `INTEGER` is compared as text against `PHP_INT_MIN` and `PHP_INT_MAX`, so
`9223372036854775807` and `-9223372036854775808` match while `9223372036854775808` does not, and the consumer can cast
every accepted value to `int` without overflow. Leading zeros remain accepted.

A `DATETIME` is checked against the calendar and the clock, not only its shape:

| Part | Accepted |
|----------|-------------------------------------------------------------------------------------------------|
| Date | A day that exists in the calendar, from `0001-01-01` to `9999-12-31` (`2026-02-30` is refused). |
| Time | An hour from `00` to `23`, a minute and a second from `00` to `59`. |
| Fraction | Optional, from one to nine digits. |
| Offset | Optional, `Z` or `±HH:MM` with the hours from `00` to `23` and the minutes from `00` to `59`. |
| Instant | Shifted to UTC, between `0001-01-01T00:00:00Z` and `9999-12-31T23:59:59.999999999Z`. |

A date-time without an offset is read as UTC. So `9999-12-31T23:59:59Z` matches, while `9999-12-31T22:00:00-05:00`
does not, because in UTC it falls in the year 10000.

### Sorting

Expand Down Expand Up @@ -214,6 +232,15 @@ $page->metadata(); # The JSON:API meta contents.
$page->totalPages(); # 24
```

The page number runs from `1` up to a ceiling that depends on the page size: the last page whose zero-based offset
stays below `PHP_INT_MAX`, that is `intdiv(PHP_INT_MAX - 1, size) + 1`. With the default size of 20 the ceiling is
`461168601842738791`, and with a size of 1 it is `PHP_INT_MAX` itself. A page number above the ceiling raises
`PageNumberOutOfRange`, the same exception as a page number below 1, so `page[number]=9223372036854775807` or
`page[number]=99999999999999999999` is refused before any offset is computed. On the offset side,
`Offset\Pagination::fromOffset` refuses an offset equal to `PHP_INT_MAX` with `OffsetOutOfRange`, so the page number
derived back from any accepted offset also fits in an `int`. Every navigation target, the last page included, stays
within the ceiling, and the total pages are computed in integer arithmetic.

Use `Offset\Criteria::slice` instead of `page` when the total is unknown. The consumer fetches one element beyond the
page size, and the `Offset\Slice` trims it and reads its presence as the next-page hint.

Expand Down
32 changes: 24 additions & 8 deletions src/Exceptions/OffsetOutOfRange.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,43 @@
/**
* Raised when a requested offset falls outside the valid range.
*
* The offset is zero-based, so it must be greater than or equal to 0.
* The offset is zero-based, so it must be greater than or equal to 0. The one-based page number
* derived from it must also fit in an integer, so it must be less than the largest integer.
*/
final class OffsetOutOfRange extends InvalidArgumentException implements HttpQueryException
{
private const string REASON_TEMPLATE = 'Offset <%d> must be greater than or equal to 0.';
private const string ABOVE_MAXIMUM = 'Offset <%d> must be less than or equal to %d.';
private const string BELOW_MINIMUM = 'Offset <%d> must be greater than or equal to 0.';

private function __construct(int $offset)
private function __construct(string $reason)
{
$template = OffsetOutOfRange::REASON_TEMPLATE;

parent::__construct(message: sprintf($template, $offset));
parent::__construct(message: $reason);
}

/**
* Creates an OffsetOutOfRange from the offending offset.
*
* @param int $offset The offset that fell outside the valid range.
* @param int $offset The offset that fell below the minimum of 0.
* @return OffsetOutOfRange The composed exception describing the invalid offset.
*/
public static function from(int $offset): OffsetOutOfRange
{
return new OffsetOutOfRange(offset: $offset);
$template = OffsetOutOfRange::BELOW_MINIMUM;

return new OffsetOutOfRange(reason: sprintf($template, $offset));
}

/**
* Creates an OffsetOutOfRange signaling that the offset exceeds the largest offset accepted.
*
* @param int $offset The offset that exceeded the maximum.
* @param int $maximum The largest offset whose one-based page number fits in an integer.
* @return OffsetOutOfRange The composed exception describing the oversized offset.
*/
public static function aboveMaximum(int $offset, int $maximum): OffsetOutOfRange
{
$template = OffsetOutOfRange::ABOVE_MAXIMUM;

return new OffsetOutOfRange(reason: sprintf($template, $offset, $maximum));
}
}
32 changes: 24 additions & 8 deletions src/Exceptions/PageNumberOutOfRange.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,43 @@
/**
* Raised when a requested page number falls outside the valid range.
*
* The page number is one-based, so it must be greater than or equal to 1.
* The page number is one-based, so it must be greater than or equal to 1. Its zero-based offset must
* also fit in an integer, so it must be less than or equal to the ceiling derived from the page size.
*/
final class PageNumberOutOfRange extends InvalidArgumentException implements HttpQueryException
{
private const string REASON_TEMPLATE = 'Page number <%d> must be greater than or equal to 1.';
private const string ABOVE_MAXIMUM = 'Page number <%d> must be less than or equal to %d.';
private const string BELOW_MINIMUM = 'Page number <%d> must be greater than or equal to 1.';

private function __construct(int $page)
private function __construct(string $reason)
{
$template = PageNumberOutOfRange::REASON_TEMPLATE;

parent::__construct(message: sprintf($template, $page));
parent::__construct(message: $reason);
}

/**
* Creates a PageNumberOutOfRange from the offending page number.
*
* @param int $page The page number that fell outside the valid range.
* @param int $page The page number that fell below the minimum of 1.
* @return PageNumberOutOfRange The composed exception describing the invalid page number.
*/
public static function from(int $page): PageNumberOutOfRange
{
return new PageNumberOutOfRange(page: $page);
$template = PageNumberOutOfRange::BELOW_MINIMUM;

return new PageNumberOutOfRange(reason: sprintf($template, $page));
}

/**
* Creates a PageNumberOutOfRange signaling that the page number exceeds the ceiling for the page size.
*
* @param int $page The page number that exceeded the ceiling.
* @param int $maximum The last page number whose offset fits in an integer for the page size.
* @return PageNumberOutOfRange The composed exception describing the oversized page number.
*/
public static function aboveMaximum(int $page, int $maximum): PageNumberOutOfRange
{
$template = PageNumberOutOfRange::ABOVE_MAXIMUM;

return new PageNumberOutOfRange(reason: sprintf($template, $page, $maximum));
}
}
24 changes: 24 additions & 0 deletions src/Internal/DecimalInteger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\HttpQuery\Internal;

final class DecimalInteger
{
private function __construct()
{
}

public static function isValid(string $value): bool
{
if (preg_match('/^(?<sign>-?)0*(?<digits>\d+)$/D', $value, $parts) !== 1) {
return false;
}

$bound = ltrim((string)($parts['sign'] === '-' ? PHP_INT_MIN : PHP_INT_MAX), '-');
$digits = str_pad($parts['digits'], strlen($bound), '0', STR_PAD_LEFT);

return strlen($digits) === strlen($bound) && strcmp($digits, $bound) <= 0;
}
}
20 changes: 18 additions & 2 deletions src/Internal/Iso8601.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,32 @@

namespace TinyBlocks\HttpQuery\Internal;

use DateTimeImmutable;
use DateTimeZone;

final class Iso8601
{
private const string PATTERN = '/^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})'
. '(?:T(?:[01]\d|2[0-3])(?::[0-5]\d){2}(?:\.\d{1,9})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)?)?$/D';
private const string LATEST_INSTANT = '9999-12-31T23:59:59.999999Z';
private const string EARLIEST_INSTANT = '0001-01-01T00:00:00Z';

private function __construct()
{
}

public static function isValid(string $value): bool
{
$pattern = '/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/';
if (
preg_match(Iso8601::PATTERN, $value, $date) !== 1
|| !checkdate((int)$date['month'], (int)$date['day'], (int)$date['year'])
) {
return false;
}

$instant = new DateTimeImmutable(datetime: $value, timezone: new DateTimeZone(timezone: 'UTC'));

return preg_match($pattern, $value) === 1;
return $instant >= new DateTimeImmutable(datetime: Iso8601::EARLIEST_INSTANT)
&& $instant <= new DateTimeImmutable(datetime: Iso8601::LATEST_INSTANT);
}
}
13 changes: 13 additions & 0 deletions src/Internal/Offset/Offset.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
namespace TinyBlocks\HttpQuery\Internal\Offset;

use TinyBlocks\HttpQuery\Exceptions\OffsetOutOfRange;
use TinyBlocks\HttpQuery\Exceptions\PageNumberOutOfRange;
use TinyBlocks\HttpQuery\Internal\Limit;

final readonly class Offset
{
private const int MAXIMUM = (PHP_INT_MAX - 1);

private function __construct(private int $value)
{
}
Expand All @@ -19,11 +22,21 @@ public static function from(int $value): Offset
throw OffsetOutOfRange::from(offset: $value);
}

if ($value > Offset::MAXIMUM) {
throw OffsetOutOfRange::aboveMaximum(offset: $value, maximum: Offset::MAXIMUM);
}

return new Offset(value: $value);
}

public static function fromPage(PageNumber $page, Limit $limit): Offset
{
$lastPage = (intdiv(Offset::MAXIMUM, $limit->value()) + 1);

if ($page->value() > $lastPage) {
throw PageNumberOutOfRange::aboveMaximum(page: $page->value(), maximum: $lastPage);
}

return new Offset(value: (($page->value() - 1) * $limit->value()));
}

Expand Down
4 changes: 3 additions & 1 deletion src/Internal/Offset/PageCount.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ private function __construct(private int $value)

public static function from(Limit $limit, Total $total): PageCount
{
return new PageCount(value: (int)ceil($total->value() / $limit->value()));
$hasPartialPage = ($total->value() % $limit->value()) > 0;

return new PageCount(value: (intdiv($total->value(), $limit->value()) + ($hasPartialPage ? 1 : 0)));
}

public function value(): int
Expand Down
4 changes: 2 additions & 2 deletions src/Offset/Criteria.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ private function __construct(
* @return Criteria The criteria carrying the validated comparisons, the effective sort, and the pagination.
* @throws FilterExpressionIsInvalid If the filter expression cannot be parsed.
* @throws SortExpressionIsInvalid If the sort expression cannot be parsed.
* @throws PageNumberOutOfRange If the page number is less than 1.
* @throws PageNumberOutOfRange If the page number is less than 1 or its offset does not fit in an integer.
* @throws PageSizeOutOfRange If the page size falls outside the valid range.
* @throws FilterShapeNotSupported If the filter is not a comparison or an AND group of comparisons.
* @throws FilterFieldNotAllowed If a comparison targets a field that was never allowed.
Expand Down Expand Up @@ -84,7 +84,7 @@ public static function fromQuery(Schema $schema, ServerRequestInterface $request
* @return Criteria The criteria carrying the validated comparisons, the effective sort, and the pagination.
* @throws FilterExpressionIsInvalid If the filter expression cannot be parsed.
* @throws SortExpressionIsInvalid If the sort expression cannot be parsed.
* @throws PageNumberOutOfRange If the page number is less than 1.
* @throws PageNumberOutOfRange If the page number is less than 1 or its offset does not fit in an integer.
* @throws PageSizeOutOfRange If the page size falls outside the valid range.
* @throws FilterShapeNotSupported If the filter is not a comparison or an AND group of comparisons.
* @throws FilterFieldNotAllowed If a comparison targets a field that was never allowed.
Expand Down
4 changes: 2 additions & 2 deletions src/Offset/Pagination.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ private function __construct(private Limit $limit, private Offset $offset)
* @param int $page The one-based page number.
* @param int $perPage The page size.
* @return Pagination The pagination whose offset is derived from the page and the page size.
* @throws PageNumberOutOfRange If the page number is less than 1.
* @throws PageNumberOutOfRange If the page number is less than 1 or its offset does not fit in an integer.
* @throws PageSizeOutOfRange If the page size is less than 1.
*/
public static function fromPage(int $page, int $perPage): Pagination
Expand All @@ -46,7 +46,7 @@ public static function fromPage(int $page, int $perPage): Pagination
* @param int $limit The maximum number of elements per page.
* @param int $offset The zero-based offset.
* @return Pagination The pagination carrying the offset and the limit.
* @throws OffsetOutOfRange If the offset is less than 0.
* @throws OffsetOutOfRange If the offset is less than 0 or is the largest integer.
* @throws PageSizeOutOfRange If the limit is less than 1.
*/
public static function fromOffset(int $limit, int $offset): Pagination
Expand Down
12 changes: 9 additions & 3 deletions src/ValueKind.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@

namespace TinyBlocks\HttpQuery;

use TinyBlocks\HttpQuery\Internal\DecimalInteger;
use TinyBlocks\HttpQuery\Internal\Iso8601;

/**
* Kind a filter value or a cursor value is validated against, backed by its canonical token.
*
* <p>A <code>UUID</code> is the canonical hyphenated form of a UUID in either letter case, a
* <code>STRING</code> is any non-empty string, an <code>INTEGER</code> is an optionally signed
* sequence of digits, and a <code>DATETIME</code> is an ISO-8601 date or date-time.</p>
* sequence of digits that fits in an integer, and a <code>DATETIME</code> is an ISO-8601 date or
* date-time that exists in the calendar, whose instant in UTC falls between years 0001 and 9999.</p>
*
* <p>No kind accepts a trailing line break. A date-time carries an hour from 00 to 23, a minute and
* a second from 00 to 59, an optional fraction of up to nine digits, and an optional <code>Z</code>
* or <code>±HH:MM</code> offset whose hours run from 00 to 23 and whose minutes run from 00 to 59.</p>
*/
enum ValueKind: string
{
Expand All @@ -29,9 +35,9 @@ enum ValueKind: string
public function matches(string $value): bool
{
return match ($this) {
ValueKind::UUID => preg_match('/^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i', $value) === 1,
ValueKind::UUID => preg_match('/^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/Di', $value) === 1,
ValueKind::STRING => $value !== '',
ValueKind::INTEGER => preg_match('/^-?\d+$/', $value) === 1,
ValueKind::INTEGER => DecimalInteger::isValid(value: $value),
ValueKind::DATETIME => Iso8601::isValid(value: $value)
};
}
Expand Down
37 changes: 37 additions & 0 deletions tests/Unit/Offset/CriteriaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

namespace Test\TinyBlocks\HttpQuery\Unit\Offset;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\HttpQuery\Models\Query;
use TinyBlocks\HttpQuery\Comparison;
use TinyBlocks\HttpQuery\Exceptions\PageNumberOutOfRange;
use TinyBlocks\HttpQuery\Exceptions\PageSizeOutOfRange;
use TinyBlocks\HttpQuery\Exceptions\SortFieldNotAllowed;
use TinyBlocks\HttpQuery\Offset\Criteria;
Expand Down Expand Up @@ -87,6 +89,18 @@ public function testFromQueryWhenPerPageAtMaximumThenLimitIsAccepted(): void
self::assertSame(100, $criteria->limit()->toInteger());
}

public function testFromQueryWhenPageNumberAtCeilingThenOffsetFitsAnInteger(): void
{
/** @Given query parameters carrying the last page whose offset fits an integer */
$query = Query::from(parameters: ['page' => ['number' => '461168601842738791']]);

/** @When building the criteria from the query */
$criteria = Criteria::fromQueryWithDefaultSchema(request: $query);

/** @Then the offset is the largest multiple of the default page size below the largest integer */
self::assertSame(9223372036854775800, $criteria->offset());
}

public function testSortWhenClientSortsAllowedFieldsThenReturnsTheClientSort(): void
{
/** @Given a schema allowing the client to sort by two fields */
Expand Down Expand Up @@ -159,6 +173,20 @@ public function testSortWhenClientSortsDisallowedFieldThenThrowsSortFieldNotAllo
Criteria::fromQuery(schema: $schema, request: $query);
}

#[DataProvider('pageNumbersBeyondTheCeiling')]
public function testFromQueryWhenPageNumberBeyondCeilingThenThrowsPageNumberOutOfRange(string $pageNumber): void
{
/** @Given query parameters carrying a page number whose offset does not fit an integer */
$query = Query::from(parameters: ['page' => ['number' => $pageNumber]]);

/** @Then an exception carrying the ceiling for the default page size is raised */
$this->expectException(PageNumberOutOfRange::class);
$this->expectExceptionMessage('must be less than or equal to 461168601842738791.');

/** @When building the criteria from the query */
Criteria::fromQueryWithDefaultSchema(request: $query);
}

public function testFromQueryWhenFilterSortAndPageGivenThenEachSpecificationIsValidated(): void
{
/** @Given a schema allowing the filtered and sorted fields */
Expand Down Expand Up @@ -206,4 +234,13 @@ public function testSortWhenServerControlledAndClientSortsThenThrowsSortFieldNot
/** @When building the criteria from the query */
Criteria::fromQuery(schema: $schema, request: $query);
}

public static function pageNumbersBeyondTheCeiling(): array
{
return [
'First page past the ceiling' => ['pageNumber' => '461168601842738792'],
'Largest integer' => ['pageNumber' => '9223372036854775807'],
'Beyond the integer range' => ['pageNumber' => '99999999999999999999']
];
}
}
Loading