Skip to content

fix(client): keep an empty nested object out of the array flattener - #48

Merged
abnegate merged 1 commit into
mainfrom
fix/empty-object-fidelity
Aug 12, 2026
Merged

fix(client): keep an empty nested object out of the array flattener#48
abnegate merged 1 commit into
mainfrom
fix/empty-object-fidelity

Conversation

@abnegate

@abnegate abnegate commented Aug 12, 2026

Copy link
Copy Markdown
Member

What

An empty JSON object stored in a document attribute was served back as an empty array.

Client::toArray() recursively cast every nested stdClass to an array, and (array) new \stdClass() is []. So an attribute holding {} came back as [], an attribute holding {"inner": {}} came back as {"inner": []}, and {"arr": [{}, {"x": 1}]} came back as {"arr": [[], {"x": 1}]}.

This is not only a read-path defect. insert() (line 922) and insertMany() (line 1000) both return $this->toArray($docObj), and insertMany() is what utopia-php/database's Adapter/Mongo.php createDocuments returns to the caller, so the POST response body was already wrong even when storage was correct. That is exactly what the production probe measured: the create response itself carried [], every write returned 201, and nothing warned.

Measured on production 2026-08-12 against a dedicated Mongo-backed DocumentsDB and a Postgres-backed VectorsDB. Linear: https://linear.app/appwrite/issue/DAT-2310

The change

src/Client.php, toArray() (lines 1694-1729): a nested object with no properties is left as the stdClass it already is, so it re-encodes as {}. Every non-empty object still becomes an associative array, so callers that iterate or key into the result are unaffected. Only the empty case changes shape.

Verified the BSON type before writing the condition rather than assuming it. This client decodes responses with Document::fromBSON($bson)->toPHP() and configures no typeMap, so on the required extension version an empty sub-document is a plain stdClass with zero properties, not BSONDocument and not an ArrayObject subclass:

ext: 2.1.1 php: 8.3.7
root: stdClass
  empty => stdClass props=0 exactStdClass=true
  inner => stdClass props=1 exactStdClass=true
  arr => array count=2
    [0] => stdClass props=0
    [1] => stdClass props=1
  filled => stdClass props=1 exactStdClass=true
  emptyArr => array count=0

A BSON array still decodes to a PHP array, so an empty array is not affected and must keep encoding as []. The test pins that too.

Same shape of solution as the toAssociative() helper in utopia-php/monorepo#123, which fixes the framework-level root cause: convert non-empty objects to arrays, keep empty ones.

Regression test, seen red

tests/MongoTest.php::testEmptyObjectSurvivesToArray. It drives the public API only, against a real MongoDB 8 over the real wire protocol. No reflection, no doubles, so nothing in it can answer its own assertion.

It covers insert(), insertMany(), find() + toArray() and lastDocument(), and asserts both directions of the contract: empty objects encode as {}, non-empty objects are still associative arrays, and empty arrays are still [].

The test was written first and run against unmodified origin/main on PHP 8.3.7 with ext-mongodb 2.1.1 (the version composer.json requires), MongoDB 8.0.

Red, unmodified src/:

PHPUnit 9.6.29 by Sebastian Bergmann and contributors.

F                                                                   1 / 1 (100%)

Time: 00:00.064, Memory: 4.00 MB

There was 1 failure:

1) Utopia\Tests\MongoTest::testEmptyObjectSurvivesToArray
insert() response flattened an empty object
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'{}'
+'[]'

/usr/src/code/tests/MongoTest.php:409

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

phpunit.xml sets stopOnFailure="true", so only the first assertion is reported. To show the full breadth of the defect, here is the same set of shapes driven through the public API against unmodified src/, reproducing the production measurements exactly:

sent                       insert()         insertMany()     find() + toArray()
empty: {}                  []               []               []
inner: {"nested":{}}       {"nested":[]}    {"nested":[]}    {"nested":[]}
list: [{},{"x":1}]         [[],{"x":1}]     [[],{"x":1}]     [[],{"x":1}]
filled: {"a":1}            {"a":1}          {"a":1}          {"a":1}
emptyList: []              []               []               []

Green, with the fix:

PHPUnit 9.6.29 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 00:00.051, Memory: 4.00 MB

OK (1 test, 18 assertions)

And the same shapes:

sent                       insert()         insertMany()     find() + toArray()
empty: {}                  {}               {}               {}
inner: {"nested":{}}       {"nested":{}}    {"nested":{}}    {"nested":{}}
list: [{},{"x":1}]         [{},{"x":1}]     [{},{"x":1}]     [{},{"x":1}]
filled: {"a":1}            {"a":1}          {"a":1}          {"a":1}
emptyList: []              []               []               []

Full suite, lint, static analysis

Full suite against a real MongoDB 8, PHP 8.3.7, ext-mongodb 2.1.1, in the repo's own php83.Dockerfile image:

OK (61 tests, 295 assertions)

composer lint (Pint, PSR-12) and composer check (PHPStan level 4 over src) both clean, run inside the same PHP 8.3 image:

    PASS   ........................................................... 9 files
 [OK] No errors

Scope and seams

There are several flatteners stacked on top of each other. This PR fixes only the one site in this repo. utopia-php/database is fixed in its own PR, and the HTTP framework root cause is utopia-php/monorepo#123, where nested empty JSON objects now decode to \stdClass while non-empty objects stay associative arrays, leaving the array-shaped contract unchanged.

Checked the immediate consumer for an assumption this change could break: Adapter/Mongo.php::replaceInternalIdsKeys() recurses on is_array($value) and otherwise passes the value through, so a property-less stdClass survives it untouched. There are no keys in an empty object to rename.

Release order: utopia-php/database and utopia-php/mongo first, then utopia-php/http, then the cloud/CE bump. All three flatteners have to be out before the end-to-end shape is correct.

No E2E test in this repo covers the cloud-to-edge-to-engine path this defect was measured on, so the end-to-end proof has to come from the cloud/CE side once all three releases land.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Empty nested objects are now preserved correctly as {} when data is converted and re-encoded.
    • Arrays, non-empty objects, and scalar values continue to convert consistently.
    • Document retrieval now maintains the expected structure for empty objects and arrays.
  • Tests

    • Added coverage for inserting, retrieving, and converting documents containing empty and non-empty objects.

toArray() cast every nested stdClass to an array, and (array) new stdClass()
is [], so a document attribute holding {} came back as []. That hit both the
insert()/insertMany() return value, which becomes the POST response body, and
every read that goes through this client. Measured on production against a
dedicated Mongo DocumentsDB: sent {} read back [], sent {"inner":{}} read back
{"inner":[]}, sent {"arr":[{},{"x":1}]} read back {"arr":[[],{"x":1}]}. Every
write returned 201 and nothing warned.

An empty BSON sub-document deserialises to a property-less stdClass under the
default typeMap this client uses, so leaving that instance in place is all it
takes for the value to re-encode as {}. Non-empty objects still become
associative arrays, so callers that iterate or key into the result see no
change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 73775b6b-5ebf-4ae0-8212-9d793e859b11

📥 Commits

Reviewing files that changed from the base of the PR and between 3ece830 and daea8f8.

📒 Files selected for processing (2)
  • src/Client.php
  • tests/MongoTest.php

📝 Walkthrough

Walkthrough

Client::toArray() now preserves empty nested stdClass objects as objects during recursive conversion. A MongoDB integration test covers single-document, multi-document, retrieval, and top-level conversion behavior.

Changes

Empty object conversion

Layer / File(s) Summary
Conversion logic
src/Client.php
toArray() handles scalar inputs, preserves empty stdClass values, and recursively converts arrays and non-empty objects.
Integration validation
tests/MongoTest.php
The integration test verifies empty objects, empty arrays, non-empty objects, insertion paths, retrieval paths, and top-level conversion.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • utopia-php/mongo#32: Both changes modify Client::toArray() and nested stdClass conversion behavior.

Suggested reviewers: chiragagg5k

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main fix: preventing empty nested objects from being flattened into arrays.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/empty-object-fidelity

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR preserves nested empty stdClass values during result conversion so they serialize as JSON objects rather than empty arrays.

  • Updates Client::toArray() while retaining existing behavior for non-empty objects, arrays, scalars, and top-level empty objects.
  • Adds integration coverage for insert(), insertMany(), read conversion, and lastDocument().

Confidence Score: 5/5

The PR appears safe to merge with no actionable defects identified.

The conversion now preserves the BSON/JSON distinction between empty objects and empty arrays, while the regression test covers the affected write and read paths and confirms surrounding conversion behavior remains intact.

Important Files Changed

Filename Overview
src/Client.php Preserves nested empty stdClass instances without changing the method’s top-level array return contract; no actionable defect identified.
tests/MongoTest.php Adds focused regression coverage for empty objects, nested objects, lists, non-empty objects, and empty arrays across the affected public paths.

Reviews (1): Last reviewed commit: "fix(client): keep an empty nested object..." | Re-trigger Greptile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant