diff --git a/src/Cursor/Criteria.php b/src/Cursor/Criteria.php index b8cfa15..bff13c4 100644 --- a/src/Cursor/Criteria.php +++ b/src/Cursor/Criteria.php @@ -6,6 +6,7 @@ use Psr\Http\Message\ServerRequestInterface; use TinyBlocks\HttpQuery\Comparison; +use TinyBlocks\HttpQuery\Exceptions\CursorIsInvalid; use TinyBlocks\HttpQuery\Exceptions\FilterExpressionIsInvalid; use TinyBlocks\HttpQuery\Exceptions\FilterFieldNotAllowed; use TinyBlocks\HttpQuery\Exceptions\FilterOperatorNotAllowed; @@ -17,6 +18,7 @@ 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; @@ -42,8 +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 pagination - * always carries the incoming cursor token and the page size.
+ *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.
* * @param Schema $schema The query contract the request is validated against. * @param ServerRequestInterface $request The incoming PSR-7 server request. @@ -56,11 +60,15 @@ 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. */ 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); return new Criteria( sort: $query->sort(), diff --git a/tests/Unit/Cursor/KeysetTest.php b/tests/Unit/Cursor/KeysetTest.php index 1edf869..c66a8aa 100644 --- a/tests/Unit/Cursor/KeysetTest.php +++ b/tests/Unit/Cursor/KeysetTest.php @@ -125,24 +125,36 @@ public function testCursorWhenIncomingCursorGivenThenKeysValuesBySortField(): vo self::assertSame(['created_at' => '2023-01-15T10:30:00Z', 'id' => 5], $cursor); } - public function testCursorWhenDecodedCountMismatchesThenThrowsCursorIsInvalid(): void + public function testCriteriaWhenDecodedCountMismatchesThenThrowsCursorIsInvalid(): void { /** @Given an opaque token carrying a single key value */ $token = Token::fromKeys(keys: [5])->toString(); - /** @And a keyset view whose sort carries two fields */ - $keyset = Criteria::fromQuery( - schema: $this->schema, - request: Query::from( - parameters: ['sort' => 'created_at,id', 'page' => ['cursor' => $token, 'size' => '2']] - ) - )->keyset(); + /** @And a request whose sort carries two fields */ + $request = Query::from( + parameters: ['sort' => 'created_at,id', 'page' => ['cursor' => $token, 'size' => '2']] + ); /** @Then an exception indicating the cursor is invalid is raised */ $this->expectException(CursorIsInvalid::class); - /** @When reading the incoming cursor key values */ - $keyset->cursor(); + /** @When the criteria is parsed from the request */ + Criteria::fromQuery(schema: $this->schema, request: $request); + } + + public function testCriteriaWhenTokenCannotBeDecodedThenThrowsCursorIsInvalid(): void + { + /** @Given a request carrying a token that never came out of the codec */ + $request = Query::from( + parameters: ['sort' => 'created_at,id', 'page' => ['cursor' => 'not-a-cursor', 'size' => '2']] + ); + + /** @Then an exception indicating the cursor is invalid is raised */ + $this->expectException(CursorIsInvalid::class); + $this->expectExceptionMessage('Cursor token