diff --git a/.github/workflows/build-ci-atlas.yml b/.github/workflows/build-ci-atlas.yml index 339f8fc38..c699d2023 100644 --- a/.github/workflows/build-ci-atlas.yml +++ b/.github/workflows/build-ci-atlas.yml @@ -6,7 +6,7 @@ on: env: MONGODB_EXT_V1: mongodb-1.21.0 - MONGODB_EXT_V2: mongodb-mongodb/mongo-php-driver@v2.x + MONGODB_EXT_V2: stable jobs: build: diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index bc799c70e..6ff13a7f0 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -6,7 +6,7 @@ on: env: MONGODB_EXT_V1: mongodb-1.21.0 - MONGODB_EXT_V2: mongodb-mongodb/mongo-php-driver@v2.x + MONGODB_EXT_V2: stable jobs: build: diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 946e84971..068415f37 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -49,7 +49,7 @@ jobs: run: "php --ri mongodb" - name: "Install dependencies with Composer" - uses: "ramsey/composer-install@3.0.0" + uses: "ramsey/composer-install@3.1.0" with: composer-options: "--no-suggest" diff --git a/docs/compatibility.txt b/docs/compatibility.txt index 9ee891e20..5580e6856 100644 --- a/docs/compatibility.txt +++ b/docs/compatibility.txt @@ -34,3 +34,9 @@ PHP Driver Compatibility To use {+odm-long+} v5.2 or later, you must install v1.21 of the {+php-library+} and {+php-extension+}. + +.. important:: {+php-extension+} v2.0 Compatibility + + If you upgrade the {+php-extension+} to v2.0, you must also upgrade + {+odm-long+} to v5.2.1. {+odm-long+} v5.2.1 still supports v1.x + versions of the extension. diff --git a/src/Connection.php b/src/Connection.php index 4dd04120d..29b72ae44 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -53,6 +53,9 @@ class Connection extends BaseConnection private ?CommandSubscriber $commandSubscriber = null; + /** @var bool Whether to rename the rename "id" into "_id" for embedded documents. */ + private bool $renameEmbeddedIdField; + /** * Create a new database connection instance. */ @@ -80,6 +83,8 @@ public function __construct(array $config) $this->useDefaultSchemaGrammar(); $this->useDefaultQueryGrammar(); + + $this->renameEmbeddedIdField = $config['rename_embedded_id_field'] ?? true; } /** @@ -395,6 +400,18 @@ public function __call($method, $parameters) return $this->db->$method(...$parameters); } + /** Set whether to rename "id" field into "_id" for embedded documents. */ + public function setRenameEmbeddedIdField(bool $rename): void + { + $this->renameEmbeddedIdField = $rename; + } + + /** Get whether to rename "id" field into "_id" for embedded documents. */ + public function getRenameEmbeddedIdField(): bool + { + return $this->renameEmbeddedIdField; + } + /** * Return the server version of one of the MongoDB servers: primary for * replica sets and standalone, and the selected server for sharded clusters. diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index aabc526f7..f3ffd7012 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -18,6 +18,7 @@ use MongoDB\Model\BSONDocument; use function array_key_exists; +use function array_map; use function array_replace; use function collect; use function is_array; @@ -237,7 +238,7 @@ public function raw($value = null) // Convert MongoCursor results to a collection of models. if ($results instanceof CursorInterface) { $results->setTypeMap(['root' => 'array', 'document' => 'array', 'array' => 'array']); - $results = $this->query->aliasIdForResult(iterator_to_array($results)); + $results = array_map(fn ($document) => $this->query->aliasIdForResult($document), iterator_to_array($results)); return $this->model->hydrate($results); } diff --git a/src/Eloquent/DocumentModel.php b/src/Eloquent/DocumentModel.php index d39a12401..965b1a444 100644 --- a/src/Eloquent/DocumentModel.php +++ b/src/Eloquent/DocumentModel.php @@ -5,7 +5,6 @@ namespace MongoDB\Laravel\Eloquent; use BackedEnum; -use Carbon\Carbon; use Carbon\CarbonInterface; use DateTimeInterface; use DateTimeZone; @@ -128,7 +127,7 @@ public function fromDateTime($value): UTCDateTime * * @param mixed $value */ - protected function asDateTime($value): Carbon + protected function asDateTime($value): DateTimeInterface { // Convert UTCDateTime instances to Carbon. if ($value instanceof UTCDateTime) { diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 07a3483b0..7d0fdce74 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -29,6 +29,7 @@ use MongoDB\Builder\Type\SearchOperatorInterface; use MongoDB\Driver\Cursor; use MongoDB\Driver\ReadPreference; +use MongoDB\Laravel\Connection; use Override; use RuntimeException; use stdClass; @@ -83,6 +84,7 @@ use function trait_exists; use function var_export; +/** @property Connection $connection */ class Builder extends BaseBuilder { private const REGEX_DELIMITERS = ['/', '#', '~']; @@ -1764,9 +1766,9 @@ public function orWhereIntegerNotInRaw($column, $values, $boolean = 'and') throw new BadMethodCallException('This method is not supported by MongoDB'); } - private function aliasIdForQuery(array $values): array + private function aliasIdForQuery(array $values, bool $root = true): array { - if (array_key_exists('id', $values)) { + if (array_key_exists('id', $values) && ($root || $this->connection->getRenameEmbeddedIdField())) { if (array_key_exists('_id', $values) && $values['id'] !== $values['_id']) { throw new InvalidArgumentException('Cannot have both "id" and "_id" fields.'); } @@ -1793,7 +1795,7 @@ private function aliasIdForQuery(array $values): array } // ".id" subfield are alias for "._id" - if (str_ends_with($key, '.id')) { + if (str_ends_with($key, '.id') && ($root || $this->connection->getRenameEmbeddedIdField())) { $newkey = substr($key, 0, -3) . '._id'; if (array_key_exists($newkey, $values) && $value !== $values[$newkey]) { throw new InvalidArgumentException(sprintf('Cannot have both "%s" and "%s" fields.', $key, $newkey)); @@ -1806,7 +1808,7 @@ private function aliasIdForQuery(array $values): array foreach ($values as &$value) { if (is_array($value)) { - $value = $this->aliasIdForQuery($value); + $value = $this->aliasIdForQuery($value, false); } elseif ($value instanceof DateTimeInterface) { $value = new UTCDateTime($value); } @@ -1824,10 +1826,13 @@ private function aliasIdForQuery(array $values): array * * @template T of array|object */ - public function aliasIdForResult(array|object $values): array|object + public function aliasIdForResult(array|object $values, bool $root = true): array|object { if (is_array($values)) { - if (array_key_exists('_id', $values) && ! array_key_exists('id', $values)) { + if ( + array_key_exists('_id', $values) && ! array_key_exists('id', $values) + && ($root || $this->connection->getRenameEmbeddedIdField()) + ) { $values['id'] = $values['_id']; unset($values['_id']); } @@ -1837,13 +1842,16 @@ public function aliasIdForResult(array|object $values): array|object $values[$key] = Date::instance($value->toDateTime()) ->setTimezone(new DateTimeZone(date_default_timezone_get())); } elseif (is_array($value) || is_object($value)) { - $values[$key] = $this->aliasIdForResult($value); + $values[$key] = $this->aliasIdForResult($value, false); } } } if ($values instanceof stdClass) { - if (property_exists($values, '_id') && ! property_exists($values, 'id')) { + if ( + property_exists($values, '_id') && ! property_exists($values, 'id') + && ($root || $this->connection->getRenameEmbeddedIdField()) + ) { $values->id = $values->_id; unset($values->_id); } @@ -1853,7 +1861,7 @@ public function aliasIdForResult(array|object $values): array|object $values->{$key} = Date::instance($value->toDateTime()) ->setTimezone(new DateTimeZone(date_default_timezone_get())); } elseif (is_array($value) || is_object($value)) { - $values->{$key} = $this->aliasIdForResult($value); + $values->{$key} = $this->aliasIdForResult($value, false); } } } diff --git a/tests/DateTimeImmutableTest.php b/tests/DateTimeImmutableTest.php new file mode 100644 index 000000000..a4dffb168 --- /dev/null +++ b/tests/DateTimeImmutableTest.php @@ -0,0 +1,43 @@ + 'John', + 'anniversary' => new CarbonImmutable('2020-01-01 00:00:00'), + ]); + + $anniversary = Anniversary::sole(); + assert($anniversary instanceof Anniversary); + self::assertInstanceOf(CarbonImmutable::class, $anniversary->anniversary); + } +} diff --git a/tests/Models/Anniversary.php b/tests/Models/Anniversary.php new file mode 100644 index 000000000..fb78c9a55 --- /dev/null +++ b/tests/Models/Anniversary.php @@ -0,0 +1,30 @@ + 'immutable_datetime']; +} diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 20b5a12fb..6e68d42c7 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -12,7 +12,6 @@ use Illuminate\Tests\Database\DatabaseQueryBuilderTest; use InvalidArgumentException; use LogicException; -use Mockery as m; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; use MongoDB\Driver\ReadPreference; @@ -39,7 +38,7 @@ public function testMql(array $expected, Closure $build, ?string $requiredMethod $this->markTestSkipped(sprintf('Method "%s::%s()" does not exist.', Builder::class, $requiredMethod)); } - $builder = $build(self::getBuilder()); + $builder = $build($this->getBuilder()); $this->assertInstanceOf(Builder::class, $builder); $mql = $builder->toMql(); @@ -1447,7 +1446,7 @@ function (Builder $elemMatchQuery): void { #[DataProvider('provideExceptions')] public function testException($class, $message, Closure $build): void { - $builder = self::getBuilder(); + $builder = $this->getBuilder(); $this->expectException($class); $this->expectExceptionMessage($message); @@ -1545,7 +1544,7 @@ public static function provideExceptions(): iterable #[DataProvider('getEloquentMethodsNotSupported')] public function testEloquentMethodsNotSupported(Closure $callback) { - $builder = self::getBuilder(); + $builder = $this->getBuilder(); $this->expectException(BadMethodCallException::class); $this->expectExceptionMessage('This method is not supported by MongoDB'); @@ -1600,12 +1599,38 @@ public static function getEloquentMethodsNotSupported() yield 'orWhereIntegerNotInRaw' => [fn (Builder $builder) => $builder->orWhereIntegerNotInRaw('id', ['1a', 2])]; } - private static function getBuilder(): Builder + public function testRenameEmbeddedIdFieldCanBeDisabled() { - $connection = m::mock(Connection::class); - $processor = m::mock(Processor::class); - $connection->shouldReceive('getSession')->andReturn(null); - $connection->shouldReceive('getQueryGrammar')->andReturn(new Grammar($connection)); + $builder = $this->getBuilder(false); + $this->assertFalse($builder->getConnection()->getRenameEmbeddedIdField()); + + $mql = $builder + ->where('id', '=', 10) + ->where('nested.id', '=', 20) + ->where('embed', '=', ['id' => 30]) + ->toMql(); + + $this->assertEquals([ + 'find' => [ + [ + '$and' => [ + ['_id' => 10], + ['nested.id' => 20], + ['embed' => ['id' => 30]], + ], + ], + ['typeMap' => ['root' => 'object', 'document' => 'array']], + ], + ], $mql); + } + + private function getBuilder(bool $renameEmbeddedIdField = true): Builder + { + $connection = $this->createStub(Connection::class); + $connection->method('getRenameEmbeddedIdField')->willReturn($renameEmbeddedIdField); + $processor = $this->createStub(Processor::class); + $connection->method('getSession')->willReturn(null); + $connection->method('getQueryGrammar')->willReturn(new Grammar($connection)); return new Builder($connection, null, $processor); } diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 46beebab1..1233cda75 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -20,10 +20,12 @@ use MongoDB\BSON\UTCDateTime; use MongoDB\Collection; use MongoDB\Driver\Cursor; +use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Monitoring\CommandFailedEvent; use MongoDB\Driver\Monitoring\CommandStartedEvent; use MongoDB\Driver\Monitoring\CommandSubscriber; use MongoDB\Driver\Monitoring\CommandSucceededEvent; +use MongoDB\Laravel\Connection; use MongoDB\Laravel\Query\Builder; use MongoDB\Laravel\Tests\Models\Item; use MongoDB\Laravel\Tests\Models\User; @@ -336,6 +338,93 @@ public function testRaw() $this->assertEquals('Jane Doe', $results[0]->name); } + public function testRawResultRenameId() + { + $connection = DB::connection('mongodb'); + self::assertInstanceOf(Connection::class, $connection); + + $date = Carbon::createFromDate(1986, 12, 31)->setTime(12, 0, 0); + User::insert([ + ['id' => 1, 'name' => 'Jane Doe', 'address' => ['id' => 11, 'city' => 'Ghent'], 'birthday' => $date], + ['id' => 2, 'name' => 'John Doe', 'address' => ['id' => 12, 'city' => 'Brussels'], 'birthday' => $date], + ]); + + // Using raw database query, result is not altered + $results = $connection->table('users')->raw(fn (Collection $collection) => $collection->find([])); + self::assertInstanceOf(CursorInterface::class, $results); + $results = $results->toArray(); + self::assertCount(2, $results); + + self::assertObjectHasProperty('_id', $results[0]); + self::assertObjectNotHasProperty('id', $results[0]); + self::assertSame(1, $results[0]->_id); + + self::assertObjectHasProperty('_id', $results[0]->address); + self::assertObjectNotHasProperty('id', $results[0]->address); + self::assertSame(11, $results[0]->address->_id); + + self::assertInstanceOf(UTCDateTime::class, $results[0]->birthday); + + // Using Eloquent query, result is transformed + self::assertTrue($connection->getRenameEmbeddedIdField()); + $results = User::raw(fn (Collection $collection) => $collection->find([])); + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(2, $results); + + $attributes = $results->first()->getAttributes(); + self::assertArrayHasKey('id', $attributes); + self::assertArrayNotHasKey('_id', $attributes); + self::assertSame(1, $attributes['id']); + + self::assertArrayHasKey('id', $attributes['address']); + self::assertArrayNotHasKey('_id', $attributes['address']); + self::assertSame(11, $attributes['address']['id']); + + self::assertEquals($date, $attributes['birthday']); + + // Single result + $result = User::raw(fn (Collection $collection) => $collection->findOne([], ['typeMap' => ['root' => 'object', 'document' => 'array']])); + self::assertInstanceOf(User::class, $result); + + $attributes = $result->getAttributes(); + self::assertArrayHasKey('id', $attributes); + self::assertArrayNotHasKey('_id', $attributes); + self::assertSame(1, $attributes['id']); + + self::assertArrayHasKey('id', $attributes['address']); + self::assertArrayNotHasKey('_id', $attributes['address']); + self::assertSame(11, $attributes['address']['id']); + + // Change the renameEmbeddedIdField option + $connection->setRenameEmbeddedIdField(false); + + $results = User::raw(fn (Collection $collection) => $collection->find([])); + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(2, $results); + + $attributes = $results->first()->getAttributes(); + self::assertArrayHasKey('id', $attributes); + self::assertArrayNotHasKey('_id', $attributes); + self::assertSame(1, $attributes['id']); + + self::assertArrayHasKey('_id', $attributes['address']); + self::assertArrayNotHasKey('id', $attributes['address']); + self::assertSame(11, $attributes['address']['_id']); + + // Single result + $result = User::raw(fn (Collection $collection) => $collection->findOne([])); + self::assertInstanceOf(User::class, $result); + + $attributes = $result->getAttributes(); + self::assertArrayHasKey('id', $attributes); + self::assertArrayNotHasKey('_id', $attributes); + self::assertSame(1, $attributes['id']); + + self::assertArrayHasKey('_id', $attributes['address']); + self::assertArrayNotHasKey('id', $attributes['address']); + self::assertSame(11, $attributes['address']['_id']); + } + public function testPush() { $id = DB::table('users')->insertGetId([