From f1307e0b726d198d7ae365826859efb32d51daf0 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 6 Oct 2025 16:42:40 +1300 Subject: [PATCH 1/2] Validate order --- src/Database/Validator/Query/Order.php | 5 + .../e2e/Adapter/Scopes/RelationshipTests.php | 122 ++++++++++++++++-- 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/src/Database/Validator/Query/Order.php b/src/Database/Validator/Query/Order.php index 9487f55d1..90f7746b2 100644 --- a/src/Database/Validator/Query/Order.php +++ b/src/Database/Validator/Query/Order.php @@ -37,6 +37,11 @@ protected function isValidAttribute(string $attribute): bool // For relationships, just validate the top level. // Will validate each nested level during the recursive calls. $attribute = \explode('.', $attribute)[0]; + + if (isset($this->schema[$attribute])) { + $this->message = 'Cannot order by nested attribute: ' . $attribute; + return false; + } } // Search for attribute in schema diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 67d9d4535..acb47b229 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -4344,51 +4344,51 @@ public function testCountAndSumWithRelationshipQueries(): void 'author' => 'author2', ])); - // Test 1: Count posts by author name + // Count posts by author name $count = $database->count('postsCount', [ Query::equal('author.name', ['Alice']), ]); $this->assertEquals(3, $count); - // Test 2: Count published posts by author age filter + // Count published posts by author age filter $count = $database->count('postsCount', [ Query::lessThan('author.age', 30), Query::equal('published', [true]), ]); $this->assertEquals(1, $count); // Only Bob's published post - // Test 3: Count posts by author name (different author) + // Count posts by author name (different author) $count = $database->count('postsCount', [ Query::equal('author.name', ['Bob']), ]); $this->assertEquals(2, $count); - // Test 4: Count with no matches (author with no posts) + // Count with no matches (author with no posts) $count = $database->count('postsCount', [ Query::equal('author.name', ['Charlie']), ]); $this->assertEquals(0, $count); - // Test 5: Sum views for posts by author name + // Sum views for posts by author name $sum = $database->sum('postsCount', 'views', [ Query::equal('author.name', ['Alice']), ]); $this->assertEquals(350, $sum); // 100 + 200 + 50 - // Test 6: Sum views for published posts by author age + // Sum views for published posts by author age $sum = $database->sum('postsCount', 'views', [ Query::lessThan('author.age', 30), Query::equal('published', [true]), ]); $this->assertEquals(150, $sum); // Only Bob's published post - // Test 7: Sum views for Bob's posts + // Sum views for Bob's posts $sum = $database->sum('postsCount', 'views', [ Query::equal('author.name', ['Bob']), ]); $this->assertEquals(225, $sum); // 150 + 75 - // Test 8: Sum with no matches + // Sum with no matches $sum = $database->sum('postsCount', 'views', [ Query::equal('author.name', ['Charlie']), ]); @@ -4398,4 +4398,110 @@ public function testCountAndSumWithRelationshipQueries(): void $database->deleteCollection('authorsCount'); $database->deleteCollection('postsCount'); } + + /** + // and cursor queries properly reject relationship (dot-path) attributes. + * + * Relationship attributes like 'author.name' are NOT supported for ordering because: + * 1. Only filter queries go through convertRelationshipFiltersToSubqueries() + * 2. Order attributes are passed directly to the adapter without relationship resolution + * 3. The Order validator now catches dot-path attributes and rejects them with a clear error + * 4. Cursor validation doesn't need separate dot-path checks since order validation runs first + */ + public function testOrderAndCursorWithRelationshipQueries(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $database->createCollection('authorsOrder'); + $database->createCollection('postsOrder'); + + $database->createAttribute('authorsOrder', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('authorsOrder', 'age', Database::VAR_INTEGER, 0, true); + + $database->createAttribute('postsOrder', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('postsOrder', 'views', Database::VAR_INTEGER, 0, true); + + $database->createRelationship( + collection: 'postsOrder', + relatedCollection: 'authorsOrder', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'author', + twoWayKey: 'postsOrder' + ); + + // Create authors + $alice = $database->createDocument('authorsOrder', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'Alice', + 'age' => 30, + ])); + + $bob = $database->createDocument('authorsOrder', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'Bob', + 'age' => 25, + ])); + + // Create posts + $database->createDocument('postsOrder', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Post 1', + 'views' => 100, + 'author' => $alice->getId(), + ])); + + $database->createDocument('postsOrder', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Post 2', + 'views' => 200, + 'author' => $bob->getId(), + ])); + + $database->createDocument('postsOrder', new Document([ + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Post 3', + 'views' => 150, + 'author' => $alice->getId(), + ])); + + // Order by relationship attribute should fail with validation error + $caught = false; + try { + $database->find('postsOrder', [ + Query::orderAsc('author.name') + ]); + } catch (\Throwable $e) { + $caught = true; + $this->assertStringContainsString('Cannot order by nested attribute', $e->getMessage()); + } + $this->assertTrue($caught, 'Should throw exception for nested order attribute'); + + // Cursor with relationship order attribute should fail with same validation error + $caught = false; + try { + $firstPost = $database->findOne('postsOrder', [ + Query::orderAsc('title') + ]); + + $database->find('postsOrder', [ + Query::orderAsc('author.name'), + Query::cursorAfter($firstPost) + ]); + } catch (\Throwable $e) { + $caught = true; + $this->assertStringContainsString('Cannot order by nested attribute', $e->getMessage()); + } + $this->assertTrue($caught, 'Should throw exception for nested order attribute with cursor'); + + + // Clean up + $database->deleteCollection('authorsOrder'); + $database->deleteCollection('postsOrder'); + } } From 1b225981cc0cdd3faf597b8639f1ae60b299477e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 6 Oct 2025 16:46:24 +1300 Subject: [PATCH 2/2] Update tests/e2e/Adapter/Scopes/RelationshipTests.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- tests/e2e/Adapter/Scopes/RelationshipTests.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index acb47b229..4f2219423 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -4400,7 +4400,7 @@ public function testCountAndSumWithRelationshipQueries(): void } /** - // and cursor queries properly reject relationship (dot-path) attributes. + * Test that order and cursor queries properly reject relationship (dot-path) attributes. * * Relationship attributes like 'author.name' are NOT supported for ordering because: * 1. Only filter queries go through convertRelationshipFiltersToSubqueries()