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)); + } + } +}