diff --git a/src/Database/Database.php b/src/Database/Database.php index 144428665..f0fbb5dfb 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9451,8 +9451,11 @@ public function decode(Document $collection, Document $document, array $selectio } } + $internalKeys = []; + foreach ($this->getInternalAttributes() as $attribute) { $attributes[] = $attribute; + $internalKeys[$attribute['$id']] = true; } $hasRelationshipSelections = false; @@ -9474,7 +9477,11 @@ public function decode(Document $collection, Document $document, array $selectio continue; } - if (\is_null($value)) { + // filter() strips the leading "$" off an internal key, leaving a name a user + // attribute is allowed to have ("$collection" -> "collection"). An internal value + // never reaches the document under that name, so the alias lookup below has + // nothing of its own to find and can only steal the user's attribute. + if (\is_null($value) && !isset($internalKeys[$key])) { $filteredKey = $this->adapter->filter($key); $value = $document->getAttribute($filteredKey); diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index fd969cb1b..2b6d37822 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -2684,6 +2684,66 @@ public function testFindBasicChecks(): void $this->assertEquals($movieDocuments[0]->getId(), $documents[0]->getId()); } + public function testFindAttributeNamedAfterInternalKey(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(__FUNCTION__); + $this->assertEquals(true, $database->createAttribute(__FUNCTION__, 'collection', Database::VAR_STRING, 128, false)); + + $database->createDocument(__FUNCTION__, new Document([ + '$id' => ID::custom('clash'), + '$permissions' => [ + Permission::read(Role::any()), + ], + 'collection' => 'value', + ])); + + $database->createDocument(__FUNCTION__, new Document([ + '$id' => ID::custom('clashNull'), + '$permissions' => [ + Permission::read(Role::any()), + ], + 'collection' => null, + ])); + + $documents = $database->find(__FUNCTION__, [Query::orderAsc('$id')]); + + $this->assertCount(2, $documents); + $this->assertEquals('value', $documents[0]->getAttribute('collection')); + // getAttribute() reads a dropped key and a null value the same way + $this->assertTrue($documents[1]->offsetExists('collection')); + } + + public function testFindAttributeNamedAfterTenantKey(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getSharedTables()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection(__FUNCTION__); + $this->assertEquals(true, $database->createAttribute(__FUNCTION__, 'tenant', Database::VAR_STRING, 128, false)); + + $database->createDocument(__FUNCTION__, new Document([ + '$id' => ID::custom('clash'), + '$permissions' => [ + Permission::read(Role::any()), + ], + 'tenant' => 'value', + ])); + + // A select leaves _tenant out of the projection, so $tenant is null at decode + $documents = $database->find(__FUNCTION__, [Query::select(['tenant'])]); + + $this->assertCount(1, $documents); + $this->assertEquals('value', $documents[0]->getAttribute('tenant')); + } + public function testFindCheckPermissions(): void { /** @var Database $database */