From c2879f795924b2626f7af4ea737502268e143c0b Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 14 Oct 2026 14:38:05 -0300 Subject: [PATCH 1/3] fix: Reject page numbers and offsets whose arithmetic overflows an integer. --- src/Exceptions/OffsetOutOfRange.php | 32 +++++++--- src/Exceptions/PageNumberOutOfRange.php | 32 +++++++--- src/Internal/Offset/Offset.php | 13 ++++ src/Internal/Offset/PageCount.php | 4 +- src/Offset/Criteria.php | 4 +- src/Offset/Pagination.php | 4 +- tests/Unit/Offset/CriteriaTest.php | 37 +++++++++++ tests/Unit/Offset/PageTest.php | 37 +++++++++++ tests/Unit/Offset/PaginationTest.php | 82 +++++++++++++++++++++++++ 9 files changed, 224 insertions(+), 21 deletions(-) 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/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/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()); + } } From f542cde487e8c1aacdfc7588012ac1c755890491 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 14 Oct 2026 14:38:06 -0300 Subject: [PATCH 2/3] fix: Reject value kinds that overflow an integer, break the calendar, or end in a line break. --- src/Internal/DecimalInteger.php | 24 ++++++ src/Internal/Iso8601.php | 20 ++++- src/ValueKind.php | 12 ++- tests/Unit/ValueKindTest.php | 147 +++++++++++++++++++++++++++----- 4 files changed, 178 insertions(+), 25 deletions(-) create mode 100644 src/Internal/DecimalInteger.php 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/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/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'] ]; } } From f0002a49db4be149df965c8510e5ee51ec145b24 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 14 Oct 2026 14:38:06 -0300 Subject: [PATCH 3/3] docs: Document the page number ceiling and the stricter value kinds. --- README.md | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) 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.