From 8ae339fa76e7f6c9224fe72e7b8e49fae6fdb148 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Tue, 6 Oct 2026 21:33:20 -0300 Subject: [PATCH 1/2] feat: Refuse a cursor value that breaks the kind declared for its key. A cursor round-trips through the client, and Criteria::fromQuery only checked that it decodes into one value per sort order. A forged value reached the store through the column binding, so a cursor carrying a non-UUID id answered 500 wherever the id is bound through UUID_TO_BIN. A Schema now declares the kind of a cursor key with cursorKey, the new UUID kind joins the others, and fromQuery rejects a value outside its declared kind with CursorIsInvalid. A field without a declared kind is not checked, so existing schemas behave as before. --- phpstan.neon.dist | 6 + src/Cursor/Criteria.php | 15 +-- src/Exceptions/CursorIsInvalid.php | 32 +++-- src/Internal/Cursor/CursorKeys.php | 49 ++++++++ src/Schema.php | 56 ++++++++- src/ValueKind.php | 9 +- tests/Unit/Cursor/CriteriaTest.php | 181 +++++++++++++++++++++++++++++ tests/Unit/ValueKindTest.php | 7 ++ 8 files changed, 335 insertions(+), 20 deletions(-) create mode 100644 src/Internal/Cursor/CursorKeys.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index dd0c6fe..b80039e 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -62,6 +62,12 @@ parameters: # The cursor codec encodes and decodes opaque key values whose types are not known. - identifier: missingType.iterableValue path: src/Internal/Cursor/CursorCodec.php + # The cursor key kinds are a map of field to ValueKind carried on an internal constructor, so + # the kind read back from it is widened to mixed. + - identifier: missingType.iterableValue + path: src/Internal/Cursor/CursorKeys.php + - identifier: argument.type + path: src/Internal/Cursor/CursorKeys.php # Test fixtures and data providers carry raw arrays at the test boundary. - identifier: missingType.iterableValue path: tests diff --git a/src/Cursor/Criteria.php b/src/Cursor/Criteria.php index bff13c4..26f88fd 100644 --- a/src/Cursor/Criteria.php +++ b/src/Cursor/Criteria.php @@ -18,7 +18,6 @@ use TinyBlocks\HttpQuery\Exceptions\SortIsRequired; use TinyBlocks\HttpQuery\Filter; use TinyBlocks\HttpQuery\Internal\Query; -use TinyBlocks\HttpQuery\Order; use TinyBlocks\HttpQuery\Schema; use TinyBlocks\HttpQuery\Sort; @@ -45,9 +44,10 @@ private function __construct( * Creates a Criteria from the schema and the request. * *

It parses the request query string and validates it against the schema, the incoming cursor - * included: a token that cannot be decoded into one value per effective sort order is rejected - * here, and never later while the seek is being built. The pagination always carries the - * incoming cursor token and the page size.

+ * included: a token that cannot be decoded into one value per effective sort order, or whose + * value breaks the kind the schema declares for its cursor key, is rejected here, and never + * later while the seek is being built. The pagination always carries the incoming cursor token + * and the page size.

* * @param Schema $schema The query contract the request is validated against. * @param ServerRequestInterface $request The incoming PSR-7 server request. @@ -60,15 +60,12 @@ private function __construct( * @throws FilterOperatorNotAllowed If a comparison uses an operator not allowed for its field. * @throws FilterValueNotAllowed If a compared value falls outside the permitted set or kind. * @throws SortFieldNotAllowed If the sort orders by a field that was never declared sortable. - * @throws CursorIsInvalid If the incoming cursor cannot be decoded into one value per sort order. + * @throws CursorIsInvalid If the incoming cursor is not one value per sort order or breaks a cursor key kind. */ public static function fromQuery(Schema $schema, ServerRequestInterface $request): Criteria { $query = Query::from(schema: $schema, request: $request); - $cursor = Token::from(token: $query->cursorToken()); - $fields = array_map(static fn(Order $order): string => $order->field(), $query->sort()->orders()); - - $cursor->keyedBy(fields: $fields); + $cursor = $schema->cursorFor(sort: $query->sort(), cursor: Token::from(token: $query->cursorToken())); return new Criteria( sort: $query->sort(), diff --git a/src/Exceptions/CursorIsInvalid.php b/src/Exceptions/CursorIsInvalid.php index e4a6bbc..5efcaf8 100644 --- a/src/Exceptions/CursorIsInvalid.php +++ b/src/Exceptions/CursorIsInvalid.php @@ -6,22 +6,23 @@ use InvalidArgumentException; use Throwable; +use TinyBlocks\HttpQuery\ValueKind; /** * Raised when an opaque cursor token cannot be decoded back into its ordering key values. * * A cursor token is produced by the library and must round-trip through its codec. A token that - * was truncated, tampered with, or generated elsewhere fails to decode. + * was truncated, tampered with, or generated elsewhere fails to decode. A token that decodes but + * carries a value outside the kind declared for its cursor key is rejected the same way. */ final class CursorIsInvalid extends InvalidArgumentException implements HttpQueryException { - private const string REASON_TEMPLATE = 'Cursor token <%s> is invalid and could not be decoded.'; + private const string KIND_MISMATCH = 'Cursor token <%s> does not match the %s kind for cursor key <%s>.'; + private const string NOT_DECODABLE = 'Cursor token <%s> is invalid and could not be decoded.'; - private function __construct(string $token, ?Throwable $previous) + private function __construct(string $reason, ?Throwable $previous = null) { - $template = CursorIsInvalid::REASON_TEMPLATE; - - parent::__construct(message: sprintf($template, $token), previous: $previous); + parent::__construct(message: $reason, previous: $previous); } /** @@ -33,6 +34,23 @@ private function __construct(string $token, ?Throwable $previous) */ public static function from(string $token, ?Throwable $previous = null): CursorIsInvalid { - return new CursorIsInvalid(token: $token, previous: $previous); + $template = CursorIsInvalid::NOT_DECODABLE; + + return new CursorIsInvalid(reason: sprintf($template, $token), previous: $previous); + } + + /** + * Creates a CursorIsInvalid signaling that a decoded value does not match its cursor key kind. + * + * @param ValueKind $kind The value kind declared for the cursor key. + * @param string $field The cursor key whose value was rejected. + * @param string $token The opaque cursor token carrying the rejected value. + * @return CursorIsInvalid The composed exception describing the kind mismatch. + */ + public static function kindMismatch(ValueKind $kind, string $field, string $token): CursorIsInvalid + { + $template = CursorIsInvalid::KIND_MISMATCH; + + return new CursorIsInvalid(reason: sprintf($template, $token, $kind->value, $field)); } } diff --git a/src/Internal/Cursor/CursorKeys.php b/src/Internal/Cursor/CursorKeys.php new file mode 100644 index 0000000..6a10468 --- /dev/null +++ b/src/Internal/Cursor/CursorKeys.php @@ -0,0 +1,49 @@ +matches(value: (string) $key)); + } + + public function with(ValueKind $kind, string $field): CursorKeys + { + return new CursorKeys(kinds: [...$this->kinds, $field => $kind]); + } + + public function permit(Sort $sort, Token $cursor): Token + { + $fields = array_map(static fn(Order $order): string => $order->field(), $sort->orders()); + $keys = $cursor->keyedBy(fields: $fields); + + foreach ($fields as $field) { + $kind = ($this->kinds[$field] ?? null); + + if (!is_null($kind) && !$this->fits(key: $keys[$field], kind: $kind)) { + throw CursorIsInvalid::kindMismatch(kind: $kind, field: $field, token: $cursor->toString()); + } + } + + return $cursor; + } +} diff --git a/src/Schema.php b/src/Schema.php index 97136b4..a109730 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -4,6 +4,8 @@ namespace TinyBlocks\HttpQuery; +use TinyBlocks\HttpQuery\Cursor\Token; +use TinyBlocks\HttpQuery\Exceptions\CursorIsInvalid; use TinyBlocks\HttpQuery\Exceptions\FilterFieldNotAllowed; use TinyBlocks\HttpQuery\Exceptions\FilterOperatorNotAllowed; use TinyBlocks\HttpQuery\Exceptions\FilterShapeNotSupported; @@ -12,12 +14,14 @@ use TinyBlocks\HttpQuery\Exceptions\SortFieldNotAllowed; use TinyBlocks\HttpQuery\Internal\AllowedFilters; use TinyBlocks\HttpQuery\Internal\Conjunction; +use TinyBlocks\HttpQuery\Internal\Cursor\CursorKeys; /** * Declarative contract of the query an endpoint accepts, used to validate an incoming request. * *

It declares the filterable fields with their permitted operators, values, and kinds, the - * client-sortable fields, the sort applied when the client sends none, and the page-size bounds. + * client-sortable fields, the kind each cursor key value must match, the sort applied when the + * client sends none, and the page-size bounds. * The query parameter names follow JSON:API and are fixed: filter, sort, * and the page family. The default page size is 20 and the maximum is 100.

*/ @@ -26,6 +30,7 @@ private function __construct( private AllowedFilters $allowed, private Sort $byDefault, + private CursorKeys $cursorKeys, private int $maxPerPage, private int $defaultPerPage, private array $sortableFields, @@ -70,6 +75,7 @@ public static function default(): Schema return new Schema( allowed: AllowedFilters::createFromEmpty(), byDefault: Sort::fromExpression(expression: ''), + cursorKeys: CursorKeys::createFromEmpty(), maxPerPage: 100, defaultPerPage: 20, sortableFields: [], @@ -110,6 +116,7 @@ public function sortable(array $fields): Schema return new Schema( allowed: $this->allowed, byDefault: $this->byDefault, + cursorKeys: $this->cursorKeys, maxPerPage: $this->maxPerPage, defaultPerPage: $this->defaultPerPage, sortableFields: $fields, @@ -117,6 +124,48 @@ public function sortable(array $fields): Schema ); } + /** + * Returns the incoming cursor, validated against the effective sort and the declared cursor keys. + * + *

The cursor must decode into one value per effective sort order, and every value of a field + * declared through cursorKey must match the declared kind. An absent cursor and a + * null value carry no key, so they always pass. A field without a declared kind is not checked.

+ * + * @param Sort $sort The effective sort the cursor values are keyed by. + * @param Token $cursor The incoming cursor read from the request. + * @return Token The incoming cursor, unchanged once validated. + * @throws CursorIsInvalid If the cursor is not one value per sort order or breaks a cursor key kind. + */ + public function cursorFor(Sort $sort, Token $cursor): Token + { + return $this->cursorKeys->permit(sort: $sort, cursor: $cursor); + } + + /** + * Returns a copy of the Schema declaring the kind every cursor value of the field must match. + * + *

A cursor token round-trips through the client, so its values are untrusted input. A cursor + * whose value for the field does not match the kind is rejected while the request is parsed, + * before the value reaches the store. The kind describes the value as the cursor carries it, + * that is as it was read from the source rows.

+ * + * @param string $field The ordering field whose cursor values are checked. + * @param ValueKind $valueKind The kind every cursor value of the field must match. + * @return Schema A copy carrying the original contract plus the cursor key kind. + */ + public function cursorKey(string $field, ValueKind $valueKind): Schema + { + return new Schema( + allowed: $this->allowed, + byDefault: $this->byDefault, + cursorKeys: $this->cursorKeys->with(kind: $valueKind, field: $field), + maxPerPage: $this->maxPerPage, + defaultPerPage: $this->defaultPerPage, + sortableFields: $this->sortableFields, + allowsDisjunction: $this->allowsDisjunction + ); + } + /** * Returns a copy of the Schema allowing the field under the operators, values, and kind. * @@ -140,6 +189,7 @@ public function filterable( operators: $operators ), byDefault: $this->byDefault, + cursorKeys: $this->cursorKeys, maxPerPage: $this->maxPerPage, defaultPerPage: $this->defaultPerPage, sortableFields: $this->sortableFields, @@ -159,6 +209,7 @@ public function maxPerPage(int $maxPerPage): Schema return new Schema( allowed: $this->allowed, byDefault: $this->byDefault, + cursorKeys: $this->cursorKeys, maxPerPage: $maxPerPage, defaultPerPage: $this->defaultPerPage, sortableFields: $this->sortableFields, @@ -177,6 +228,7 @@ public function defaultSort(Sort $sort): Schema return new Schema( allowed: $this->allowed, byDefault: $sort, + cursorKeys: $this->cursorKeys, maxPerPage: $this->maxPerPage, defaultPerPage: $this->defaultPerPage, sortableFields: $this->sortableFields, @@ -243,6 +295,7 @@ public function defaultPerPage(int $defaultPerPage): Schema return new Schema( allowed: $this->allowed, byDefault: $this->byDefault, + cursorKeys: $this->cursorKeys, maxPerPage: $this->maxPerPage, defaultPerPage: $defaultPerPage, sortableFields: $this->sortableFields, @@ -260,6 +313,7 @@ public function allowDisjunction(): Schema return new Schema( allowed: $this->allowed, byDefault: $this->byDefault, + cursorKeys: $this->cursorKeys, maxPerPage: $this->maxPerPage, defaultPerPage: $this->defaultPerPage, sortableFields: $this->sortableFields, diff --git a/src/ValueKind.php b/src/ValueKind.php index 7943a0d..490171b 100644 --- a/src/ValueKind.php +++ b/src/ValueKind.php @@ -7,13 +7,15 @@ use TinyBlocks\HttpQuery\Internal\Iso8601; /** - * Kind a filter value is validated against, backed by its canonical token. + * Kind a filter value or a cursor value is validated against, backed by its canonical token. * - *

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.

+ *

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.

*/ enum ValueKind: string { + case UUID = 'uuid'; case STRING = 'string'; case INTEGER = 'integer'; case DATETIME = 'datetime'; @@ -27,6 +29,7 @@ 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::STRING => $value !== '', ValueKind::INTEGER => preg_match('/^-?\d+$/', $value) === 1, ValueKind::DATETIME => Iso8601::isValid(value: $value) diff --git a/tests/Unit/Cursor/CriteriaTest.php b/tests/Unit/Cursor/CriteriaTest.php index afd3184..2109211 100644 --- a/tests/Unit/Cursor/CriteriaTest.php +++ b/tests/Unit/Cursor/CriteriaTest.php @@ -4,17 +4,20 @@ namespace Test\TinyBlocks\HttpQuery\Unit\Cursor; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Test\TinyBlocks\HttpQuery\Models\Query; use TinyBlocks\HttpQuery\Comparison; use TinyBlocks\HttpQuery\Cursor\Criteria; use TinyBlocks\HttpQuery\Cursor\Page; use TinyBlocks\HttpQuery\Cursor\Token; +use TinyBlocks\HttpQuery\Exceptions\CursorIsInvalid; use TinyBlocks\HttpQuery\Exceptions\PageSizeOutOfRange; use TinyBlocks\HttpQuery\Exceptions\SortIsRequired; use TinyBlocks\HttpQuery\Operator; use TinyBlocks\HttpQuery\Schema; use TinyBlocks\HttpQuery\Sort; +use TinyBlocks\HttpQuery\ValueKind; final class CriteriaTest extends TestCase { @@ -97,6 +100,49 @@ public function testKeysetWhenEffectiveSortIsEmptyThenThrowsSortIsRequired(): vo $criteria->keyset(); } + public function testFromQueryWhenCursorValueFitsDeclaredKindThenKeysetCarriesIt(): void + { + /** @Given an opaque token carrying a store timestamp and a UUID identifier */ + $token = Token::fromKeys(keys: ['2026-01-15 10:30:00.0', '01900000-0000-7099-8000-000000000001'])->toString(); + + /** @And a schema declaring only the identifier cursor key as a UUID */ + $schema = Schema::create() + ->cursorKey(field: 'id', valueKind: ValueKind::UUID) + ->defaultSort(sort: Sort::fromExpression(expression: '-created_at,-id')); + + /** @When building the keyset view from a request carrying that cursor */ + $keyset = Criteria::fromQuery( + schema: $schema, + request: Query::from(parameters: ['page' => ['cursor' => $token]]) + )->keyset(); + + /** @Then the keyset carries both values, the undeclared timestamp left unchecked */ + self::assertSame( + ['created_at' => '2026-01-15 10:30:00.0', 'id' => '01900000-0000-7099-8000-000000000001'], + $keyset->cursor() + ); + } + + public function testFromQueryWhenCursorValueIsNullThenTheDeclaredKindLetsItPass(): void + { + /** @Given an opaque token carrying a timestamp and a null identifier */ + $token = Token::fromKeys(keys: ['2026-01-15T10:30:00Z', null])->toString(); + + /** @And a schema declaring the identifier cursor key as a UUID */ + $schema = Schema::create() + ->cursorKey(field: 'id', valueKind: ValueKind::UUID) + ->defaultSort(sort: Sort::fromExpression(expression: '-created_at,-id')); + + /** @When building the keyset view from a request carrying that cursor */ + $keyset = Criteria::fromQuery( + schema: $schema, + request: Query::from(parameters: ['page' => ['cursor' => $token]]) + )->keyset(); + + /** @Then the keyset carries the null value, which holds no key to check */ + self::assertSame(['created_at' => '2026-01-15T10:30:00Z', 'id' => null], $keyset->cursor()); + } + public function testFromQueryWhenCustomSchemaGivenThenAppliesItsDefaultPageSize(): void { /** @Given a schema lowering the default page size and declaring a default sort */ @@ -111,6 +157,20 @@ public function testFromQueryWhenCustomSchemaGivenThenAppliesItsDefaultPageSize( self::assertSame(5, $keyset->limit()->toInteger()); } + public function testFromQueryWhenNoCursorThenDeclaredKindsLeaveTheFirstPageOpen(): void + { + /** @Given a schema declaring the identifier cursor key as a UUID */ + $schema = Schema::create() + ->cursorKey(field: 'id', valueKind: ValueKind::UUID) + ->defaultSort(sort: Sort::fromExpression(expression: 'id')); + + /** @When building the keyset view from a request carrying no cursor */ + $keyset = Criteria::fromQuery(schema: $schema, request: Query::from(parameters: []))->keyset(); + + /** @Then every cursor key is null, so the first page is served */ + self::assertSame(['id' => null], $keyset->cursor()); + } + public function testFromQueryWhenCursorPresentThenKeysetCarriesPageSizeAndCursor(): void { /** @Given an opaque token produced from a single ordering key value */ @@ -135,6 +195,25 @@ public function testFromQueryWhenCursorPresentThenKeysetCarriesPageSizeAndCursor self::assertSame(['id' => 5], $keyset->cursor()); } + #[DataProvider('schemaCopies')] + public function testFromQueryWhenKindDeclaredBeforeAnotherCopyThenTheCopyKeepsIt(Schema $schema): void + { + /** @Given a schema copy derived after the identifier cursor key was declared as a UUID */ + + /** @And an opaque token whose identifier value is not a UUID */ + $token = Token::fromKeys(keys: ['not-a-uuid'])->toString(); + + /** @Then an exception indicating the cursor token is invalid is raised */ + $this->expectException(CursorIsInvalid::class); + $this->expectExceptionMessage('does not match the uuid kind for cursor key .'); + + /** @When building the criteria from that copy sorted by the identifier and a request carrying the cursor */ + Criteria::fromQuery( + schema: $schema->defaultSort(sort: Sort::fromExpression(expression: 'id')), + request: Query::from(parameters: ['page' => ['cursor' => $token]]) + ); + } + public function testFromQueryWhenPerPageAboveMaximumThenThrowsPageSizeOutOfRange(): void { /** @Given query parameters carrying a page size above the default maximum */ @@ -170,4 +249,106 @@ public function testFromQueryWhenFilterAndSortGivenThenEachSpecificationIsValida /** @And the effective sort is the client sort */ self::assertEquals(Sort::fromExpression(expression: '-created_at'), $criteria->sort()); } + + public function testFromQueryWhenForgedDatetimeCursorValueThenThrowsCursorIsInvalid(): void + { + /** @Given an opaque token whose creation timestamp value is not a date-time */ + $token = Token::fromKeys(keys: ['garbage', '01900000-0000-7099-8000-000000000001'])->toString(); + + /** @And a schema declaring the timestamp cursor key as a date-time and the identifier as a UUID */ + $schema = Schema::create() + ->cursorKey(field: 'created_at', valueKind: ValueKind::DATETIME) + ->cursorKey(field: 'id', valueKind: ValueKind::UUID) + ->defaultSort(sort: Sort::fromExpression(expression: '-created_at,-id')); + + /** @Then an exception naming the timestamp cursor key is raised */ + $this->expectException(CursorIsInvalid::class); + $this->expectExceptionMessage('does not match the datetime kind for cursor key .'); + + /** @When building the criteria from a request carrying that cursor */ + Criteria::fromQuery(schema: $schema, request: Query::from(parameters: ['page' => ['cursor' => $token]])); + } + + public function testFromQueryWhenIntegerCursorValueFitsIntegerKindThenKeysetCarriesIt(): void + { + /** @Given an opaque token carrying an integer identifier */ + $token = Token::fromKeys(keys: [5])->toString(); + + /** @And a schema declaring the identifier cursor key as an integer */ + $schema = Schema::create() + ->cursorKey(field: 'id', valueKind: ValueKind::INTEGER) + ->defaultSort(sort: Sort::fromExpression(expression: 'id')); + + /** @When building the keyset view from a request carrying that cursor */ + $keyset = Criteria::fromQuery( + schema: $schema, + request: Query::from(parameters: ['page' => ['cursor' => $token]]) + )->keyset(); + + /** @Then the keyset carries the integer value as decoded */ + self::assertSame(['id' => 5], $keyset->cursor()); + } + + public function testFromQueryWhenCursorValueBreaksDeclaredKindThenThrowsCursorIsInvalid(): void + { + /** @Given an opaque token whose identifier value is not a UUID */ + $token = Token::fromKeys(keys: ['2026-01-15 10:30:00.000000', 'not-a-uuid'])->toString(); + + /** @And a schema declaring the identifier cursor key as a UUID and a default sort over both keys */ + $schema = Schema::create() + ->cursorKey(field: 'id', valueKind: ValueKind::UUID) + ->defaultSort(sort: Sort::fromExpression(expression: '-created_at,-id')); + + /** @And the reason template naming the token, the kind, and the cursor key */ + $template = 'Cursor token <%s> does not match the uuid kind for cursor key .'; + + /** @Then an exception indicating the cursor token is invalid is raised */ + $this->expectException(CursorIsInvalid::class); + $this->expectExceptionMessage(sprintf($template, $token)); + + /** @When building the criteria from a request carrying that cursor */ + Criteria::fromQuery(schema: $schema, request: Query::from(parameters: ['page' => ['cursor' => $token]])); + } + + #[DataProvider('untypedCursorValues')] + public function testFromQueryWhenCursorValueIsNeitherTextNorIntegerThenThrowsCursorIsInvalid(bool|float $key): void + { + /** @Given a cursor value that is neither text nor an integer */ + + /** @And an opaque token carrying that value */ + $token = Token::fromKeys(keys: [$key])->toString(); + + /** @And a schema declaring the name cursor key as a string */ + $schema = Schema::create() + ->cursorKey(field: 'name', valueKind: ValueKind::STRING) + ->defaultSort(sort: Sort::fromExpression(expression: 'name')); + + /** @Then an exception indicating the cursor token is invalid is raised */ + $this->expectException(CursorIsInvalid::class); + $this->expectExceptionMessage('does not match the string kind for cursor key .'); + + /** @When building the criteria from a request carrying that cursor */ + Criteria::fromQuery(schema: $schema, request: Query::from(parameters: ['page' => ['cursor' => $token]])); + } + + public static function schemaCopies(): array + { + $schema = Schema::create()->cursorKey(field: 'id', valueKind: ValueKind::UUID); + + return [ + 'Sortable fields' => ['schema' => $schema->sortable(fields: ['id'])], + 'Filterable field' => ['schema' => $schema->filterable(field: 'status', operators: [Operator::EQUAL])], + 'Maximum page size' => ['schema' => $schema->maxPerPage(maxPerPage: 50)], + 'Default page size' => ['schema' => $schema->defaultPerPage(defaultPerPage: 10)], + 'Disjunction' => ['schema' => $schema->allowDisjunction()] + ]; + } + + public static function untypedCursorValues(): array + { + return [ + 'Boolean value' => ['key' => true], + 'Floating value' => ['key' => 1.5] + ]; + } } diff --git a/tests/Unit/ValueKindTest.php b/tests/Unit/ValueKindTest.php index eb30578..a6eedea 100644 --- a/tests/Unit/ValueKindTest.php +++ b/tests/Unit/ValueKindTest.php @@ -52,6 +52,8 @@ public function testConstructorWhenInvokedThroughReflectionThenInstantiatesTheSt 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'], @@ -65,6 +67,11 @@ public static function matchingValues(): array 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'], From f7df9498f32e99955b7ad08c76fbe577a3265682 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Tue, 6 Oct 2026 21:33:20 -0300 Subject: [PATCH 2/2] docs: Describe the cursor key kinds in the README. --- README.md | 46 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 44f0759..167d2c4 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,10 @@ parameters, validate them against the schema, and expose the same `comparisons() `Schema` is the contract of the query an endpoint accepts. `filterable` declares a field with its permitted operators and, optionally, the permitted values and the `ValueKind` every value must match. `sortable` declares the fields the -client may sort by. `defaultSort` declares the sort applied when the client sends none. `maxPerPage` and -`defaultPerPage` bound the page size. The query parameter names follow JSON:API and are fixed: `filter`, `sort`, and the -`page` family. +client may sort by. `cursorKey` declares the `ValueKind` every cursor value of a field must match (see +[Cursor pagination](#cursor-pagination)). `defaultSort` declares the sort applied when the client sends none. +`maxPerPage` and `defaultPerPage` bound the page size. The query parameter names follow JSON:API and are fixed: +`filter`, `sort`, and the `page` family. ```php map(transformation: static fn(array $row): array => ['id' => $row[' An invalid cursor token raises `CursorIsInvalid` when it is decoded. +A cursor token round-trips through the client, so its values are untrusted input. `cursorKey` declares the `ValueKind` +every cursor value of a field must match, and `Criteria::fromQuery` then raises `CursorIsInvalid` for a token whose +value for that field does not match, before the value reaches the store. A forged identifier is refused at parse +instead of failing inside a `UUID_TO_BIN` binding. + +```php +sortable(fields: ['created_at', 'id']) + ->cursorKey(field: 'id', valueKind: ValueKind::UUID) + ->defaultSort(sort: Sort::fromExpression(expression: '-created_at,-id')); +``` + +A field without a declared kind is not checked, and an absent cursor or a null value carries no key, so it always +passes. The kind describes the value as the cursor carries it, which is the form read from the source rows, not the +form a client sends in a filter. A `ValueKind::DATETIME` key therefore needs ISO-8601 values in the rows (or a +`keysOf` that emits them), since a raw SQL timestamp such as `2026-01-15 10:30:00.000000` does not match it. + ### Building the store query The library decides what to fetch and hands the consumer typed SQL fragments to apply against its own store. It builds @@ -581,7 +608,8 @@ the page's own approach. Field and operator names are validated against the `Schema` allowlist while parsing, so only declared identifiers ever reach your store. Comparison and cursor values are returned as data for you to bind as parameters, and the library -builds no SQL. A cursor token is decoded only as a list of scalar values. Any unsafe character in the filter and sort +builds no SQL. A cursor token is decoded only as a list of scalar values, and a value whose field declares a +`cursorKey` kind must match it. Any unsafe character in the filter and sort echoed into the `links` object and the `Link` header is percent-encoded. Binding the values is still your responsibility.