From e000582f1c29fcfab4debdbfeb120d4619e0e171 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 16 Oct 2026 00:02:08 +1300 Subject: [PATCH 1/2] fix(query): refuse a nested path on an attribute that holds no nested values The filter and select validators treated any dotted attribute as a relationship path and checked only its first segment. On a plain attribute, equal('name.x') or select(['name.x']) passed validation and reached the engine, which named a column that does not exist: MySQL 1054 and PostgreSQL 42703 surfaced as NotFound('Attribute not found'), so callers answered 404 or 500 for a malformed query, and MongoDB silently matched nothing. A dotted path now passes only when its base is a relationship or an object attribute, or a join alias, and is otherwise refused as an invalid query before it reaches the adapter. Order already refused nested paths, and aggregate and group-by accept only exact schema keys. Schemaless adapters keep accepting any path. Co-Authored-By: Claude Opus 5.5 --- src/Database/Validator/Query/Filter.php | 16 ++++++++ src/Database/Validator/Query/Select.php | 18 +++++++++ tests/e2e/Adapter/Scopes/DocumentTests.php | 39 ++++++++++++++++++ tests/unit/Validator/DocumentQueriesTest.php | 2 +- tests/unit/Validator/Query/FilterTest.php | 42 ++++++++++++++++++++ tests/unit/Validator/Query/SelectTest.php | 20 ++++++++++ 6 files changed, 136 insertions(+), 1 deletion(-) diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 807655821..2ed9bbcc9 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -84,6 +84,12 @@ protected function isValidAttribute(string $attribute): bool return false; } + if (! $this->isNestable($alias)) { + $this->message = 'Cannot query nested attribute on: '.$alias; + + return false; + } + // For relationships, just validate the top level. // will validate each nested level during the recursive calls. $attribute = $alias; @@ -98,6 +104,16 @@ protected function isValidAttribute(string $attribute): bool return true; } + private function isNestable(string $base): bool + { + $definition = $this->schema[$base] ?? null; + if (! $this->supportForAttributes || ! \is_array($definition)) { + return true; + } + + return \in_array($definition['type'] ?? null, [ColumnType::Relationship, ColumnType::Object], true); + } + private function isValidObjectPath(string $base, string $path): bool { /** @var array $definition */ diff --git a/src/Database/Validator/Query/Select.php b/src/Database/Validator/Query/Select.php index d9f75a338..8ad7a192c 100644 --- a/src/Database/Validator/Query/Select.php +++ b/src/Database/Validator/Query/Select.php @@ -9,6 +9,7 @@ use Utopia\Database\Validator\Query\Joined\Attributes; use Utopia\Query\Method; use Utopia\Query\Query as BaseQuery; +use Utopia\Query\Schema\ColumnType; /** * Validates select query methods ensuring referenced attributes exist in the schema, are not duplicated and, in an @@ -30,6 +31,13 @@ class Select extends Base */ private array $relationships = []; + /** + * The object attributes of the collection. + * + * @var array + */ + private array $objects = []; + /** * Whether the query set holds an aggregate. */ @@ -55,6 +63,10 @@ public function __construct(array $attributes = [], protected bool $supportForAt if ($attribute->relationship !== null) { $this->relationships[$attribute->key] = true; } + + if ($attribute->type === ColumnType::Object) { + $this->objects[$attribute->key] = true; + } } } @@ -156,6 +168,12 @@ public function isValid(mixed $value): bool return $this->rejectUngrouped($attribute); } + if (! $this->isAggregation() && $this->supportForAttributes && isset($this->schema[$alias]) && ! isset($this->relationships[$alias]) && ! isset($this->objects[$alias])) { + $this->message = 'Cannot select nested attribute on: '.$alias; + + return false; + } + // For relationships, just validate the top level. // Will validate each nested level during the recursive calls. $attribute = $alias; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 973550113..4061580ac 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -11154,4 +11154,43 @@ public function testDistinctIsRefusedWhereTheAdapterCannotDeduplicate(): void $database->deleteCollection($collection); } } + + public function testNestedPathOnAPlainAttributeIsAnInvalidQuery(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = __FUNCTION__; + $database->createCollection(Collection::create(id: $collection, permissions: [Permission::read(Role::any()), Permission::create(Role::any())])); + + try { + $database->createAttribute($collection, Attribute::string(key: 'name', size: 64, required: true)); + $database->createDocument($collection, new Document(['name' => 'a', '$permissions' => [Permission::read(Role::any())]])); + + $reads = [ + 'filter' => fn (): array => $database->find($collection, [Query::equal('name.x', ['a'])]), + 'count' => fn (): int => $database->count($collection, [Query::equal('name.x', ['a'])]), + 'select' => fn (): array => $database->find($collection, [Query::select(['name.x'])]), + ]; + + foreach ($reads as $shape => $read) { + try { + $read(); + $this->fail("A {$shape} on a nested path under a plain attribute must be refused"); + } catch (QueryException $e) { + $this->assertStringContainsString('nested attribute on: name', $e->getMessage(), $shape); + } + } + + $this->assertCount(1, $database->find($collection, [Query::equal('name', ['a'])])); + } finally { + $database->deleteCollection($collection); + } + } } diff --git a/tests/unit/Validator/DocumentQueriesTest.php b/tests/unit/Validator/DocumentQueriesTest.php index 8da690263..09614bf4b 100644 --- a/tests/unit/Validator/DocumentQueriesTest.php +++ b/tests/unit/Validator/DocumentQueriesTest.php @@ -67,7 +67,7 @@ public function test_valid_queries(): void $this->assertEquals(true, $validator->isValid($queries)); $queries[] = Query::select(['price.relation']); - $this->assertEquals(true, $validator->isValid($queries)); + $this->assertFalse($validator->isValid($queries), 'price is a plain attribute, so a path under it names a column that does not exist'); } /** diff --git a/tests/unit/Validator/Query/FilterTest.php b/tests/unit/Validator/Query/FilterTest.php index 9ae56806e..afdb6b7b2 100644 --- a/tests/unit/Validator/Query/FilterTest.php +++ b/tests/unit/Validator/Query/FilterTest.php @@ -3,9 +3,12 @@ namespace Tests\Unit\Validator\Query; use PHPUnit\Framework\TestCase; +use Utopia\Database\Attribute; use Utopia\Database\Document; use Utopia\Database\Exception; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationshipSide; use Utopia\Database\Validator\Query\Filter; use Utopia\Query\Method; use Utopia\Query\Schema\ColumnType; @@ -217,6 +220,45 @@ public function test_not_between(): void $this->assertEquals('NotBetween queries require exactly two values.', $this->validator->getDescription()); } + public function test_nested_path_on_a_plain_attribute_is_rejected(): void + { + $this->assertFalse($this->validator->isValid(Query::equal('string.x', ['a']))); + $this->assertSame('Cannot query nested attribute on: string', $this->validator->getDescription()); + + $this->assertFalse($this->validator->isValid(Query::greaterThan('integer.x', 1))); + $this->assertSame('Cannot query nested attribute on: integer', $this->validator->getDescription()); + } + + public function test_nested_path_on_a_relationship_or_object_is_accepted(): void + { + $validator = new Filter( + attributes: [ + Attribute::relationship('artist', Relationship::manyToOne('artists'), RelationshipSide::Parent)->toDocument(), + new Document([ + '$id' => 'meta', + 'key' => 'meta', + 'type' => ColumnType::Object->value, + 'array' => false, + ]), + ], + idAttributeType: ColumnType::Integer->value, + ); + + $this->assertTrue($validator->isValid(Query::equal('artist.name', ['a'])), $validator->getDescription()); + $this->assertTrue($validator->isValid(Query::equal('meta.key', ['a'])), $validator->getDescription()); + } + + public function test_nested_path_on_a_plain_attribute_is_left_to_a_schemaless_adapter(): void + { + $validator = new Filter( + attributes: [new Document(['$id' => 'string', 'key' => 'string', 'type' => ColumnType::String->value, 'array' => false])], + idAttributeType: ColumnType::Integer->value, + supportForAttributes: false, + ); + + $this->assertTrue($validator->isValid(Query::equal('string.x', ['a'])), $validator->getDescription()); + } + public function test_dotted_join_alias_is_accepted_after_allow_join_aliases(): void { $this->validator->allowJoinAliases(['sec']); diff --git a/tests/unit/Validator/Query/SelectTest.php b/tests/unit/Validator/Query/SelectTest.php index 147317057..7c7c0bf5e 100644 --- a/tests/unit/Validator/Query/SelectTest.php +++ b/tests/unit/Validator/Query/SelectTest.php @@ -50,6 +50,26 @@ public function test_value_failure(): void $this->assertFalse($this->validator->isValid(Query::select(['name.artist']))); } + public function testANestedPathOnAPlainAttributeIsRefused(): void + { + $this->assertFalse($this->validator->isValid(Query::select(['attr.x']))); + $this->assertSame('Cannot select nested attribute on: attr', $this->validator->getDescription()); + } + + public function testANestedPathOnAnObjectAttributeIsAccepted(): void + { + $validator = new Select([ + new Document([ + '$id' => 'meta', + 'key' => 'meta', + 'type' => ColumnType::Object->value, + 'array' => false, + ]), + ]); + + $this->assertTrue($validator->isValid(Query::select(['meta.key'])), $validator->getDescription()); + } + /** * A non-string selection used to reach str_contains() and raise a TypeError, * which is an Error rather than an Exception, so it escaped every catch on the From 384369e650b97a38b547c1e039f8fe608b8f432c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 16 Oct 2026 00:13:41 +1300 Subject: [PATCH 2/2] test(query): name the nested-path collection within the table name limit Co-Authored-By: Claude Opus 5.5 --- tests/e2e/Adapter/Scopes/DocumentTests.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index fbe886228..89ab8855f 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -11226,7 +11226,7 @@ public function testNestedPathOnAPlainAttributeIsAnInvalidQuery(): void return; } - $collection = __FUNCTION__; + $collection = 'nestedPathPlain'; $database->createCollection(Collection::create(id: $collection, permissions: [Permission::read(Role::any()), Permission::create(Role::any())])); try {