Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/Database/Validator/Query/Filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, mixed> $definition */
Expand Down
18 changes: 18 additions & 0 deletions src/Database/Validator/Query/Select.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +31,13 @@ class Select extends Base
*/
private array $relationships = [];

/**
* The object attributes of the collection.
*
* @var array<int|string, true>
*/
private array $objects = [];

/**
* Whether the query set holds an aggregate.
*/
Expand All @@ -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;
}
}
}

Expand Down Expand Up @@ -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;
Expand Down
39 changes: 39 additions & 0 deletions tests/e2e/Adapter/Scopes/DocumentTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -11214,4 +11214,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 = 'nestedPathPlain';
$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);
}
}
}
2 changes: 1 addition & 1 deletion tests/unit/Validator/DocumentQueriesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

/**
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/Validator/Query/FilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']);
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/Validator/Query/SelectTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading