diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index cf8ed08b8..8d2c2cfbc 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1664,6 +1664,16 @@ protected function getPDOType(mixed $value): int }; } + /** + * Get the SQL function for random ordering + * + * @return string + */ + protected function getRandomOrder(): string + { + return 'RAND()'; + } + /** * Size of POINT spatial type * diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 18c645934..f86a5f26c 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1792,6 +1792,16 @@ protected function getPDOType(mixed $value): int }; } + /** + * Get the SQL function for random ordering + * + * @return string + */ + protected function getRandomOrder(): string + { + return 'RANDOM()'; + } + /** * Size of POINT spatial type * diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index c51d525c9..975fa50bd 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1725,6 +1725,13 @@ protected function getPDO(): mixed */ abstract protected function getPDOType(mixed $value): int; + /** + * Get the SQL function for random ordering + * + * @return string + */ + abstract protected function getRandomOrder(): string; + /** * Returns default PDO configuration * @@ -2395,10 +2402,18 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $cursorWhere = []; foreach ($orderAttributes as $i => $originalAttribute) { + $orderType = $orderTypes[$i] ?? Database::ORDER_ASC; + + // Handle random ordering specially + if ($orderType === Database::ORDER_RANDOM) { + $orders[] = $this->getRandomOrder(); + continue; + } + $attribute = $this->getInternalKeyForAttribute($originalAttribute); $attribute = $this->filter($attribute); - $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC); + $orderType = $this->filter($orderType); $direction = $orderType; if ($cursorDirection === Database::CURSOR_BEFORE) { diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 36f7e823a..a892b6626 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1294,4 +1294,14 @@ public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool { return true; } + + /** + * Get the SQL function for random ordering + * + * @return string + */ + protected function getRandomOrder(): string + { + return 'RANDOM()'; + } } diff --git a/src/Database/Database.php b/src/Database/Database.php index 72fd8574c..5e65422d7 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -91,6 +91,8 @@ class Database public const ORDER_ASC = 'ASC'; public const ORDER_DESC = 'DESC'; + public const ORDER_RANDOM = 'RANDOM'; + // Permissions public const PERMISSION_CREATE = 'create'; public const PERMISSION_READ = 'read'; diff --git a/src/Database/Query.php b/src/Database/Query.php index d8f1557d9..96383efdb 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -46,6 +46,7 @@ class Query // Order methods public const TYPE_ORDER_DESC = 'orderDesc'; public const TYPE_ORDER_ASC = 'orderAsc'; + public const TYPE_ORDER_RANDOM = 'orderRandom'; // Pagination methods public const TYPE_LIMIT = 'limit'; @@ -93,6 +94,7 @@ class Query self::TYPE_SELECT, self::TYPE_ORDER_DESC, self::TYPE_ORDER_ASC, + self::TYPE_ORDER_RANDOM, self::TYPE_LIMIT, self::TYPE_OFFSET, self::TYPE_CURSOR_AFTER, @@ -247,6 +249,7 @@ public static function isMethod(string $value): bool self::TYPE_NOT_SEARCH, self::TYPE_ORDER_ASC, self::TYPE_ORDER_DESC, + self::TYPE_ORDER_RANDOM, self::TYPE_LIMIT, self::TYPE_OFFSET, self::TYPE_CURSOR_AFTER, @@ -600,6 +603,16 @@ public static function orderAsc(string $attribute = ''): self return new self(self::TYPE_ORDER_ASC, $attribute); } + /** + * Helper method to create Query with orderRandom method + * + * @return Query + */ + public static function orderRandom(): self + { + return new self(self::TYPE_ORDER_RANDOM); + } + /** * Helper method to create Query with limit method * @@ -830,13 +843,16 @@ public static function groupByType(array $queries): array switch ($method) { case Query::TYPE_ORDER_ASC: case Query::TYPE_ORDER_DESC: + case Query::TYPE_ORDER_RANDOM: if (!empty($attribute)) { $orderAttributes[] = $attribute; } - $orderTypes[] = $method === Query::TYPE_ORDER_ASC - ? Database::ORDER_ASC - : Database::ORDER_DESC; + $orderTypes[] = match ($method) { + Query::TYPE_ORDER_ASC => Database::ORDER_ASC, + Query::TYPE_ORDER_DESC => Database::ORDER_DESC, + Query::TYPE_ORDER_RANDOM => Database::ORDER_RANDOM, + }; break; case Query::TYPE_LIMIT: diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index a2363101b..727c9eed7 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -85,7 +85,8 @@ public function isValid($value): bool Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE => Base::METHOD_TYPE_CURSOR, Query::TYPE_ORDER_ASC, - Query::TYPE_ORDER_DESC => Base::METHOD_TYPE_ORDER, + Query::TYPE_ORDER_DESC, + Query::TYPE_ORDER_RANDOM => Base::METHOD_TYPE_ORDER, Query::TYPE_EQUAL, Query::TYPE_NOT_EQUAL, Query::TYPE_LESSER, diff --git a/src/Database/Validator/Query/Order.php b/src/Database/Validator/Query/Order.php index f0e7f2d56..9cc520b4f 100644 --- a/src/Database/Validator/Query/Order.php +++ b/src/Database/Validator/Query/Order.php @@ -60,6 +60,10 @@ public function isValid($value): bool return $this->isValidAttribute($attribute); } + if ($method === Query::TYPE_ORDER_RANDOM) { + return true; // orderRandom doesn't need an attribute + } + return false; } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 79e2d8299..9d3ab881d 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -3459,6 +3459,72 @@ public function testFindNotEndsWith(): void $this->assertLessThanOrEqual(5, count($documents)); // But still excluding Marvel movies } + public function testFindOrderRandom(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Test orderRandom with default limit + $documents = $database->find('movies', [ + Query::orderRandom(), + Query::limit(1), + ]); + $this->assertEquals(1, count($documents)); + $this->assertNotEmpty($documents[0]['name']); // Ensure we got a valid document + + // Test orderRandom with multiple documents + $documents = $database->find('movies', [ + Query::orderRandom(), + Query::limit(3), + ]); + $this->assertEquals(3, count($documents)); + + // Test that orderRandom returns different results (not guaranteed but highly likely) + $firstSet = $database->find('movies', [ + Query::orderRandom(), + Query::limit(3), + ]); + $secondSet = $database->find('movies', [ + Query::orderRandom(), + Query::limit(3), + ]); + + // Extract IDs for comparison + $firstIds = array_map(fn ($doc) => $doc['$id'], $firstSet); + $secondIds = array_map(fn ($doc) => $doc['$id'], $secondSet); + + // While not guaranteed to be different, with 6 movies and selecting 3, + // the probability of getting the same set in the same order is very low + // We'll just check that we got valid results + $this->assertEquals(3, count($firstIds)); + $this->assertEquals(3, count($secondIds)); + + // Test orderRandom with more than available documents + $documents = $database->find('movies', [ + Query::orderRandom(), + Query::limit(10), // We only have 6 movies + ]); + $this->assertLessThanOrEqual(6, count($documents)); // Should return all available documents + + // Test orderRandom with filters + $documents = $database->find('movies', [ + Query::greaterThan('price', 10), + Query::orderRandom(), + Query::limit(2), + ]); + $this->assertLessThanOrEqual(2, count($documents)); + foreach ($documents as $document) { + $this->assertGreaterThan(10, $document['price']); + } + + // Test orderRandom without explicit limit (should use default) + $documents = $database->find('movies', [ + Query::orderRandom(), + ]); + $this->assertGreaterThan(0, count($documents)); + $this->assertLessThanOrEqual(25, count($documents)); // Default limit is 25 + } + public function testFindNotBetween(): void { /** @var Database $database */ diff --git a/tests/unit/QueryTest.php b/tests/unit/QueryTest.php index c48755cb2..b9b261dc0 100644 --- a/tests/unit/QueryTest.php +++ b/tests/unit/QueryTest.php @@ -153,6 +153,12 @@ public function testCreate(): void $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); $this->assertEquals('$updatedAt', $query->getAttribute()); $this->assertEquals(['2023-01-01T00:00:00.000Z', '2023-12-31T23:59:59.999Z'], $query->getValues()); + + // Test orderRandom query + $query = Query::orderRandom(); + $this->assertEquals(Query::TYPE_ORDER_RANDOM, $query->getMethod()); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); } /** @@ -365,6 +371,12 @@ public function testParse(): void } catch (QueryException $e) { $this->assertEquals('Invalid query. Must be an array, got boolean', $e->getMessage()); } + + // Test orderRandom query parsing + $query = Query::parse(Query::orderRandom()->toString()); + $this->assertEquals('orderRandom', $query->getMethod()); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); } public function testIsMethod(): void @@ -389,6 +401,7 @@ public function testIsMethod(): void $this->assertTrue(Query::isMethod('offset')); $this->assertTrue(Query::isMethod('cursorAfter')); $this->assertTrue(Query::isMethod('cursorBefore')); + $this->assertTrue(Query::isMethod('orderRandom')); $this->assertTrue(Query::isMethod('isNull')); $this->assertTrue(Query::isMethod('isNotNull')); $this->assertTrue(Query::isMethod('between')); @@ -417,6 +430,7 @@ public function testIsMethod(): void $this->assertTrue(Query::isMethod(QUERY::TYPE_OFFSET)); $this->assertTrue(Query::isMethod(QUERY::TYPE_CURSOR_AFTER)); $this->assertTrue(Query::isMethod(QUERY::TYPE_CURSOR_BEFORE)); + $this->assertTrue(Query::isMethod(QUERY::TYPE_ORDER_RANDOM)); $this->assertTrue(Query::isMethod(QUERY::TYPE_IS_NULL)); $this->assertTrue(Query::isMethod(QUERY::TYPE_IS_NOT_NULL)); $this->assertTrue(Query::isMethod(QUERY::TYPE_BETWEEN)); @@ -437,5 +451,6 @@ public function testNewQueryTypesInTypesArray(): void $this->assertContains(Query::TYPE_NOT_STARTS_WITH, Query::TYPES); $this->assertContains(Query::TYPE_NOT_ENDS_WITH, Query::TYPES); $this->assertContains(Query::TYPE_NOT_BETWEEN, Query::TYPES); + $this->assertContains(Query::TYPE_ORDER_RANDOM, Query::TYPES); } }