From c96d5de857b69d4f641b708b3d986f5b436ab1c8 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 12 Aug 2026 16:28:18 +1200 Subject: [PATCH 1/4] (fix): preserve empty object attributes --- src/Database/Adapter/Mongo.php | 4 +- src/Database/Database.php | 33 +++++++- src/Database/Validator/ObjectValidator.php | 4 + .../Adapter/Scopes/ObjectAttributeTests.php | 75 +++++++++++++++++++ tests/unit/Validator/ObjectTest.php | 1 + 5 files changed, 112 insertions(+), 5 deletions(-) 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..a82425de80 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,37 @@ 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; + } + /** * Add listener to events * Passing a null $callback will remove the listener 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..095bf796bf 100644 --- a/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php +++ b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php @@ -963,6 +963,81 @@ 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'))); + + $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'])); + + $cachedMeta = $database->getDocument($collectionId, 'nestedEmptyObjects')->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'])); + + $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')); } From 74d9af59947d1f3c6998fb23715bf2d8f1cf4e0b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 12 Aug 2026 16:58:51 +1200 Subject: [PATCH 2/4] (test): verify empty object dependency chain --- .github/workflows/tests.yml | 3 +++ Dockerfile | 19 ++++++++++++- src/Database/Database.php | 25 ++++++++++++++++- .../Adapter/Scopes/ObjectAttributeTests.php | 27 ++++++++++++++++++- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 318304d9d3..bf11f07a95 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,6 +25,9 @@ jobs: uses: docker/build-push-action@1104d471370f9806843c095c1db02b5a90c5f8b6 # v3.3.1 with: context: . + build-args: | + UTOPIA_CACHE_REFERENCE=d7c0806f9bbdafee794849398f65e8ece322711d + UTOPIA_MONGO_REFERENCE=daea8f8213c33f52e8b66d0f5366428bf2f5a4b8 push: false tags: ${{ env.IMAGE }} load: true diff --git a/Dockerfile b/Dockerfile index d43c2a167d..2c0c65e3dc 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ FROM composer:2.8 AS composer +ARG UTOPIA_CACHE_REFERENCE='' +ARG UTOPIA_MONGO_REFERENCE='' + WORKDIR /usr/local/src/ COPY composer.lock /usr/local/src/ @@ -10,7 +13,21 @@ RUN composer install \ --optimize-autoloader \ --no-plugins \ --no-scripts \ - --prefer-dist + --prefer-dist \ + && if [ -n "$UTOPIA_CACHE_REFERENCE" ] || [ -n "$UTOPIA_MONGO_REFERENCE" ]; then \ + test -n "$UTOPIA_CACHE_REFERENCE" \ + && test -n "$UTOPIA_MONGO_REFERENCE" \ + && git init /tmp/utopia-cache \ + && git -C /tmp/utopia-cache remote add origin https://github.com/utopia-php/monorepo.git \ + && git -C /tmp/utopia-cache fetch --depth 1 origin "$UTOPIA_CACHE_REFERENCE" \ + && git -C /tmp/utopia-cache checkout --detach FETCH_HEAD \ + && cp -R /tmp/utopia-cache/packages/cache/src/. vendor/utopia-php/cache/src/ \ + && git init /tmp/utopia-mongo \ + && git -C /tmp/utopia-mongo remote add origin https://github.com/utopia-php/mongo.git \ + && git -C /tmp/utopia-mongo fetch --depth 1 origin "$UTOPIA_MONGO_REFERENCE" \ + && git -C /tmp/utopia-mongo checkout --detach FETCH_HEAD \ + && cp -R /tmp/utopia-mongo/src/. vendor/utopia-php/mongo/src/; \ + fi FROM php:8.5.8-cli-alpine AS compile diff --git a/src/Database/Database.php b/src/Database/Database.php index a82425de80..760c1aeafe 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -739,6 +739,29 @@ private static function toAssociative(mixed $value): mixed 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 @@ -6385,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/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php b/tests/e2e/Adapter/Scopes/ObjectAttributeTests.php index 095bf796bf..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; @@ -991,6 +992,21 @@ public function testObjectAttributeEmptyObject(): void $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); } @@ -1029,12 +1045,21 @@ public function testObjectAttributeNestedEmptyObjects(): void $this->assertSame('{"x":1}', json_encode($readMeta['arr'][1])); $this->assertSame('[]', json_encode($readMeta['emptyArray'])); - $cachedMeta = $database->getDocument($collectionId, 'nestedEmptyObjects')->getAttribute('meta'); + $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); } From d875e118ea968911efd9c585537f08b0fc15b6e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:16:03 +0000 Subject: [PATCH 3/4] chore: bump utopia-php/mongo lock to 1.5.2 Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com> --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 4a4d4c57ab..e85204ab30 100644 --- a/composer.lock +++ b/composer.lock @@ -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", From fccb9abb1f7c3d60e7a2007ab09c70a614f524ea Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 12 Aug 2026 21:25:37 +1200 Subject: [PATCH 4/4] chore: depend on released cache and mongo instead of patching vendor The Dockerfile cloned utopia-php/monorepo and utopia-php/mongo at fixed commits and copied their src/ over vendor/, driven by build args pinned in the workflow: UTOPIA_CACHE_REFERENCE=d7c0806f... UTOPIA_MONGO_REFERENCE=daea8f82... That was a vendor patch standing in for releases that did not exist yet. Both now do, so the lock resolves them normally and the machinery goes. utopia-php/cache 4.0.1 -> 4.0.2 source 92e02dab6 utopia-php/mongo 1.5.2 source 20f9a644a Both lock references equal the Packagist source references exactly, and each release contains the pinned commit: d7c0806f is an ancestor of the cache/4.0.2 tag commit, and 1.5.2 contains daea8f82. Constraints already admitted these (cache ^4.0.0, mongo 1.*), so only the lock moved. Removing the build args also stops a Docker build fetching arbitrary external git refs. Co-Authored-By: Claude Opus 5 --- .github/workflows/tests.yml | 3 --- Dockerfile | 19 +------------------ composer.lock | 14 +++++++------- 3 files changed, 8 insertions(+), 28 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bf11f07a95..318304d9d3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,9 +25,6 @@ jobs: uses: docker/build-push-action@1104d471370f9806843c095c1db02b5a90c5f8b6 # v3.3.1 with: context: . - build-args: | - UTOPIA_CACHE_REFERENCE=d7c0806f9bbdafee794849398f65e8ece322711d - UTOPIA_MONGO_REFERENCE=daea8f8213c33f52e8b66d0f5366428bf2f5a4b8 push: false tags: ${{ env.IMAGE }} load: true diff --git a/Dockerfile b/Dockerfile index 2c0c65e3dc..d43c2a167d 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,5 @@ FROM composer:2.8 AS composer -ARG UTOPIA_CACHE_REFERENCE='' -ARG UTOPIA_MONGO_REFERENCE='' - WORKDIR /usr/local/src/ COPY composer.lock /usr/local/src/ @@ -13,21 +10,7 @@ RUN composer install \ --optimize-autoloader \ --no-plugins \ --no-scripts \ - --prefer-dist \ - && if [ -n "$UTOPIA_CACHE_REFERENCE" ] || [ -n "$UTOPIA_MONGO_REFERENCE" ]; then \ - test -n "$UTOPIA_CACHE_REFERENCE" \ - && test -n "$UTOPIA_MONGO_REFERENCE" \ - && git init /tmp/utopia-cache \ - && git -C /tmp/utopia-cache remote add origin https://github.com/utopia-php/monorepo.git \ - && git -C /tmp/utopia-cache fetch --depth 1 origin "$UTOPIA_CACHE_REFERENCE" \ - && git -C /tmp/utopia-cache checkout --detach FETCH_HEAD \ - && cp -R /tmp/utopia-cache/packages/cache/src/. vendor/utopia-php/cache/src/ \ - && git init /tmp/utopia-mongo \ - && git -C /tmp/utopia-mongo remote add origin https://github.com/utopia-php/mongo.git \ - && git -C /tmp/utopia-mongo fetch --depth 1 origin "$UTOPIA_MONGO_REFERENCE" \ - && git -C /tmp/utopia-mongo checkout --detach FETCH_HEAD \ - && cp -R /tmp/utopia-mongo/src/. vendor/utopia-php/mongo/src/; \ - fi + --prefer-dist FROM php:8.5.8-cli-alpine AS compile diff --git a/composer.lock b/composer.lock index e85204ab30..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", @@ -4648,5 +4648,5 @@ "ext-redis": "*" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" }