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..34754e2 100644 --- a/tests/MongoTest.php +++ b/tests/MongoTest.php @@ -320,6 +320,60 @@ public function testToArrayWithNestedDocumentFromMongo() $client->dropCollection('movies_nested'); } + 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 + // 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();