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.