From 2fe76cef67e053080048556a84f2633f71f144f0 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 16 Oct 2026 15:02:50 +1300 Subject: [PATCH] fix(find): refuse a cursor along an order with no comparable value A cursor pages along the values of the read's order, comparing each row's value with the cursor's. A read ordered by an array attribute passed the array the cursor holds to Query::greaterThan(), a TypeError, and one ordered by a spatial attribute reached MySQL as a geometry comparison it refuses with error 1210, both server errors. MongoDB, Memory and Redis answered such a page, but by comparisons that do not follow the order, so paging along it skipped or repeated rows. 7.4.2 failed the SQL engines the same way ("Unknown PDO Type for array"). find() refuses a cursor along an array, object, spatial or vector attribute as an invalid query on every adapter, under a join alias too. Ordering by such an attribute without a cursor keeps working as it did. The refusal is a plain query error rather than Exception\Order, which consumers report as a null order value. Co-Authored-By: Claude Opus 5.5 --- src/Database/Trait/Documents.php | 36 +++++++++++ tests/e2e/Adapter/Scopes/DocumentTests.php | 69 ++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index b95120b54..e4d26eb8e 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -85,6 +85,15 @@ trait Documents Method::VarSamp, ]; + private const array UNCOMPARABLE_TYPES = [ + ColumnType::Json, + ColumnType::Object, + ColumnType::Point, + ColumnType::Linestring, + ColumnType::Polygon, + ColumnType::Vector, + ]; + private const string DOCUMENT_CACHE_ACTIVE_PREFIX = 'active:'; private const string DOCUMENT_CACHE_BLOCKED_PREFIX = 'blocked:'; @@ -4298,6 +4307,8 @@ private function fetch(Collection $collection, array $queries, PermissionType $f throw new QueryException('Cursor pagination is not supported with aggregation queries'); } + $this->assertCursorOrdersAreComparable($collection, $orderAttributes, $joinedCollections); + if ($joins === [] && ! $distinct && $this->validation()->get() && $cursor->getId() === '') { throw new QueryException('Invalid query: Invalid cursor: '.(new UID($this->adapter->limits()->uidLength))->getDescription()); } @@ -5432,6 +5443,31 @@ private function qualifyJoinedOrders(Document $collection, array $orderAttribute return [$orderAttributes, $cursor]; } + /** + * A cursor pages along the values of a read's order, which the engine compares with the cursor's: an array, object, + * spatial or vector attribute holds no value it can compare, so a read ordered by one cannot be paged by a cursor. + * + * @param array $orderAttributes + * @param array $joinedCollections The collection each join reads, by its alias + * + * @throws QueryException + */ + private function assertCursorOrdersAreComparable(Document $collection, array $orderAttributes, array $joinedCollections): void + { + foreach ($orderAttributes as $order) { + $dot = \strpos($order, '.'); + $alias = $dot === false ? null : \substr($order, 0, $dot); + $owner = $alias === null ? $collection : ($joinedCollections[$alias] ?? null); + $key = $alias === null ? $order : \substr($order, $dot + 1); + + foreach ($owner === null ? [] : Collection::fromDocument($owner)->attributes() as $attribute) { + if ($attribute->key === $key && ($attribute->array || \in_array($attribute->type, self::UNCOMPARABLE_TYPES, true))) { + throw new QueryException("Cannot page with a cursor along the order of '{$order}': an array, object, spatial or vector attribute has no value a cursor can compare"); + } + } + } + } + /** * A cursor names the row it was read from by the values of the read's order, each under the name the read orders * by. A value it lacks is never taken from an attribute of the same name elsewhere in the document. diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index bab056537..6c24a2ba8 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -1677,6 +1677,75 @@ public function testFilterValuesThatAreNotAListAreAnInvalidQuery(): void $database->deleteCollection($collection); } + /** + * A cursor on a read ordered by an array or spatial attribute reached the engine: the array cursor value failed + * Query::greaterThan() with a TypeError and MySQL refused a geometry comparison with error 1210, both server + * errors. Such an order has no value a cursor can compare, so the cursor is refused as an invalid query on every + * adapter, while the same order without a cursor, and a cursor along a scalar order, keep working. + */ + public function testCursorAlongAnOrderWithoutComparableValuesIsAnInvalidQuery(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + $spatial = $adapter->hasFeature(Feature\Spatial::class); + $objects = $adapter->supports(Capability::Objects); + $vectors = $adapter->supports(Capability::Vectors); + $collection = 'cursor_orders_'.\substr(\uniqid(), -6); + $database->createCollection(Collection::create( + id: $collection, + attributes: [ + Attribute::integer(key: 'n', required: true), + Attribute::string(key: 'sa', size: 20, array: true), + Attribute::integer(key: 'ia', array: true), + ...($spatial ? [Attribute::point(key: 'pt'), Attribute::lineString(key: 'ln')] : []), + ...($objects ? [Attribute::object(key: 'obj')] : []), + ...($vectors ? [Attribute::vector(key: 'vec', dimensions: 2)] : []), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + foreach ([1, 2, 3] as $n) { + $database->createDocument($collection, new Document([ + '$id' => 'row'.$n, + 'n' => $n, + 'sa' => ['s'.$n], + 'ia' => [$n], + ...($spatial ? ['pt' => [(float) $n, (float) $n], 'ln' => [[0.0, 0.0], [(float) $n, (float) $n]]] : []), + ...($objects ? ['obj' => ['n' => $n]] : []), + ...($vectors ? ['vec' => [(float) $n, 1.0]] : []), + '$permissions' => [Permission::read(Role::any())], + ])); + } + + $cursor = $database->getDocument($collection, 'row1'); + $orders = ['sa', 'ia', ...($spatial ? ['pt', 'ln'] : []), ...($objects ? ['obj'] : []), ...($vectors ? ['vec'] : [])]; + foreach ($orders as $order) { + $pages = [ + 'asc after' => [Query::orderAsc($order), Query::cursorAfter($cursor)], + 'desc after' => [Query::orderDesc($order), Query::cursorAfter($cursor)], + 'asc before' => [Query::orderAsc($order), Query::cursorBefore($cursor)], + 'behind a scalar order' => [Query::orderAsc('n'), Query::orderAsc($order), Query::cursorAfter($cursor)], + ]; + foreach ($pages as $case => $queries) { + try { + $database->find($collection, $queries); + $this->fail("A cursor along {$order}, {$case}, is refused"); + } catch (\Throwable $exception) { + $this->assertInstanceOf(QueryException::class, $exception, "{$order}, {$case}: ".$exception::class.': '.$exception->getMessage()); + $this->assertSame("Cannot page with a cursor along the order of '{$order}': an array, object, spatial or vector attribute has no value a cursor can compare", $exception->getMessage()); + } + } + + if ($order !== 'vec') { + $this->assertCount(3, $database->find($collection, [Query::orderAsc($order)]), "an order by {$order} without a cursor"); + } + } + + $next = $database->find($collection, [Query::orderAsc('n'), Query::cursorAfter($cursor)]); + $this->assertSame(['row2', 'row3'], \array_map(static fn (Document $document): string => $document->getId(), $next)); + + $database->deleteCollection($collection); + } + public function testFind(): void { $this->initMoviesFixture();