From d8c1bb646c46f61129682b489d19e5f63a87f5b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 13 Sep 2024 11:52:10 +0200 Subject: [PATCH 1/5] PHPORM-239 Apply result transformation to raw() queries --- src/Eloquent/Builder.php | 4 ++-- src/Query/Builder.php | 13 ++++++++++++- tests/QueryBuilderTest.php | 13 ++++++++----- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index da96b64f1..554894921 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -5,7 +5,7 @@ namespace MongoDB\Laravel\Eloquent; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; -use MongoDB\Driver\Cursor; +use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Exception\WriteException; use MongoDB\Laravel\Connection; use MongoDB\Laravel\Helpers\QueriesRelationships; @@ -177,7 +177,7 @@ public function raw($value = null) $results = $this->query->raw($value); // Convert MongoCursor results to a collection of models. - if ($results instanceof Cursor) { + if ($results instanceof CursorInterface) { $results = iterator_to_array($results, false); return $this->model->hydrate($results); diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 43acbcc24..4b7437a14 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -25,6 +25,7 @@ use MongoDB\BSON\UTCDateTime; use MongoDB\Builder\Stage\FluentFactoryTrait; use MongoDB\Driver\Cursor; +use MongoDB\Driver\CursorInterface; use Override; use RuntimeException; use stdClass; @@ -934,7 +935,17 @@ public function raw($value = null) { // Execute the closure on the mongodb collection if ($value instanceof Closure) { - return call_user_func($value, $this->collection); + $results = call_user_func($value, $this->collection); + + if ($results instanceof CursorInterface) { + $results = $results->toArray(); + } + + if (is_array($results) || is_object($results)) { + $results = $this->aliasIdForResult($results); + } + + return $results; } // Create an expression for the given value diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 523ad3411..444bdaeae 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -18,7 +18,6 @@ use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; use MongoDB\Collection; -use MongoDB\Driver\Cursor; use MongoDB\Driver\Monitoring\CommandFailedEvent; use MongoDB\Driver\Monitoring\CommandStartedEvent; use MongoDB\Driver\Monitoring\CommandSubscriber; @@ -314,12 +313,16 @@ public function testRaw() ['name' => 'John Doe', 'age' => 25], ]); - $cursor = DB::table('users')->raw(function ($collection) { - return $collection->find(['age' => 20]); + $results = DB::table('users')->raw(function ($collection) { + return $collection->find(['age' => 20], ['typeMap' => ['root' => 'array', 'document' => 'array']]); }); - $this->assertInstanceOf(Cursor::class, $cursor); - $this->assertCount(1, $cursor->toArray()); + $this->assertIsArray($results); + $this->assertCount(1, $results); + $this->assertArrayNotHasKey('_id', $results[0]); + $this->assertArrayHasKey('id', $results[0]); + $this->assertInstanceOf(ObjectId::class, $results[0]['id']); + $this->assertSame(20, $results[0]['age']); $collection = DB::table('users')->raw(); $this->assertInstanceOf(Collection::class, $collection); From 98df7c4f2d9430ab5dbfe7289b0274e4855b3d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 17 Sep 2024 12:28:44 +0200 Subject: [PATCH 2/5] PHPORM-239 Convert raw results to eloquent models, only when _id or id is returned --- src/Eloquent/Builder.php | 24 ++++++++++++++++-------- src/Query/Builder.php | 18 +++++------------- tests/ModelTest.php | 27 +++++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 554894921..4fd4880df 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -5,6 +5,7 @@ namespace MongoDB\Laravel\Eloquent; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; +use MongoDB\BSON\Document; use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Exception\WriteException; use MongoDB\Laravel\Connection; @@ -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 @@ -178,21 +181,26 @@ public function raw($value = null) // Convert MongoCursor results to a collection of models. if ($results instanceof CursorInterface) { - $results = iterator_to_array($results, false); + $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 4b7437a14..cb9fe471a 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -20,12 +20,12 @@ use InvalidArgumentException; use LogicException; use MongoDB\BSON\Binary; +use MongoDB\BSON\Document; use MongoDB\BSON\ObjectID; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; use MongoDB\Builder\Stage\FluentFactoryTrait; use MongoDB\Driver\Cursor; -use MongoDB\Driver\CursorInterface; use Override; use RuntimeException; use stdClass; @@ -935,17 +935,7 @@ public function raw($value = null) { // Execute the closure on the mongodb collection if ($value instanceof Closure) { - $results = call_user_func($value, $this->collection); - - if ($results instanceof CursorInterface) { - $results = $results->toArray(); - } - - if (is_array($results) || is_object($results)) { - $results = $this->aliasIdForResult($results); - } - - return $results; + return call_user_func($value, $this->collection); } // Create an expression for the given value @@ -1659,13 +1649,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..2a76b3c72 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -908,10 +908,17 @@ public function testRaw(): void $this->assertInstanceOf(User::class, $users[0]); $user = User::raw(function (Collection $collection) { - return $collection->findOne(['age' => 35]); + return $collection->findOne( + ['age' => 35], + ['projection' => ['_id' => 1, 'name' => 1, 'age' => 1, 'now' => '$$NOW']], + ); }); - $this->assertTrue(Model::isDocumentModel($user)); + $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); $count = User::raw(function (Collection $collection) { return $collection->count(); @@ -922,6 +929,22 @@ public function testRaw(): void return $collection->insertOne(['name' => 'Yvonne Yoe', 'age' => 35]); }); $this->assertNotNull($result); + + $result = User::raw(function (Collection $collection) { + return $collection->aggregate([ + ['$set' => ['now' => '$$NOW']], + ['$limit' => 2], + ]); + }); + + $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); } public function testDotNotation(): void From e7430235b2d3bba229548d376c0f303b72bb85f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 17 Sep 2024 12:41:58 +0200 Subject: [PATCH 3/5] Test hydratation with every typeMap --- src/Query/Builder.php | 1 - tests/ModelTest.php | 66 ++++++++++++++++++++++++++++++------------- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index cb9fe471a..372dcf633 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -20,7 +20,6 @@ use InvalidArgumentException; use LogicException; use MongoDB\BSON\Binary; -use MongoDB\BSON\Document; use MongoDB\BSON\ObjectID; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; diff --git a/tests/ModelTest.php b/tests/ModelTest.php index 2a76b3c72..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,21 +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], - ['projection' => ['_id' => 1, 'name' => 1, 'age' => 1, 'now' => '$$NOW']], - ); - }); - - $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); - $count = User::raw(function (Collection $collection) { - return $collection->count(); + return $collection->estimatedDocumentCount(); }); $this->assertEquals(3, $count); @@ -929,13 +918,39 @@ public function testRaw(): void return $collection->insertOne(['name' => 'Yvonne Yoe', 'age' => 35]); }); $this->assertNotNull($result); + } - $result = User::raw(function (Collection $collection) { - return $collection->aggregate([ - ['$set' => ['now' => '$$NOW']], - ['$limit' => 2], - ]); - }); + #[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); @@ -945,6 +960,17 @@ public function testRaw(): void $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 From 5a79bec1870e5779a4de117ad3e779c7d32d7bd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 17 Sep 2024 12:43:42 +0200 Subject: [PATCH 4/5] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) 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) From 676391479483cabb547e3f16e3fa62151c8d7c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 17 Sep 2024 13:45:31 +0200 Subject: [PATCH 5/5] Add test on where() --- tests/Query/BuilderTest.php | 25 +++++++++++++++++++++++++ tests/QueryBuilderTest.php | 13 +++++-------- 2 files changed, 30 insertions(+), 8 deletions(-) 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')] diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 444bdaeae..523ad3411 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -18,6 +18,7 @@ use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; use MongoDB\Collection; +use MongoDB\Driver\Cursor; use MongoDB\Driver\Monitoring\CommandFailedEvent; use MongoDB\Driver\Monitoring\CommandStartedEvent; use MongoDB\Driver\Monitoring\CommandSubscriber; @@ -313,16 +314,12 @@ public function testRaw() ['name' => 'John Doe', 'age' => 25], ]); - $results = DB::table('users')->raw(function ($collection) { - return $collection->find(['age' => 20], ['typeMap' => ['root' => 'array', 'document' => 'array']]); + $cursor = DB::table('users')->raw(function ($collection) { + return $collection->find(['age' => 20]); }); - $this->assertIsArray($results); - $this->assertCount(1, $results); - $this->assertArrayNotHasKey('_id', $results[0]); - $this->assertArrayHasKey('id', $results[0]); - $this->assertInstanceOf(ObjectId::class, $results[0]['id']); - $this->assertSame(20, $results[0]['age']); + $this->assertInstanceOf(Cursor::class, $cursor); + $this->assertCount(1, $cursor->toArray()); $collection = DB::table('users')->raw(); $this->assertInstanceOf(Collection::class, $collection);