diff --git a/CHANGELOG.md b/CHANGELOG.md index bd353702e..c0e383338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog All notable changes to this project will be documented in this file. +## [5.1.0] - next + +* Convert `_id` and `UTCDateTime` in results of `Model::raw()` before hydratation by @GromNaN in [#3152](https://github.com/mongodb/laravel-mongodb/pull/3152) + ## [5.0.2] - 2024-09-17 * Fix missing return types in CommandSubscriber by @GromNaN in [#3158](https://github.com/mongodb/laravel-mongodb/pull/3158) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index da96b64f1..4fd4880df 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -5,7 +5,8 @@ namespace MongoDB\Laravel\Eloquent; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; -use MongoDB\Driver\Cursor; +use MongoDB\BSON\Document; +use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Exception\WriteException; use MongoDB\Laravel\Connection; use MongoDB\Laravel\Helpers\QueriesRelationships; @@ -16,7 +17,9 @@ use function array_merge; use function collect; use function is_array; +use function is_object; use function iterator_to_array; +use function property_exists; /** @method \MongoDB\Laravel\Query\Builder toBase() */ class Builder extends EloquentBuilder @@ -177,22 +180,27 @@ public function raw($value = null) $results = $this->query->raw($value); // Convert MongoCursor results to a collection of models. - if ($results instanceof Cursor) { - $results = iterator_to_array($results, false); + if ($results instanceof CursorInterface) { + $results->setTypeMap(['root' => 'array', 'document' => 'array', 'array' => 'array']); + $results = $this->query->aliasIdForResult(iterator_to_array($results)); return $this->model->hydrate($results); } - // Convert MongoDB BSONDocument to a single object. - if ($results instanceof BSONDocument) { - $results = $results->getArrayCopy(); - - return $this->model->newFromBuilder((array) $results); + // Convert MongoDB Document to a single object. + if (is_object($results) && (property_exists($results, '_id') || property_exists($results, 'id'))) { + $results = (array) match (true) { + $results instanceof BSONDocument => $results->getArrayCopy(), + $results instanceof Document => $results->toPHP(['root' => 'array', 'document' => 'array', 'array' => 'array']), + default => $results, + }; } // The result is a single object. - if (is_array($results) && array_key_exists('_id', $results)) { - return $this->model->newFromBuilder((array) $results); + if (is_array($results) && (array_key_exists('_id', $results) || array_key_exists('id', $results))) { + $results = $this->query->aliasIdForResult($results); + + return $this->model->newFromBuilder($results); } return $results; diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 43acbcc24..372dcf633 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -1648,13 +1648,15 @@ private function aliasIdForQuery(array $values): array } /** + * @internal + * * @psalm-param T $values * * @psalm-return T * * @template T of array|object */ - private function aliasIdForResult(array|object $values): array|object + public function aliasIdForResult(array|object $values): array|object { if (is_array($values)) { if (array_key_exists('_id', $values) && ! array_key_exists('id', $values)) { diff --git a/tests/ModelTest.php b/tests/ModelTest.php index 075c0d3ad..c532eea55 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -28,6 +28,8 @@ use MongoDB\Laravel\Tests\Models\Soft; use MongoDB\Laravel\Tests\Models\SqlUser; use MongoDB\Laravel\Tests\Models\User; +use MongoDB\Model\BSONArray; +use MongoDB\Model\BSONDocument; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; @@ -907,14 +909,8 @@ public function testRaw(): void $this->assertInstanceOf(EloquentCollection::class, $users); $this->assertInstanceOf(User::class, $users[0]); - $user = User::raw(function (Collection $collection) { - return $collection->findOne(['age' => 35]); - }); - - $this->assertTrue(Model::isDocumentModel($user)); - $count = User::raw(function (Collection $collection) { - return $collection->count(); + return $collection->estimatedDocumentCount(); }); $this->assertEquals(3, $count); @@ -924,6 +920,59 @@ public function testRaw(): void $this->assertNotNull($result); } + #[DataProvider('provideTypeMap')] + public function testRawHyradeModel(array $typeMap): void + { + User::insert([ + ['name' => 'John Doe', 'age' => 35, 'embed' => ['foo' => 'bar'], 'list' => [1, 2, 3]], + ['name' => 'Jane Doe', 'age' => 35, 'embed' => ['foo' => 'bar'], 'list' => [1, 2, 3]], + ['name' => 'Harry Hoe', 'age' => 15, 'embed' => ['foo' => 'bar'], 'list' => [1, 2, 3]], + ]); + + // Single document result + $user = User::raw(fn (Collection $collection) => $collection->findOne( + ['age' => 35], + [ + 'projection' => ['_id' => 1, 'name' => 1, 'age' => 1, 'now' => '$$NOW', 'embed' => 1, 'list' => 1], + 'typeMap' => $typeMap, + ], + )); + + $this->assertInstanceOf(User::class, $user); + $this->assertArrayNotHasKey('_id', $user->getAttributes()); + $this->assertArrayHasKey('id', $user->getAttributes()); + $this->assertNotEmpty($user->id); + $this->assertInstanceOf(Carbon::class, $user->now); + $this->assertEquals(['foo' => 'bar'], (array) $user->embed); + $this->assertEquals([1, 2, 3], (array) $user->list); + + // Cursor result + $result = User::raw(fn (Collection $collection) => $collection->aggregate([ + ['$set' => ['now' => '$$NOW']], + ['$limit' => 2], + ], ['typeMap' => $typeMap])); + + $this->assertInstanceOf(EloquentCollection::class, $result); + $this->assertCount(2, $result); + $user = $result->first(); + $this->assertInstanceOf(User::class, $user); + $this->assertArrayNotHasKey('_id', $user->getAttributes()); + $this->assertArrayHasKey('id', $user->getAttributes()); + $this->assertNotEmpty($user->id); + $this->assertInstanceOf(Carbon::class, $user->now); + $this->assertEquals(['foo' => 'bar'], $user->embed); + $this->assertEquals([1, 2, 3], $user->list); + } + + public static function provideTypeMap(): Generator + { + yield 'default' => [[]]; + yield 'array' => [['root' => 'array', 'document' => 'array', 'array' => 'array']]; + yield 'object' => [['root' => 'object', 'document' => 'object', 'array' => 'array']]; + yield 'Library BSON' => [['root' => BSONDocument::class, 'document' => BSONDocument::class, 'array' => BSONArray::class]]; + yield 'Driver BSON' => [['root' => 'bson', 'document' => 'bson', 'array' => 'bson']]; + } + public function testDotNotation(): void { $user = User::create([ diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 49da6fada..c1587dc73 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -1381,6 +1381,31 @@ function (Builder $elemMatchQuery): void { ->orWhereAny(['last_name', 'email'], 'not like', '%Doe%'), 'orWhereAny', ]; + + yield 'raw filter with _id and date' => [ + [ + 'find' => [ + [ + '$and' => [ + [ + '$or' => [ + ['foo._id' => 1], + ['created_at' => ['$gte' => new UTCDateTime(new DateTimeImmutable('2018-09-30 00:00:00.000 +00:00'))]], + ], + ], + ['age' => 15], + ], + ], + [], // options + ], + ], + fn (Builder $builder) => $builder->where([ + '$or' => [ + ['foo.id' => 1], + ['created_at' => ['$gte' => new DateTimeImmutable('2018-09-30 00:00:00 +00:00')]], + ], + ])->where('age', 15), + ]; } #[DataProvider('provideExceptions')]