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();