diff --git a/composer.lock b/composer.lock index 4a4d4c57ab..90db213487 100644 --- a/composer.lock +++ b/composer.lock @@ -2036,16 +2036,16 @@ }, { "name": "utopia-php/cache", - "version": "4.0.1", + "version": "4.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "6f8cb03a5cb76e95b95a05103cd14cf782b8060f" + "reference": "92e02dab63606234b993b841ebf4c58845dd4620" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/6f8cb03a5cb76e95b95a05103cd14cf782b8060f", - "reference": "6f8cb03a5cb76e95b95a05103cd14cf782b8060f", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/92e02dab63606234b993b841ebf4c58845dd4620", + "reference": "92e02dab63606234b993b841ebf4c58845dd4620", "shasum": "" }, "require": { @@ -2089,9 +2089,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/4.0.1" + "source": "https://github.com/utopia-php/cache/tree/4.0.2" }, - "time": "2026-08-04T00:06:02+00:00" + "time": "2026-08-12T07:48:59+00:00" }, { "name": "utopia-php/circuit-breaker", @@ -2202,16 +2202,16 @@ }, { "name": "utopia-php/mongo", - "version": "1.5.1", + "version": "1.5.2", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "3ece830d1d72d2c9f68c656738e285629b5c72a9" + "reference": "20f9a644a356599fdd6265ba48696fc42a1f1881" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/3ece830d1d72d2c9f68c656738e285629b5c72a9", - "reference": "3ece830d1d72d2c9f68c656738e285629b5c72a9", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/20f9a644a356599fdd6265ba48696fc42a1f1881", + "reference": "20f9a644a356599fdd6265ba48696fc42a1f1881", "shasum": "" }, "require": { @@ -2257,9 +2257,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/1.5.1" + "source": "https://github.com/utopia-php/mongo/tree/1.5.2" }, - "time": "2026-08-02T00:46:11+00:00" + "time": "2026-08-12T06:57:25+00:00" }, { "name": "utopia-php/pools", @@ -4648,5 +4648,5 @@ "ext-redis": "*" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 6894d3508e..0ce0ece451 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1401,7 +1401,9 @@ public function castingAfter(Document $collection, Document $document): Document private function convertStdClassToArray(mixed $value): mixed { if (is_object($value) && get_class($value) === stdClass::class) { - return array_map($this->convertStdClassToArray(...), get_object_vars($value)); + $properties = get_object_vars($value); + + return $properties === [] ? $value : array_map($this->convertStdClassToArray(...), $properties); } if (is_array($value)) { diff --git a/src/Database/Database.php b/src/Database/Database.php index 1caba3c55a..760c1aeafe 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -669,7 +669,7 @@ function (mixed $value) { }, /** * @param string|null $value - * @return array|null + * @return mixed */ function (?string $value) { if (is_null($value)) { @@ -690,7 +690,7 @@ function (?string $value) { * @return mixed */ function (mixed $value) { - if (!\is_array($value)) { + if (!\is_array($value) && !$value instanceof \stdClass) { return $value; } @@ -708,12 +708,60 @@ function (mixed $value) { if (!is_string($value)) { return $value; } - $decoded = json_decode($value, true); - return is_array($decoded) ? $decoded : $value; + $decoded = self::decodeObject($value); + + return is_array($decoded) || $decoded instanceof \stdClass ? $decoded : $value; } ); } + private static function decodeObject(string $value): mixed + { + if (preg_match('/\{\s*\}/', $value) === 0) { + return json_decode($value, true); + } + + return self::toAssociative(json_decode($value)); + } + + private static function toAssociative(mixed $value): mixed + { + if ($value instanceof \stdClass) { + $properties = (array)$value; + + return $properties === [] ? $value : array_map(self::toAssociative(...), $properties); + } + + if (is_array($value)) { + return array_map(self::toAssociative(...), $value); + } + + return $value; + } + + private static function valuesEqual(mixed $value, mixed $old): bool + { + if ($value instanceof \stdClass && $old instanceof \stdClass) { + return self::valuesEqual((array)$value, (array)$old); + } + + if (is_array($value) && is_array($old)) { + if (array_keys($value) !== array_keys($old)) { + return false; + } + + foreach ($value as $key => $item) { + if (!self::valuesEqual($item, $old[$key])) { + return false; + } + } + + return true; + } + + return $value === $old; + } + /** * Add listener to events * Passing a null $callback will remove the listener @@ -6360,7 +6408,7 @@ public function updateDocument(string $collection, string $id, Document $documen $oldValue = $old->getAttribute($key); - if ($value !== $oldValue) { + if (!self::valuesEqual($value, $oldValue)) { $shouldUpdate = true; break; } diff --git a/src/Database/Validator/ObjectValidator.php b/src/Database/Validator/ObjectValidator.php index d4524d901d..1103128010 100644 --- a/src/Database/Validator/ObjectValidator.php +++ b/src/Database/Validator/ObjectValidator.php @@ -27,6 +27,10 @@ public function isValid(mixed $value): bool return json_last_error() === JSON_ERROR_NONE; } + if ($value instanceof \stdClass) { + return true; + } + // Allow empty or associative arrays (non-list) return empty($value) || (is_array($value) && !array_is_list($value)); } diff --git a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index aacd0c86fd..7c3ea359ad 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -5,6 +5,7 @@ use Exception; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Authorization as AuthorizationException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Index as IndexException; use Utopia\Database\Exception\Query as QueryException; @@ -963,6 +964,105 @@ public function testObjectAttributeDefaults(): void $database->deleteCollection($collectionId); } + public function testObjectAttributeEmptyObject(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForObject()) { + $this->markTestSkipped('Adapter does not support object attributes'); + } + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + $this->createAttribute($database, $collectionId, 'meta', Database::VAR_OBJECT, 0, false); + + $created = $database->createDocument($collectionId, new Document([ + '$id' => 'emptyObject', + '$permissions' => [Permission::read(Role::any())], + 'meta' => new \stdClass(), + ])); + + $this->assertSame('{}', json_encode($created->getAttribute('meta'))); + + $database->purgeCachedDocument($collectionId, 'emptyObject'); + $read = $database->getDocument($collectionId, 'emptyObject'); + + $this->assertSame('{}', json_encode($read->getAttribute('meta'))); + $cached = $database->getDocument($collectionId, 'emptyObject'); + $this->assertSame('{}', json_encode($cached->getAttribute('meta'))); + + $updated = $database->updateDocument($collectionId, 'emptyObject', new Document([ + 'meta' => new \stdClass(), + ])); + $this->assertSame($cached->getUpdatedAt(), $updated->getUpdatedAt()); + $this->assertSame('{}', json_encode($updated->getAttribute('meta'))); + + try { + $database->updateDocument($collectionId, 'emptyObject', new Document([ + 'meta' => [], + ])); + $this->fail('Changing an empty object to an empty array must require update permission'); + } catch (AuthorizationException) { + $this->addToAssertionCount(1); + } + + $database->deleteCollection($collectionId); + } + + public function testObjectAttributeNestedEmptyObjects(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForObject()) { + $this->markTestSkipped('Adapter does not support object attributes'); + } + + $collectionId = ID::unique(); + $database->createCollection($collectionId); + $this->createAttribute($database, $collectionId, 'meta', Database::VAR_OBJECT, 0, false); + + $created = $database->createDocument($collectionId, new Document([ + '$id' => 'nestedEmptyObjects', + '$permissions' => [Permission::read(Role::any())], + 'meta' => [ + 'inner' => new \stdClass(), + 'arr' => [new \stdClass(), ['x' => 1]], + 'emptyArray' => [], + ], + ])); + $createdMeta = $created->getAttribute('meta'); + $this->assertSame('{}', json_encode($createdMeta['inner'])); + $this->assertSame('{}', json_encode($createdMeta['arr'][0])); + $this->assertSame('{"x":1}', json_encode($createdMeta['arr'][1])); + $this->assertSame('[]', json_encode($createdMeta['emptyArray'])); + + $database->purgeCachedDocument($collectionId, 'nestedEmptyObjects'); + $readMeta = $database->getDocument($collectionId, 'nestedEmptyObjects')->getAttribute('meta'); + $this->assertSame('{}', json_encode($readMeta['inner'])); + $this->assertSame('{}', json_encode($readMeta['arr'][0])); + $this->assertSame('{"x":1}', json_encode($readMeta['arr'][1])); + $this->assertSame('[]', json_encode($readMeta['emptyArray'])); + + $cached = $database->getDocument($collectionId, 'nestedEmptyObjects'); + $cachedMeta = $cached->getAttribute('meta'); + $this->assertSame('{}', json_encode($cachedMeta['inner'])); + $this->assertSame('{}', json_encode($cachedMeta['arr'][0])); + $this->assertSame('{"x":1}', json_encode($cachedMeta['arr'][1])); + $this->assertSame('[]', json_encode($cachedMeta['emptyArray'])); + + $updatedMeta = $cachedMeta; + $updatedMeta['inner'] = new \stdClass(); + $updatedMeta['arr'][0] = new \stdClass(); + $updated = $database->updateDocument($collectionId, 'nestedEmptyObjects', new Document([ + 'meta' => $updatedMeta, + ])); + $this->assertSame($cached->getUpdatedAt(), $updated->getUpdatedAt()); + + $database->deleteCollection($collectionId); + } + public function testMetadataWithVector(): void { /** @var Database $database */ diff --git a/tests/unit/Validator/ObjectTest.php b/tests/unit/Validator/ObjectTest.php index 3cf50b026f..4563fe2eb8 100644 --- a/tests/unit/Validator/ObjectTest.php +++ b/tests/unit/Validator/ObjectTest.php @@ -64,6 +64,7 @@ public function testEmptyCases(): void $validator = new ObjectValidator(); $this->assertTrue($validator->isValid([])); + $this->assertTrue($validator->isValid(new \stdClass())); $this->assertFalse($validator->isValid('sldfjsdlfj')); }