Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/Database/Trait/Documents.php
Original file line number Diff line number Diff line change
Expand Up @@ -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:';
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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<string> $orderAttributes
* @param array<string, Document> $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.
Expand Down
69 changes: 69 additions & 0 deletions tests/e2e/Adapter/Scopes/DocumentTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading