From 94f99ee38fefe5e3a585d8ef03d9bea9ce1ca77e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 16 Oct 2026 00:05:01 +1300 Subject: [PATCH] fix(query): refuse ordering by a relationship that holds no column A one-to-many parent, a many-to-one child and either side of a many-to-many keep their related ids on the other collection or in a junction table, so their collection has no column to order by. The order validator accepted every relationship key, the engine then failed on the unknown column, and the read surfaced that as NotFound "Attribute not found", which a consumer answers as a missing collection. The validator now marks those keys from Joined\Collection::columns(), which already tells which side of a relationship stores its column, and refuses an orderAsc() or orderDesc() on them with "Cannot order by relationship attribute: ", the QueryException every other invalid order raises. Relationships that hold a column order as before. Co-Authored-By: Claude Opus 5.5 --- src/Database/Validator/Query/Order.php | 21 +++++ tests/unit/RelationshipOrderTest.php | 112 +++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 tests/unit/RelationshipOrderTest.php diff --git a/src/Database/Validator/Query/Order.php b/src/Database/Validator/Query/Order.php index f3ea45c0a..3ab681a80 100644 --- a/src/Database/Validator/Query/Order.php +++ b/src/Database/Validator/Query/Order.php @@ -6,6 +6,7 @@ use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\Query\Joined\Attributes; +use Utopia\Database\Validator\Query\Joined\Collection; use Utopia\Query\Method; use Utopia\Query\Query as BaseQuery; @@ -29,6 +30,14 @@ class Order extends Base */ protected array $aggregationAliases = []; + /** + * The relationships whose related ids live on the other collection or in a junction table, so this collection + * holds no column to order by. + * + * @var array + */ + private array $columnless = []; + private bool $aggregates = false; /** @@ -48,6 +57,12 @@ public function __construct( $attribute = $attribute instanceof Attribute ? $attribute : Attribute::fromDocument($attribute); $this->schema[$attribute->key] = true; } + + foreach (Collection::columns($attributes) as $key => $holdsColumn) { + if (! $holdsColumn) { + $this->columnless[$key] = true; + } + } } protected function isValidAttribute(string $attribute): bool @@ -109,6 +124,12 @@ public function isValid(mixed $value): bool $attribute = $value->getAttribute(); if ($method === Method::OrderAsc || $method === Method::OrderDesc) { + if (isset($this->columnless[$attribute])) { + $this->message = 'Cannot order by relationship attribute: '.$attribute; + + return false; + } + return $this->isValidAttribute($attribute) && $this->isGroupedOrder($attribute); } diff --git a/tests/unit/RelationshipOrderTest.php b/tests/unit/RelationshipOrderTest.php new file mode 100644 index 000000000..a6cdf7150 --- /dev/null +++ b/tests/unit/RelationshipOrderTest.php @@ -0,0 +1,112 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); + $this->database + ->setDatabase('relationship_order') + ->setNamespace('relationship_order_'.\uniqid()); + $this->database->getAuthorization()->addRole(Role::any()->toString()); + $this->database->create(); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + foreach (['authors', 'books', 'tags', 'covers', 'shelves'] as $collection) { + $this->database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 16)], + permissions: $permissions, + )); + } + + $this->database->createRelationship('authors', Relationship::oneToMany(relatedCollection: 'books', key: 'books', twoWay: true, twoWayKey: 'author')); + $this->database->createRelationship('books', Relationship::manyToMany(relatedCollection: 'tags', key: 'tags', twoWay: true, twoWayKey: 'books')); + $this->database->createRelationship('books', Relationship::oneToOne(relatedCollection: 'covers', key: 'cover', twoWay: true, twoWayKey: 'book')); + $this->database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'shelves', key: 'shelf', twoWay: true, twoWayKey: 'books')); + + $this->database->createDocument('authors', new Document(['$id' => 'ann', 'name' => 'Ann', '$permissions' => [Permission::read(Role::any())]])); + $this->database->createDocument('books', new Document(['$id' => 'b1', 'name' => 'One', 'author' => 'ann', '$permissions' => [Permission::read(Role::any())]])); + } + + /** + * @return iterable + */ + public static function columnless(): iterable + { + yield 'one-to-many parent' => ['authors', 'books']; + yield 'many-to-many parent' => ['books', 'tags']; + yield 'many-to-many child' => ['tags', 'books']; + yield 'many-to-one child' => ['shelves', 'books']; + } + + #[DataProvider('columnless')] + public function testOrderByARelationshipWithoutAColumnIsAnInvalidQuery(string $collection, string $attribute): void + { + foreach ([Query::orderAsc($attribute), Query::orderDesc($attribute)] as $order) { + foreach ([ + 'find()' => fn (): mixed => $this->database->find($collection, [$order]), + 'count()' => fn (): mixed => $this->database->count($collection, [$order]), + ] as $read => $call) { + $error = null; + try { + $call(); + } catch (Throwable $caught) { + $error = $caught; + } + + $this->assertInstanceOf(QueryException::class, $error, $read.' '.$order->getMethod()->value.': '.($error === null ? 'accepted' : $error::class.': '.$error->getMessage())); + $this->assertSame('Invalid query: Cannot order by relationship attribute: '.$attribute, $error->getMessage(), $read); + } + } + } + + /** + * @return iterable}> + */ + public static function stored(): iterable + { + yield 'one-to-many child' => ['books', 'author', ['b1']]; + yield 'one-to-one parent' => ['books', 'cover', ['b1']]; + yield 'two-way one-to-one child' => ['covers', 'book', []]; + yield 'many-to-one parent' => ['books', 'shelf', ['b1']]; + } + + /** + * @param list $ids + */ + #[DataProvider('stored')] + public function testOrderByARelationshipThatHoldsAColumnIsRead(string $collection, string $attribute, array $ids): void + { + foreach ([Query::orderAsc($attribute), Query::orderDesc($attribute)] as $order) { + $documents = $this->database->find($collection, [$order]); + $this->assertSame($ids, \array_map(static fn (Document $document): string => $document->getId(), $documents)); + } + } +}