diff --git a/README.md b/README.md
index 167d2c4..f771101 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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.
diff --git a/src/Exceptions/OffsetOutOfRange.php b/src/Exceptions/OffsetOutOfRange.php
index 451ebbf..7dbcc29 100644
--- a/src/Exceptions/OffsetOutOfRange.php
+++ b/src/Exceptions/OffsetOutOfRange.php
@@ -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));
}
}
diff --git a/src/Exceptions/PageNumberOutOfRange.php b/src/Exceptions/PageNumberOutOfRange.php
index 5802083..bcce3fa 100644
--- a/src/Exceptions/PageNumberOutOfRange.php
+++ b/src/Exceptions/PageNumberOutOfRange.php
@@ -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));
}
}
diff --git a/src/Internal/DecimalInteger.php b/src/Internal/DecimalInteger.php
new file mode 100644
index 0000000..b792ace
--- /dev/null
+++ b/src/Internal/DecimalInteger.php
@@ -0,0 +1,24 @@
+-?)0*(?\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;
+ }
+}
diff --git a/src/Internal/Iso8601.php b/src/Internal/Iso8601.php
index 1203a13..a6b0993 100644
--- a/src/Internal/Iso8601.php
+++ b/src/Internal/Iso8601.php
@@ -4,16 +4,32 @@
namespace TinyBlocks\HttpQuery\Internal;
+use DateTimeImmutable;
+use DateTimeZone;
+
final class Iso8601
{
+ private const string PATTERN = '/^(?\d{4})-(?\d{2})-(?\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);
}
}
diff --git a/src/Internal/Offset/Offset.php b/src/Internal/Offset/Offset.php
index 370fa9b..51e4a78 100644
--- a/src/Internal/Offset/Offset.php
+++ b/src/Internal/Offset/Offset.php
@@ -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)
{
}
@@ -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()));
}
diff --git a/src/Internal/Offset/PageCount.php b/src/Internal/Offset/PageCount.php
index 0681787..68dd362 100644
--- a/src/Internal/Offset/PageCount.php
+++ b/src/Internal/Offset/PageCount.php
@@ -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
diff --git a/src/Offset/Criteria.php b/src/Offset/Criteria.php
index a41f2df..5bcc601 100644
--- a/src/Offset/Criteria.php
+++ b/src/Offset/Criteria.php
@@ -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.
@@ -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.
diff --git a/src/Offset/Pagination.php b/src/Offset/Pagination.php
index 155e474..fc0b66b 100644
--- a/src/Offset/Pagination.php
+++ b/src/Offset/Pagination.php
@@ -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
@@ -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
diff --git a/src/ValueKind.php b/src/ValueKind.php
index 490171b..ff78046 100644
--- a/src/ValueKind.php
+++ b/src/ValueKind.php
@@ -4,6 +4,7 @@
namespace TinyBlocks\HttpQuery;
+use TinyBlocks\HttpQuery\Internal\DecimalInteger;
use TinyBlocks\HttpQuery\Internal\Iso8601;
/**
@@ -11,7 +12,12 @@
*
* A UUID is the canonical hyphenated form of a UUID in either letter case, a
* STRING is any non-empty string, an INTEGER is an optionally signed
- * sequence of digits, and a DATETIME is an ISO-8601 date or date-time.
+ * sequence of digits that fits in an integer, and a DATETIME is an ISO-8601 date or
+ * date-time that exists in the calendar, whose instant in UTC falls between years 0001 and 9999.
+ *
+ * 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 Z
+ * or ±HH:MM offset whose hours run from 00 to 23 and whose minutes run from 00 to 59.
*/
enum ValueKind: string
{
@@ -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)
};
}
diff --git a/tests/Unit/Offset/CriteriaTest.php b/tests/Unit/Offset/CriteriaTest.php
index c8c512e..40720ab 100644
--- a/tests/Unit/Offset/CriteriaTest.php
+++ b/tests/Unit/Offset/CriteriaTest.php
@@ -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;
@@ -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 */
@@ -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 */
@@ -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']
+ ];
+ }
}
diff --git a/tests/Unit/Offset/PageTest.php b/tests/Unit/Offset/PageTest.php
index 55a50db..8107308 100644
--- a/tests/Unit/Offset/PageTest.php
+++ b/tests/Unit/Offset/PageTest.php
@@ -382,6 +382,26 @@ public function testNavigationWhenMiddlePageGivenThenTheFirstTargetPointsAtTheFi
);
}
+ public function testNavigationWhenTotalIsLargestIntegerThenLastPageStaysWithinTheCeiling(): void
+ {
+ /** @Given a criteria on the page before the ceiling with a page size of twenty */
+ $criteria = Criteria::fromQueryWithDefaultSchema(
+ request: Query::from(parameters: ['page' => ['number' => '461168601842738790', 'size' => '20']])
+ );
+
+ /** @When building the page from the largest integer total */
+ $page = $criteria->page(items: ['a'], total: PHP_INT_MAX);
+
+ /** @Then the total number of pages is the ceiling for the page size */
+ self::assertSame(461168601842738791, $page->totalPages());
+
+ /** @And the next pagination points at the ceiling */
+ self::assertSame(461168601842738791, $page->next()?->page());
+
+ /** @And the last pagination points at the ceiling */
+ self::assertSame(9223372036854775800, $page->last()?->offset());
+ }
+
public function testNavigationWhenMiddlePageGivenThenListsFirstPreviousNextAndLastTargets(): void
{
/** @Given a criteria on a middle page of a multi-page result */
@@ -400,4 +420,21 @@ public function testNavigationWhenMiddlePageGivenThenListsFirstPreviousNextAndLa
/** @Then it lists the first, previous, next, and last relations in semantic order */
self::assertSame(['first', 'prev', 'next', 'last'], $relations);
}
+
+ public function testNavigationWhenTotalIsLargestIntegerAndPerPageIsOneThenLastPageIsLargestInteger(): void
+ {
+ /** @Given a criteria on the first page with a page size of one */
+ $criteria = Criteria::fromQueryWithDefaultSchema(
+ request: Query::from(parameters: ['page' => ['number' => '1', 'size' => '1']])
+ );
+
+ /** @When building the page from the largest integer total */
+ $page = $criteria->page(items: ['a'], total: PHP_INT_MAX);
+
+ /** @Then the total number of pages is the largest integer */
+ self::assertSame(PHP_INT_MAX, $page->totalPages());
+
+ /** @And the last pagination points at the largest integer page */
+ self::assertSame(PHP_INT_MAX, $page->last()?->page());
+ }
}
diff --git a/tests/Unit/Offset/PaginationTest.php b/tests/Unit/Offset/PaginationTest.php
index 756247f..70ca5bb 100644
--- a/tests/Unit/Offset/PaginationTest.php
+++ b/tests/Unit/Offset/PaginationTest.php
@@ -105,6 +105,18 @@ public function testFromOffsetWhenUnalignedOffsetGivenThenPageIsFloored(): void
self::assertSame(20, $pagination->limit());
}
+ public function testFromPageWhenPageAtCeilingGivenThenOffsetFitsAnInteger(): void
+ {
+ /** @When building a pagination from the last page whose offset fits an integer */
+ $pagination = Pagination::fromPage(page: 461168601842738791, perPage: 20);
+
+ /** @Then the page number is preserved */
+ self::assertSame(461168601842738791, $pagination->page());
+
+ /** @And the offset is the largest multiple of the page size below the largest integer */
+ self::assertSame(9223372036854775800, $pagination->offset());
+ }
+
public function testFromPageWhenPageBelowOneGivenThenPageNumberOutOfRange(): void
{
/** @Then an exception indicating the page number is out of range is raised */
@@ -156,4 +168,74 @@ public function testToQueryStringWhenPageGivenThenRendersPageNumberAndSize(): vo
/** @Then it renders the page number and the page size in the JSON:API page family */
self::assertSame('page[number]=3&page[size]=20', $queryString);
}
+
+ public function testFromOffsetWhenOffsetIsLargestIntegerThenOffsetOutOfRange(): void
+ {
+ /** @Then an exception carrying the offset and the largest offset accepted is raised */
+ $this->expectException(OffsetOutOfRange::class);
+ $this->expectExceptionMessage(
+ 'Offset <9223372036854775807> must be less than or equal to 9223372036854775806.'
+ );
+
+ /** @When building a pagination from the largest integer offset */
+ Pagination::fromOffset(limit: 1, offset: PHP_INT_MAX);
+ }
+
+ public function testFromPageWhenPageAboveCeilingGivenThenPageNumberOutOfRange(): void
+ {
+ /** @Then an exception carrying the page number and the ceiling for the page size is raised */
+ $this->expectException(PageNumberOutOfRange::class);
+ $this->expectExceptionMessage(
+ 'Page number <461168601842738792> must be less than or equal to 461168601842738791.'
+ );
+
+ /** @When building a pagination from the first page whose offset does not fit an integer */
+ Pagination::fromPage(page: 461168601842738792, perPage: 20);
+ }
+
+ public function testFromPageWhenLargestIntegerPageGivenThenPageNumberOutOfRange(): void
+ {
+ /** @Then an exception carrying the page number and the ceiling for the page size is raised */
+ $this->expectException(PageNumberOutOfRange::class);
+ $this->expectExceptionMessage(
+ 'Page number <9223372036854775807> must be less than or equal to 461168601842738791.'
+ );
+
+ /** @When building a pagination from the largest integer page */
+ Pagination::fromPage(page: PHP_INT_MAX, perPage: 20);
+ }
+
+ public function testFromPageWhenPerPageIsLargestIntegerThenSecondPageIsOutOfRange(): void
+ {
+ /** @Then an exception stating that only the first page fits is raised */
+ $this->expectException(PageNumberOutOfRange::class);
+ $this->expectExceptionMessage('Page number <2> must be less than or equal to 1.');
+
+ /** @When building a pagination from the second page of the largest page size */
+ Pagination::fromPage(page: 2, perPage: PHP_INT_MAX);
+ }
+
+ public function testFromPageWhenLargestIntegerPageAndPerPageIsOneThenOffsetIsOneBelowIt(): void
+ {
+ /** @When building a pagination from the largest integer page with a page size of one */
+ $pagination = Pagination::fromPage(page: PHP_INT_MAX, perPage: 1);
+
+ /** @Then the page number is the largest integer */
+ self::assertSame(PHP_INT_MAX, $pagination->page());
+
+ /** @And the offset is one below the largest integer */
+ self::assertSame((PHP_INT_MAX - 1), $pagination->offset());
+ }
+
+ public function testFromOffsetWhenOffsetIsOneBelowLargestIntegerThenPageIsLargestInteger(): void
+ {
+ /** @When building a pagination from the largest offset accepted with a limit of one */
+ $pagination = Pagination::fromOffset(limit: 1, offset: (PHP_INT_MAX - 1));
+
+ /** @Then the page number is the largest integer */
+ self::assertSame(PHP_INT_MAX, $pagination->page());
+
+ /** @And the offset is preserved */
+ self::assertSame((PHP_INT_MAX - 1), $pagination->offset());
+ }
}
diff --git a/tests/Unit/ValueKindTest.php b/tests/Unit/ValueKindTest.php
index a6eedea..22e3a18 100644
--- a/tests/Unit/ValueKindTest.php
+++ b/tests/Unit/ValueKindTest.php
@@ -8,6 +8,7 @@
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionMethod;
+use TinyBlocks\HttpQuery\Internal\DecimalInteger;
use TinyBlocks\HttpQuery\Internal\Iso8601;
use TinyBlocks\HttpQuery\ValueKind;
@@ -49,35 +50,141 @@ public function testConstructorWhenInvokedThroughReflectionThenInstantiatesTheSt
self::assertInstanceOf(Iso8601::class, $validator);
}
+ public function testConstructorWhenInvokedThroughReflectionThenInstantiatesTheStaticOnlyIntegerValidator(): void
+ {
+ /** @Given an uninitialized instance of the static-only decimal integer validator */
+ $validator = new ReflectionClass(DecimalInteger::class)->newInstanceWithoutConstructor();
+
+ /** @When invoking its otherwise-uncallable private constructor */
+ new ReflectionMethod(DecimalInteger::class, '__construct')->invoke($validator);
+
+ /** @Then the static-only decimal integer validator is instantiated */
+ self::assertInstanceOf(DecimalInteger::class, $validator);
+ }
+
public static function matchingValues(): array
{
return [
- 'Lowercase UUID' => ['kind' => ValueKind::UUID, 'value' => '01900000-0000-7099-8000-000000000001'],
- 'Uppercase UUID' => ['kind' => ValueKind::UUID, 'value' => '0190A3F2-1B2C-7D4E-8F60-123456789ABC'],
- 'Non-empty string' => ['kind' => ValueKind::STRING, 'value' => 'paid'],
- 'Positive integer' => ['kind' => ValueKind::INTEGER, 'value' => '42'],
- 'Negative integer' => ['kind' => ValueKind::INTEGER, 'value' => '-7'],
- 'Date only' => ['kind' => ValueKind::DATETIME, 'value' => '2023-01-15'],
- 'Date-time with zone' => ['kind' => ValueKind::DATETIME, 'value' => '2023-01-15T10:30:00Z'],
- 'Date-time offset' => ['kind' => ValueKind::DATETIME, 'value' => '2023-01-15T10:30:00+01:00'],
- 'Date-time fraction' => ['kind' => ValueKind::DATETIME, 'value' => '2023-01-15T10:30:00.123Z']
+ 'Lowercase UUID' => [
+ 'kind' => ValueKind::UUID,
+ 'value' => '01900000-0000-7099-8000-000000000001'
+ ],
+ 'Uppercase UUID' => [
+ 'kind' => ValueKind::UUID,
+ 'value' => '0190A3F2-1B2C-7D4E-8F60-123456789ABC'
+ ],
+ 'Non-empty string' => ['kind' => ValueKind::STRING, 'value' => 'paid'],
+ 'Zero' => ['kind' => ValueKind::INTEGER, 'value' => '0'],
+ 'Negative zero' => ['kind' => ValueKind::INTEGER, 'value' => '-0'],
+ 'Positive integer' => ['kind' => ValueKind::INTEGER, 'value' => '42'],
+ 'Negative integer' => ['kind' => ValueKind::INTEGER, 'value' => '-7'],
+ 'Zero-padded integer' => ['kind' => ValueKind::INTEGER, 'value' => '0042'],
+ 'Largest integer' => ['kind' => ValueKind::INTEGER, 'value' => '9223372036854775807'],
+ 'Smallest integer' => ['kind' => ValueKind::INTEGER, 'value' => '-9223372036854775808'],
+ 'Padded largest integer' => ['kind' => ValueKind::INTEGER, 'value' => '0009223372036854775807'],
+ 'Padded smallest integer' => ['kind' => ValueKind::INTEGER, 'value' => '-009223372036854775808'],
+ 'Date only' => ['kind' => ValueKind::DATETIME, 'value' => '2023-01-15'],
+ 'Leap day' => ['kind' => ValueKind::DATETIME, 'value' => '2024-02-29'],
+ 'Earliest date' => ['kind' => ValueKind::DATETIME, 'value' => '0001-01-01'],
+ 'Latest date' => ['kind' => ValueKind::DATETIME, 'value' => '9999-12-31'],
+ 'Date-time without zone' => ['kind' => ValueKind::DATETIME, 'value' => '2023-01-15T10:30:00'],
+ 'Date-time with zone' => ['kind' => ValueKind::DATETIME, 'value' => '2023-01-15T10:30:00Z'],
+ 'Date-time offset' => ['kind' => ValueKind::DATETIME, 'value' => '2023-01-15T10:30:00+01:00'],
+ 'Date-time negative offset' => ['kind' => ValueKind::DATETIME, 'value' => '2023-01-15T10:30:00-03:00'],
+ 'Date-time largest offset' => ['kind' => ValueKind::DATETIME, 'value' => '2023-01-15T10:30:00+23:59'],
+ 'Date-time fraction' => ['kind' => ValueKind::DATETIME, 'value' => '2023-01-15T10:30:00.123Z'],
+ 'Date-time nanoseconds' => [
+ 'kind' => ValueKind::DATETIME,
+ 'value' => '2023-01-15T10:30:00.123456789Z'
+ ],
+ 'Last second of a day' => ['kind' => ValueKind::DATETIME, 'value' => '2023-01-15T23:59:59Z'],
+ 'Earliest instant' => ['kind' => ValueKind::DATETIME, 'value' => '0001-01-01T00:00:00Z'],
+ 'Earliest instant ahead' => ['kind' => ValueKind::DATETIME, 'value' => '0001-01-01T01:00:00+01:00'],
+ 'Earliest instant half ahead' => ['kind' => ValueKind::DATETIME, 'value' => '0001-01-01T00:30:00+00:30'],
+ 'Earliest date behind UTC' => ['kind' => ValueKind::DATETIME, 'value' => '0001-01-01T00:00:00-05:00'],
+ 'Latest instant' => ['kind' => ValueKind::DATETIME, 'value' => '9999-12-31T23:59:59Z'],
+ 'Latest instant fraction' => [
+ 'kind' => ValueKind::DATETIME,
+ 'value' => '9999-12-31T23:59:59.999999999Z'
+ ],
+ 'Latest instant behind' => ['kind' => ValueKind::DATETIME, 'value' => '9999-12-31T18:59:59-05:00'],
+ 'Latest instant half behind' => ['kind' => ValueKind::DATETIME, 'value' => '9999-12-31T23:29:00-00:30'],
+ 'Latest instant zero offset' => ['kind' => ValueKind::DATETIME, 'value' => '9999-12-31T23:59:59-00:00'],
+ 'Latest date ahead of UTC' => ['kind' => ValueKind::DATETIME, 'value' => '9999-12-31T23:59:59+05:00']
];
}
public static function mismatchingValues(): array
{
return [
- 'Free-form identifier' => ['kind' => ValueKind::UUID, 'value' => 'not-a-uuid'],
- 'UUID without hyphens' => ['kind' => ValueKind::UUID, 'value' => '01900000000070998000000000000001'],
- 'UUID leading text' => ['kind' => ValueKind::UUID, 'value' => 'x01900000-0000-7099-8000-000000000001'],
- 'UUID trailing text' => ['kind' => ValueKind::UUID, 'value' => '01900000-0000-7099-8000-000000000001x'],
- 'Non-hexadecimal UUID' => ['kind' => ValueKind::UUID, 'value' => '0190000g-0000-7099-8000-000000000001'],
- 'Empty string' => ['kind' => ValueKind::STRING, 'value' => ''],
- 'Non-numeric integer' => ['kind' => ValueKind::INTEGER, 'value' => 'abc'],
- 'Decimal integer' => ['kind' => ValueKind::INTEGER, 'value' => '4.2'],
- 'Free-form date' => ['kind' => ValueKind::DATETIME, 'value' => 'not-a-date'],
- 'Single-digit month' => ['kind' => ValueKind::DATETIME, 'value' => '2023-1-15'],
- 'Truncated date-time' => ['kind' => ValueKind::DATETIME, 'value' => '2023-01-15T99']
+ 'Free-form identifier' => ['kind' => ValueKind::UUID, 'value' => 'not-a-uuid'],
+ 'UUID without hyphens' => [
+ 'kind' => ValueKind::UUID,
+ 'value' => '01900000000070998000000000000001'
+ ],
+ 'UUID leading text' => [
+ 'kind' => ValueKind::UUID,
+ 'value' => 'x01900000-0000-7099-8000-000000000001'
+ ],
+ 'UUID trailing text' => [
+ 'kind' => ValueKind::UUID,
+ 'value' => '01900000-0000-7099-8000-000000000001x'
+ ],
+ 'UUID trailing newline' => [
+ 'kind' => ValueKind::UUID,
+ 'value' => "01900000-0000-7099-8000-000000000001\n"
+ ],
+ 'UUID as URN' => [
+ 'kind' => ValueKind::UUID,
+ 'value' => 'urn:uuid:01900000-0000-7099-8000-000000000001'
+ ],
+ 'Non-hexadecimal UUID' => [
+ 'kind' => ValueKind::UUID,
+ 'value' => '0190000g-0000-7099-8000-000000000001'
+ ],
+ 'Empty string' => ['kind' => ValueKind::STRING, 'value' => ''],
+ 'Non-numeric integer' => ['kind' => ValueKind::INTEGER, 'value' => 'abc'],
+ 'Decimal integer' => ['kind' => ValueKind::INTEGER, 'value' => '4.2'],
+ 'Sign without digits' => ['kind' => ValueKind::INTEGER, 'value' => '-'],
+ 'Integer leading text' => ['kind' => ValueKind::INTEGER, 'value' => 'x42'],
+ 'Integer trailing text' => ['kind' => ValueKind::INTEGER, 'value' => '42x'],
+ 'Integer trailing newline' => ['kind' => ValueKind::INTEGER, 'value' => "42\n"],
+ 'Integer above largest' => ['kind' => ValueKind::INTEGER, 'value' => '9223372036854775808'],
+ 'Integer below smallest' => ['kind' => ValueKind::INTEGER, 'value' => '-9223372036854775809'],
+ 'Integer with twenty digits' => ['kind' => ValueKind::INTEGER, 'value' => '99999999999999999999'],
+ 'Padded integer above largest' => ['kind' => ValueKind::INTEGER, 'value' => '09223372036854775808'],
+ 'Twenty digits led by one' => ['kind' => ValueKind::INTEGER, 'value' => '10000000000000000000'],
+ 'Free-form date' => ['kind' => ValueKind::DATETIME, 'value' => 'not-a-date'],
+ 'Single-digit month' => ['kind' => ValueKind::DATETIME, 'value' => '2023-1-15'],
+ 'Truncated date-time' => ['kind' => ValueKind::DATETIME, 'value' => '2023-01-15T99'],
+ 'Date leading text' => ['kind' => ValueKind::DATETIME, 'value' => 'x2023-01-15'],
+ 'Date trailing newline' => ['kind' => ValueKind::DATETIME, 'value' => "2023-01-15\n"],
+ 'Date-time trailing newline' => ['kind' => ValueKind::DATETIME, 'value' => "2023-01-15T10:30:00Z\n"],
+ 'Impossible day of month' => ['kind' => ValueKind::DATETIME, 'value' => '2026-02-30'],
+ 'Leap day of a common year' => ['kind' => ValueKind::DATETIME, 'value' => '2023-02-29'],
+ 'Thirteenth month' => ['kind' => ValueKind::DATETIME, 'value' => '2026-13-01'],
+ 'Month zero' => ['kind' => ValueKind::DATETIME, 'value' => '2026-00-10'],
+ 'Day zero' => ['kind' => ValueKind::DATETIME, 'value' => '2026-01-00'],
+ 'Day thirty-two' => ['kind' => ValueKind::DATETIME, 'value' => '2026-01-32'],
+ 'Year zero' => ['kind' => ValueKind::DATETIME, 'value' => '0000-01-01'],
+ 'Year zero date-time' => ['kind' => ValueKind::DATETIME, 'value' => '0000-12-31T23:59:59Z'],
+ 'Hour twenty-four' => ['kind' => ValueKind::DATETIME, 'value' => '2026-05-27T24:00:00Z'],
+ 'Hour ninety-nine' => ['kind' => ValueKind::DATETIME, 'value' => '2026-05-27T99:00:00Z'],
+ 'Minute sixty' => ['kind' => ValueKind::DATETIME, 'value' => '2026-05-27T10:60:00Z'],
+ 'Second sixty' => ['kind' => ValueKind::DATETIME, 'value' => '2026-05-27T10:00:60Z'],
+ 'Offset hour twenty-four' => ['kind' => ValueKind::DATETIME, 'value' => '2026-05-27T10:00:00+24:00'],
+ 'Offset minute sixty' => ['kind' => ValueKind::DATETIME, 'value' => '2026-05-27T10:00:00+01:60'],
+ 'Largest offset minute sixty' => ['kind' => ValueKind::DATETIME, 'value' => '2026-05-27T10:00:00+23:60'],
+ 'Offset without colon' => ['kind' => ValueKind::DATETIME, 'value' => '2026-05-27T10:00:00+0100'],
+ 'Fraction without digits' => ['kind' => ValueKind::DATETIME, 'value' => '2026-05-27T10:00:00.Z'],
+ 'Fraction with ten digits' => [
+ 'kind' => ValueKind::DATETIME,
+ 'value' => '2026-05-27T10:00:00.1234567890Z'
+ ],
+ 'Before earliest instant' => ['kind' => ValueKind::DATETIME, 'value' => '0001-01-01T00:59:00+01:00'],
+ 'Before earliest half ahead' => ['kind' => ValueKind::DATETIME, 'value' => '0001-01-01T00:29:59+00:30'],
+ 'After latest instant' => ['kind' => ValueKind::DATETIME, 'value' => '9999-12-31T22:00:00-05:00'],
+ 'After latest half behind' => ['kind' => ValueKind::DATETIME, 'value' => '9999-12-31T23:30:00-00:30']
];
}
}