From 108ff34be0c945029df157286adea2d0b596f58f Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 13 Aug 2026 12:43:00 +1200 Subject: [PATCH 1/2] fix(client): decode BSON int64 to native PHP integers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MongoDB\BSON\Document::toPHP() returns a MongoDB\BSON\Int64 instance for every 64-bit BSON integer, on all platforms, not just 32-bit ones. Any stored value outside the int32 range therefore came back to callers as an object where a PHP int was expected. The wrapper is not inert. Consumers that array-access a decoded value fatal with "Cannot use object of type MongoDB\BSON\Int64 as array", and consumers that apply an (int) cast get 1 plus a warning — silent corruption of the value on read. utopia-php/database hits both: a single out-of-int32-range element inside an array column makes every subsequent read of the table fail with a 500 (appwrite/appwrite#13175). Unwrap at the decode boundary so the whole client returns plain PHP values, rather than asking each consumer to recognise the wrapper. On 64-bit PHP the unwrap is lossless; on 32-bit builds the wrapper is the only lossless representation, so it is left alone there. $clusterTime is excluded: it is echoed back to the server verbatim on later commands, and its signature.keyId is an int64 that must not be re-encoded as an int32. --- src/Client.php | 48 +++++++++++++++++++++++++++++++++++++++++++++ tests/MongoTest.php | 47 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/Client.php b/src/Client.php index 4ffc265..9dcaacc 100644 --- a/src/Client.php +++ b/src/Client.php @@ -1726,6 +1726,49 @@ public function toArray(mixed $obj): ?array return $ret; } + /** + * Replace BSON Int64 wrappers with native PHP integers. + * + * MongoDB\BSON\Document::toPHP() decodes every 64-bit BSON integer as an + * Int64 object on all platforms, so any value outside the int32 range comes + * back wrapped. Callers expect plain PHP values, and an Int64 that survives + * into user code either fatals when array-accessed or degrades to 1 under an + * (int) cast. On 64-bit PHP the unwrap is lossless; on 32-bit builds the + * wrapper is the only representation that preserves precision, so it stays. + * + * @param mixed $value + * @param array $skip Top-level keys to leave untouched + * @return mixed + */ + private static function normalizeInt64(mixed $value, array $skip = []): mixed + { + if ($value instanceof Int64) { + return \PHP_INT_SIZE >= 8 ? (int)(string)$value : $value; + } + + if (\is_array($value)) { + foreach ($value as $key => $item) { + $value[$key] = self::normalizeInt64($item); + } + + return $value; + } + + if ($value instanceof stdClass) { + foreach (\get_object_vars($value) as $key => $item) { + if (isset($skip[$key])) { + continue; + } + + $value->{$key} = self::normalizeInt64($item); + } + + return $value; + } + + return $value; + } + private function cleanFilters($filters): array { $cleanedFilters = []; @@ -1910,6 +1953,11 @@ private function parseResponse(string $response, int $responseLength): stdClass| if (\is_array($result)) { $result = (object)$result; } + + // $clusterTime is echoed back to the server verbatim on subsequent + // commands, so its BSON types must survive intact — signature.keyId + // is an int64 the server rejects if it comes back as an int32. + $result = self::normalizeInt64($result, ['$clusterTime' => true]); } catch (\Throwable $error) { $this->invalidate(); throw new Exception('Failed to parse BSON response: ' . $error->getMessage(), 0, $error); diff --git a/tests/MongoTest.php b/tests/MongoTest.php index f69f481..1f8c2a0 100644 --- a/tests/MongoTest.php +++ b/tests/MongoTest.php @@ -320,6 +320,53 @@ public function testToArrayWithNestedDocumentFromMongo() $client->dropCollection('movies_nested'); } + public function testInt64ValuesDecodeToNativeIntegers() + { + $client = $this->getDatabase(); + + // Beyond the int32 range, so MongoDB stores these as BSON int64 and + // Document::toPHP() hands them back as MongoDB\BSON\Int64 wrappers. + $negative = -3408048000; + $positive = 3408048000; + $extreme = \PHP_INT_MAX; + + try { + $client->insert('movies_int64', [ + '_id' => 'int64-test-1', + 'small' => -42, + 'big' => $negative, + 'list' => [$negative, -42, $positive, $extreme], + 'nested' => ['deep' => ['value' => $negative]], + ]); + + $result = $client->find('movies_int64', ['_id' => 'int64-test-1'])->cursor->firstBatch[0] ?? null; + self::assertNotNull($result); + + self::assertIsInt($result->small); + self::assertSame(-42, $result->small); + + self::assertIsInt($result->big); + self::assertSame($negative, $result->big); + + self::assertIsInt($result->list[0]); + self::assertSame($negative, $result->list[0]); + self::assertSame(-42, $result->list[1]); + self::assertSame($positive, $result->list[2]); + self::assertSame($extreme, $result->list[3]); + + self::assertIsInt($result->nested->deep->value); + self::assertSame($negative, $result->nested->deep->value); + + // toArray() must carry the native integers through untouched. + $array = $client->toArray($result); + self::assertSame($negative, $array['big']); + self::assertSame([$negative, -42, $positive, $extreme], $array['list']); + self::assertSame($negative, $array['nested']['deep']['value']); + } finally { + $client->dropCollection('movies_int64'); + } + } + public function testToArrayNestedConversion() { $client = $this->getDatabase(); From 4875ed64f06a5c051b3285a721cf511f2045df0d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 13 Aug 2026 12:49:15 +1200 Subject: [PATCH 2/2] test: skip the int64 round-trip on 32-bit builds The test asserts the 64-bit contract: native integers out of the client. On a 32-bit build normalizeInt64() keeps the wrapper on purpose, because there it is the only lossless representation, and the literals in the fixture would already have been coerced to float before reaching the driver. Declare that requirement instead of letting the assertions contradict the implementation. --- tests/MongoTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/MongoTest.php b/tests/MongoTest.php index 1f8c2a0..34754e2 100644 --- a/tests/MongoTest.php +++ b/tests/MongoTest.php @@ -322,6 +322,13 @@ public function testToArrayWithNestedDocumentFromMongo() public function testInt64ValuesDecodeToNativeIntegers() { + if (\PHP_INT_SIZE < 8) { + // normalizeInt64() deliberately keeps the wrapper on 32-bit builds, + // where it is the only lossless representation, and the literals + // below would already be floats before reaching the driver. + self::markTestSkipped('Native int64 round-trip requires a 64-bit PHP build.'); + } + $client = $this->getDatabase(); // Beyond the int32 range, so MongoDB stores these as BSON int64 and