From daea8f8213c33f52e8b66d0f5366428bf2f5a4b8 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 12 Aug 2026 14:37:27 +1200 Subject: [PATCH] fix(client): keep an empty nested object out of the array flattener 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 --- src/Client.php | 25 +++++++++++++++------- tests/MongoTest.php | 51 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/Client.php b/src/Client.php index 8c4c153..4ffc265 100644 --- a/src/Client.php +++ b/src/Client.php @@ -1694,6 +1694,8 @@ public function toObject(array $dict): stdClass /** * Convert an object (stdClass) to an assoc array. * + * A nested object with no properties stays a stdClass, so it re-encodes as {} and not as []. + * * @param mixed $obj * @return array|null */ @@ -1703,18 +1705,25 @@ public function toArray(mixed $obj): ?array return null; } - if (is_object($obj) || is_array($obj)) { - $ret = (array)$obj; - foreach ($ret as $key => $item) { - if ($item instanceof stdClass || is_array($item)) { - $ret[$key] = $this->toArray($item); - } + if (!is_object($obj) && !is_array($obj)) { + return [$obj]; + } + + $ret = (array)$obj; + + foreach ($ret as $key => $item) { + if ($item instanceof stdClass) { + $ret[$key] = (array)$item === [] ? $item : $this->toArray($item); + + continue; } - return $ret; + if (is_array($item)) { + $ret[$key] = $this->toArray($item); + } } - return [$obj]; + return $ret; } private function cleanFilters($filters): array diff --git a/tests/MongoTest.php b/tests/MongoTest.php index afc073c..f69f481 100644 --- a/tests/MongoTest.php +++ b/tests/MongoTest.php @@ -393,6 +393,57 @@ public function testToArrayNestedConversion() self::assertEquals([42], $client->toArray(42)); } + public function testEmptyObjectSurvivesToArray() + { + $client = $this->getDatabase(); + + $created = $client->insert('movies_empty_object', [ + '_id' => 'empty-object-1', + 'empty' => new \stdClass(), + 'inner' => (object)['nested' => new \stdClass()], + 'list' => [new \stdClass(), (object)['x' => 1]], + 'filled' => (object)['a' => 1], + 'emptyList' => [], + ]); + + self::assertSame('{}', json_encode($created['empty']), 'insert() response flattened an empty object'); + self::assertSame('{"nested":{}}', json_encode($created['inner']), 'insert() response flattened a nested empty object'); + self::assertSame('[{},{"x":1}]', json_encode($created['list']), 'insert() response flattened an empty object inside a list'); + self::assertSame('{"a":1}', json_encode($created['filled'])); + self::assertSame('[]', json_encode($created['emptyList']), 'insert() response turned an empty array into an object'); + + self::assertIsArray($created['inner'], 'non-empty objects must still be associative arrays'); + self::assertSame(['a' => 1], $created['filled'], 'non-empty objects must still be associative arrays'); + + $batch = $client->insertMany('movies_empty_object', [[ + '_id' => 'empty-object-2', + 'empty' => new \stdClass(), + 'inner' => (object)['nested' => new \stdClass()], + 'list' => [new \stdClass(), (object)['x' => 1]], + ]]); + + self::assertSame('{}', json_encode($batch[0]['empty']), 'insertMany() response flattened an empty object'); + self::assertSame('{"nested":{}}', json_encode($batch[0]['inner']), 'insertMany() response flattened a nested empty object'); + self::assertSame('[{},{"x":1}]', json_encode($batch[0]['list']), 'insertMany() response flattened an empty object inside a list'); + + $read = $client->toArray($client->find('movies_empty_object', ['_id' => 'empty-object-1'])->cursor->firstBatch[0]); + + self::assertSame('{}', json_encode($read['empty']), 'read path flattened an empty object'); + self::assertSame('{"nested":{}}', json_encode($read['inner']), 'read path flattened a nested empty object'); + self::assertSame('[{},{"x":1}]', json_encode($read['list']), 'read path flattened an empty object inside a list'); + self::assertSame('{"a":1}', json_encode($read['filled'])); + self::assertSame('[]', json_encode($read['emptyList']), 'read path turned an empty array into an object'); + + $last = $client->lastDocument('movies_empty_object'); + + self::assertSame('{}', json_encode($last['empty']), 'lastDocument() flattened an empty object'); + self::assertSame('{"nested":{}}', json_encode($last['inner']), 'lastDocument() flattened a nested empty object'); + + self::assertSame([], $client->toArray(new \stdClass()), 'toArray() returns ?array, so only nested values change shape'); + + $client->dropCollection('movies_empty_object'); + } + public function testCountMethod() { $collectionName = 'count_test';