From 3cd7091b43cc6f1d83c9d47fe4e429f4a9a9610c Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 25 Aug 2023 08:39:22 +0200 Subject: [PATCH] PHPLIB-1182: Support codec option in operation classes (#1140) * Add Codec support to find operations * Add codec support to insert operations * Add codec support to replace operation * Add codec support to aggregate operation * Add codec support to BulkWrite operation * Add codec support to findAndModify operations * Add codec support to Watch operation * Support passing default codec to collection class * Remove temporary variable usage in operations * Enforce iterator type when creating ChangeStreamIterator * Remove unnecessary null-coalesce * Ensure operation-level type map gets precedence over collection-level codec * Reference psalm issue regarding unions and assert-if type annotations * Add iterator type in ReadableStream * Assume _id property will be present in change stream result document. * Prohibit specifying typeMap and codec options for operations This commit also refactors the logic of inheriting collection-level options to operations to reduce code duplication. * Defer server selection until executing operation * Remove useless assertion * Assert iterator type in ChangeStreamIterator::getInnerIterator * Make assertions on encoded result conditional * Extract factory to created decoded fixtures * Split document creation for legibility * Insert fixture through factory in test object * Trigger warning when calling CodecCursor::setTypeMap * Encode documents before type checks This commit also changes the behaviour to use encode() instead of encodeIfSupported(), requiring documents to be encodable by the given codec in order to be inserted/updated. * Use more descriptive value in failing tests * Add clarifying comment on skipping validation * Simplify double isset check * Split chained calls when inheriting options * Defer server selection in Collection::drop() * Split inheritReadOptions method * Use decode() instead of decodeIfSupported() * Add explanatory comment for ID filling behaviour * Handle null values in findAndModify responses * Update comment regarding missing identifiers in tests * Use intersection type for change streams * Prohibit specifying codec and typeMap options to Watch --- psalm-baseline.xml | 39 ++ src/ChangeStream.php | 26 +- src/Collection.php | 438 ++++++-------- src/Exception/InvalidArgumentException.php | 5 + src/GridFS/Bucket.php | 5 +- src/GridFS/CollectionWrapper.php | 8 +- src/GridFS/ReadableStream.php | 6 +- src/Model/ChangeStreamIterator.php | 39 +- src/Model/CodecCursor.php | 131 +++++ src/Operation/Aggregate.php | 21 +- src/Operation/BulkWrite.php | 269 +++++---- src/Operation/Find.php | 22 +- src/Operation/FindAndModify.php | 24 + src/Operation/FindOne.php | 3 + src/Operation/FindOneAndDelete.php | 3 + src/Operation/FindOneAndReplace.php | 53 +- src/Operation/FindOneAndUpdate.php | 3 + src/Operation/InsertMany.php | 59 +- src/Operation/InsertOne.php | 32 +- src/Operation/ReplaceOne.php | 53 +- src/Operation/Watch.php | 23 +- .../CodecCollectionFunctionalTest.php | 534 ++++++++++++++++++ tests/Collection/CollectionFunctionalTest.php | 1 + tests/Fixtures/Codec/TestDocumentCodec.php | 71 +++ tests/Fixtures/Document/TestNestedObject.php | 23 + tests/Fixtures/Document/TestObject.php | 56 ++ tests/Model/CodecCursorFunctionalTest.php | 30 + tests/Operation/AggregateFunctionalTest.php | 31 +- tests/Operation/AggregateTest.php | 1 + tests/Operation/BulkWriteFunctionalTest.php | 43 ++ tests/Operation/BulkWriteTest.php | 27 + .../Operation/FindAndModifyFunctionalTest.php | 94 ++- tests/Operation/FindAndModifyTest.php | 1 + tests/Operation/FindFunctionalTest.php | 26 +- .../FindOneAndReplaceFunctionalTest.php | 63 +++ tests/Operation/FindOneAndReplaceTest.php | 1 + tests/Operation/FindOneFunctionalTest.php | 19 +- tests/Operation/FindTest.php | 1 + tests/Operation/InsertManyFunctionalTest.php | 46 ++ tests/Operation/InsertManyTest.php | 10 + tests/Operation/InsertOneFunctionalTest.php | 27 + tests/Operation/InsertOneTest.php | 10 + tests/Operation/ReplaceOneFunctionalTest.php | 38 ++ tests/Operation/ReplaceOneTest.php | 23 + tests/Operation/WatchFunctionalTest.php | 261 ++++++--- tests/Operation/WatchTest.php | 10 + tests/TestCase.php | 6 + 47 files changed, 2161 insertions(+), 554 deletions(-) create mode 100644 src/Model/CodecCursor.php create mode 100644 tests/Collection/CodecCollectionFunctionalTest.php create mode 100644 tests/Fixtures/Codec/TestDocumentCodec.php create mode 100644 tests/Fixtures/Document/TestNestedObject.php create mode 100644 tests/Fixtures/Document/TestObject.php create mode 100644 tests/Model/CodecCursorFunctionalTest.php create mode 100644 tests/Operation/FindOneAndReplaceFunctionalTest.php create mode 100644 tests/Operation/ReplaceOneFunctionalTest.php diff --git a/psalm-baseline.xml b/psalm-baseline.xml index e6b0a3436..17571a178 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -101,6 +101,7 @@ cursor->nextBatch]]> + $resumeToken postBatchResumeToken]]> @@ -170,6 +171,7 @@ + options['codec']]]> options['typeMap']]]> @@ -191,6 +193,8 @@ $args[0] $args[0] $args[0] + $args[0] + $args[1] $args[1] $args[1] $args[1] @@ -205,6 +209,8 @@ $args[0] $args[0] $args[0] + $args[0] + $args[1] $args[1] $args[1] $args[1] @@ -244,6 +250,8 @@ $args[2] + $operations[$i][$type][0] + $operations[$i][$type][1] $operations[$i][$type][1] $operations[$i][$type][2] $operations[$i][$type][2] @@ -401,6 +409,7 @@ + options['codec']]]> options['typeMap']]]> @@ -427,18 +436,34 @@ + $value array|object|null + decode isInTransaction + options['codec']->decode($value)]]> + options['codec']->decode($value)]]> value ?? null) : null]]> value ?? null) : null]]> + + + $document + + + array|object|null + + + $document === false ? null : $document + $document === false ? null : $document + + @@ -448,6 +473,9 @@ + + $replacement + @@ -464,6 +492,9 @@ isInTransaction + + $document + @@ -475,6 +506,9 @@ isInTransaction + + $document + @@ -522,6 +556,11 @@ isInTransaction + + + $replacement + + options['writeConcern']]]> diff --git a/src/ChangeStream.php b/src/ChangeStream.php index 6fc3bbaa1..8193a35eb 100644 --- a/src/ChangeStream.php +++ b/src/ChangeStream.php @@ -18,6 +18,8 @@ namespace MongoDB; use Iterator; +use MongoDB\BSON\Document; +use MongoDB\Codec\DocumentCodec; use MongoDB\Driver\CursorId; use MongoDB\Driver\Exception\ConnectionException; use MongoDB\Driver\Exception\RuntimeException; @@ -27,6 +29,7 @@ use MongoDB\Model\ChangeStreamIterator; use ReturnTypeWillChange; +use function assert; use function call_user_func; use function in_array; @@ -82,15 +85,22 @@ class ChangeStream implements Iterator */ private bool $hasAdvanced = false; + private ?DocumentCodec $codec; + /** * @internal * * @param ResumeCallable $resumeCallable */ - public function __construct(ChangeStreamIterator $iterator, callable $resumeCallable) + public function __construct(ChangeStreamIterator $iterator, callable $resumeCallable, ?DocumentCodec $codec = null) { $this->iterator = $iterator; $this->resumeCallable = $resumeCallable; + $this->codec = $codec; + + if ($codec) { + $this->iterator->getInnerIterator()->setTypeMap(['root' => 'bson']); + } } /** @@ -100,7 +110,15 @@ public function __construct(ChangeStreamIterator $iterator, callable $resumeCall #[ReturnTypeWillChange] public function current() { - return $this->iterator->current(); + $value = $this->iterator->current(); + + if (! $this->codec) { + return $value; + } + + assert($value instanceof Document); + + return $this->codec->decode($value); } /** @return CursorId */ @@ -252,6 +270,10 @@ private function resume(): void $this->iterator->rewind(); + if ($this->codec) { + $this->iterator->getInnerIterator()->setTypeMap(['root' => 'bson']); + } + $this->onIteration($this->hasAdvanced); } diff --git a/src/Collection.php b/src/Collection.php index 801511951..b5a672308 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -17,8 +17,10 @@ namespace MongoDB; +use Iterator; use MongoDB\BSON\JavascriptInterface; -use MongoDB\Driver\Cursor; +use MongoDB\Codec\DocumentCodec; +use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; use MongoDB\Driver\Manager; use MongoDB\Driver\ReadConcern; @@ -62,6 +64,7 @@ use function array_diff_key; use function array_intersect_key; +use function array_key_exists; use function current; use function is_array; use function strlen; @@ -76,6 +79,8 @@ class Collection private const WIRE_VERSION_FOR_READ_CONCERN_WITH_WRITE_STAGE = 8; + private ?DocumentCodec $codec = null; + private string $collectionName; private string $databaseName; @@ -98,6 +103,9 @@ class Collection * * Supported options: * + * * codec (MongoDB\Codec\DocumentCodec): Codec used to decode documents + * from BSON to PHP objects. + * * * readConcern (MongoDB\Driver\ReadConcern): The default read concern to * use for collection operations. Defaults to the Manager's read concern. * @@ -127,6 +135,10 @@ public function __construct(Manager $manager, string $databaseName, string $coll throw new InvalidArgumentException('$collectionName is invalid: ' . $collectionName); } + if (isset($options['codec']) && ! $options['codec'] instanceof DocumentCodec) { + throw InvalidArgumentException::invalidType('"codec" option', $options['codec'], DocumentCodec::class); + } + if (isset($options['readConcern']) && ! $options['readConcern'] instanceof ReadConcern) { throw InvalidArgumentException::invalidType('"readConcern" option', $options['readConcern'], ReadConcern::class); } @@ -146,6 +158,8 @@ public function __construct(Manager $manager, string $databaseName, string $coll $this->manager = $manager; $this->databaseName = $databaseName; $this->collectionName = $collectionName; + + $this->codec = $options['codec'] ?? null; $this->readConcern = $options['readConcern'] ?? $this->manager->getReadConcern(); $this->readPreference = $options['readPreference'] ?? $this->manager->getReadPreference(); $this->typeMap = $options['typeMap'] ?? self::DEFAULT_TYPE_MAP; @@ -161,6 +175,7 @@ public function __construct(Manager $manager, string $databaseName, string $coll public function __debugInfo() { return [ + 'codec' => $this->codec, 'collectionName' => $this->collectionName, 'databaseName' => $this->databaseName, 'manager' => $this->manager, @@ -188,7 +203,7 @@ public function __toString() * @see Aggregate::__construct() for supported options * @param array $pipeline Aggregation pipeline * @param array $options Command options - * @return Cursor + * @return CursorInterface&Iterator * @throws UnexpectedValueException if the command response was malformed * @throws UnsupportedException if options are not supported by the selected server * @throws InvalidArgumentException for parameter/option parsing errors @@ -198,9 +213,7 @@ public function aggregate(array $pipeline, array $options = []) { $hasWriteStage = is_last_pipeline_operator_write($pipeline); - if (! isset($options['readPreference']) && ! is_in_transaction($options)) { - $options['readPreference'] = $this->readPreference; - } + $options = $this->inheritReadPreference($options); $server = $hasWriteStage ? select_server_for_aggregate_write_stage($this->manager, $options) @@ -208,23 +221,15 @@ public function aggregate(array $pipeline, array $options = []) /* MongoDB 4.2 and later supports a read concern when an $out stage is * being used, but earlier versions do not. - * - * A read concern is also not compatible with transactions. */ - if ( - ! isset($options['readConcern']) && - ! is_in_transaction($options) && - ( ! $hasWriteStage || server_supports_feature($server, self::WIRE_VERSION_FOR_READ_CONCERN_WITH_WRITE_STAGE)) - ) { - $options['readConcern'] = $this->readConcern; + if (! $hasWriteStage || server_supports_feature($server, self::WIRE_VERSION_FOR_READ_CONCERN_WITH_WRITE_STAGE)) { + $options = $this->inheritReadConcern($options); } - if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; - } + $options = $this->inheritCodecOrTypeMap($options); - if ($hasWriteStage && ! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; + if ($hasWriteStage) { + $options = $this->inheritWriteOptions($options); } $operation = new Aggregate($this->databaseName, $this->collectionName, $pipeline, $options); @@ -245,14 +250,12 @@ public function aggregate(array $pipeline, array $options = []) */ public function bulkWrite(array $operations, array $options = []) { - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } + $options = $this->inheritWriteOptions($options); + $options = $this->inheritCodec($options); $operation = new BulkWrite($this->databaseName, $this->collectionName, $operations, $options); - $server = select_server($this->manager, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -271,19 +274,11 @@ public function bulkWrite(array $operations, array $options = []) */ public function count($filter = [], array $options = []) { - if (! isset($options['readPreference']) && ! is_in_transaction($options)) { - $options['readPreference'] = $this->readPreference; - } - - $server = select_server($this->manager, $options); - - if (! isset($options['readConcern']) && ! is_in_transaction($options)) { - $options['readConcern'] = $this->readConcern; - } + $options = $this->inheritReadOptions($options); $operation = new Count($this->databaseName, $this->collectionName, $filter, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -300,19 +295,11 @@ public function count($filter = [], array $options = []) */ public function countDocuments($filter = [], array $options = []) { - if (! isset($options['readPreference']) && ! is_in_transaction($options)) { - $options['readPreference'] = $this->readPreference; - } - - $server = select_server($this->manager, $options); - - if (! isset($options['readConcern']) && ! is_in_transaction($options)) { - $options['readConcern'] = $this->readConcern; - } + $options = $this->inheritReadOptions($options); $operation = new CountDocuments($this->databaseName, $this->collectionName, $filter, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -366,15 +353,11 @@ public function createIndex($key, array $options = []) */ public function createIndexes(array $indexes, array $options = []) { - $server = select_server($this->manager, $options); - - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } + $options = $this->inheritWriteOptions($options); $operation = new CreateIndexes($this->databaseName, $this->collectionName, $indexes, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -391,14 +374,11 @@ public function createIndexes(array $indexes, array $options = []) */ public function deleteMany($filter, array $options = []) { - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } + $options = $this->inheritWriteOptions($options); $operation = new DeleteMany($this->databaseName, $this->collectionName, $filter, $options); - $server = select_server($this->manager, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -415,14 +395,11 @@ public function deleteMany($filter, array $options = []) */ public function deleteOne($filter, array $options = []) { - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } + $options = $this->inheritWriteOptions($options); $operation = new DeleteOne($this->databaseName, $this->collectionName, $filter, $options); - $server = select_server($this->manager, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -440,23 +417,12 @@ public function deleteOne($filter, array $options = []) */ public function distinct(string $fieldName, $filter = [], array $options = []) { - if (! isset($options['readPreference']) && ! is_in_transaction($options)) { - $options['readPreference'] = $this->readPreference; - } - - if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; - } - - $server = select_server($this->manager, $options); - - if (! isset($options['readConcern']) && ! is_in_transaction($options)) { - $options['readConcern'] = $this->readConcern; - } + $options = $this->inheritReadOptions($options); + $options = $this->inheritTypeMap($options); $operation = new Distinct($this->databaseName, $this->collectionName, $fieldName, $filter, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -471,16 +437,11 @@ public function distinct(string $fieldName, $filter = [], array $options = []) */ public function drop(array $options = []) { - if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; - } + $options = $this->inheritWriteOptions($options); + $options = $this->inheritTypeMap($options); $server = select_server($this->manager, $options); - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } - if (! isset($options['encryptedFields'])) { $options['encryptedFields'] = get_encrypted_fields_from_driver($this->databaseName, $this->collectionName, $this->manager) ?? get_encrypted_fields_from_server($this->databaseName, $this->collectionName, $this->manager, $server); @@ -512,19 +473,12 @@ public function dropIndex($indexName, array $options = []) throw new InvalidArgumentException('dropIndexes() must be used to drop multiple indexes'); } - if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; - } - - $server = select_server($this->manager, $options); - - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } + $options = $this->inheritWriteOptions($options); + $options = $this->inheritTypeMap($options); $operation = new DropIndexes($this->databaseName, $this->collectionName, $indexName, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -539,19 +493,12 @@ public function dropIndex($indexName, array $options = []) */ public function dropIndexes(array $options = []) { - if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; - } - - $server = select_server($this->manager, $options); - - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } + $options = $this->inheritWriteOptions($options); + $options = $this->inheritTypeMap($options); $operation = new DropIndexes($this->databaseName, $this->collectionName, '*', $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -567,19 +514,11 @@ public function dropIndexes(array $options = []) */ public function estimatedDocumentCount(array $options = []) { - if (! isset($options['readPreference']) && ! is_in_transaction($options)) { - $options['readPreference'] = $this->readPreference; - } - - $server = select_server($this->manager, $options); - - if (! isset($options['readConcern']) && ! is_in_transaction($options)) { - $options['readConcern'] = $this->readConcern; - } + $options = $this->inheritReadOptions($options); $operation = new EstimatedDocumentCount($this->databaseName, $this->collectionName, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -596,19 +535,12 @@ public function estimatedDocumentCount(array $options = []) */ public function explain(Explainable $explainable, array $options = []) { - if (! isset($options['readPreference']) && ! is_in_transaction($options)) { - $options['readPreference'] = $this->readPreference; - } - - if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; - } - - $server = select_server($this->manager, $options); + $options = $this->inheritReadPreference($options); + $options = $this->inheritTypeMap($options); $operation = new Explain($this->databaseName, $explainable, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -618,30 +550,19 @@ public function explain(Explainable $explainable, array $options = []) * @see https://mongodb.com/docs/manual/crud/#read-operations * @param array|object $filter Query by which to filter documents * @param array $options Additional options - * @return Cursor + * @return CursorInterface&Iterator * @throws UnsupportedException if options are not supported by the selected server * @throws InvalidArgumentException for parameter/option parsing errors * @throws DriverRuntimeException for other driver errors (e.g. connection errors) */ public function find($filter = [], array $options = []) { - if (! isset($options['readPreference']) && ! is_in_transaction($options)) { - $options['readPreference'] = $this->readPreference; - } - - $server = select_server($this->manager, $options); - - if (! isset($options['readConcern']) && ! is_in_transaction($options)) { - $options['readConcern'] = $this->readConcern; - } - - if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; - } + $options = $this->inheritReadOptions($options); + $options = $this->inheritCodecOrTypeMap($options); $operation = new Find($this->databaseName, $this->collectionName, $filter, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -658,23 +579,12 @@ public function find($filter = [], array $options = []) */ public function findOne($filter = [], array $options = []) { - if (! isset($options['readPreference']) && ! is_in_transaction($options)) { - $options['readPreference'] = $this->readPreference; - } - - $server = select_server($this->manager, $options); - - if (! isset($options['readConcern']) && ! is_in_transaction($options)) { - $options['readConcern'] = $this->readConcern; - } - - if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; - } + $options = $this->inheritReadOptions($options); + $options = $this->inheritCodecOrTypeMap($options); $operation = new FindOne($this->databaseName, $this->collectionName, $filter, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -694,19 +604,12 @@ public function findOne($filter = [], array $options = []) */ public function findOneAndDelete($filter, array $options = []) { - $server = select_server($this->manager, $options); - - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } - - if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; - } + $options = $this->inheritWriteOptions($options); + $options = $this->inheritCodecOrTypeMap($options); $operation = new FindOneAndDelete($this->databaseName, $this->collectionName, $filter, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -731,19 +634,12 @@ public function findOneAndDelete($filter, array $options = []) */ public function findOneAndReplace($filter, $replacement, array $options = []) { - $server = select_server($this->manager, $options); - - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } - - if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; - } + $options = $this->inheritWriteOptions($options); + $options = $this->inheritCodecOrTypeMap($options); $operation = new FindOneAndReplace($this->databaseName, $this->collectionName, $filter, $replacement, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -768,19 +664,12 @@ public function findOneAndReplace($filter, $replacement, array $options = []) */ public function findOneAndUpdate($filter, $update, array $options = []) { - $server = select_server($this->manager, $options); - - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } - - if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; - } + $options = $this->inheritWriteOptions($options); + $options = $this->inheritCodecOrTypeMap($options); $operation = new FindOneAndUpdate($this->databaseName, $this->collectionName, $filter, $update, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -871,22 +760,20 @@ public function getWriteConcern() * * @see InsertMany::__construct() for supported options * @see https://mongodb.com/docs/manual/reference/command/insert/ - * @param array[]|object[] $documents The documents to insert - * @param array $options Command options + * @param list $documents The documents to insert + * @param array $options Command options * @return InsertManyResult * @throws InvalidArgumentException for parameter/option parsing errors * @throws DriverRuntimeException for other driver errors (e.g. connection errors) */ public function insertMany(array $documents, array $options = []) { - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } + $options = $this->inheritWriteOptions($options); + $options = $this->inheritCodec($options); $operation = new InsertMany($this->databaseName, $this->collectionName, $documents, $options); - $server = select_server($this->manager, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -902,14 +789,12 @@ public function insertMany(array $documents, array $options = []) */ public function insertOne($document, array $options = []) { - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } + $options = $this->inheritWriteOptions($options); + $options = $this->inheritCodec($options); $operation = new InsertOne($this->databaseName, $this->collectionName, $document, $options); - $server = select_server($this->manager, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -923,9 +808,8 @@ public function insertOne($document, array $options = []) public function listIndexes(array $options = []) { $operation = new ListIndexes($this->databaseName, $this->collectionName, $options); - $server = select_server($this->manager, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -947,37 +831,26 @@ public function mapReduce(JavascriptInterface $map, JavascriptInterface $reduce, { $hasOutputCollection = ! is_mapreduce_output_inline($out); - if (! isset($options['readPreference']) && ! is_in_transaction($options)) { - $options['readPreference'] = $this->readPreference; - } - // Check if the out option is inline because we will want to coerce a primary read preference if not if ($hasOutputCollection) { $options['readPreference'] = new ReadPreference(ReadPreference::PRIMARY); + } else { + $options = $this->inheritReadPreference($options); } - $server = select_server($this->manager, $options); - /* A "majority" read concern is not compatible with inline output, so * avoid providing the Collection's read concern if it would conflict. - * - * A read concern is also not compatible with transactions. */ - if (! isset($options['readConcern']) && ! ($hasOutputCollection && $this->readConcern->getLevel() === ReadConcern::MAJORITY) && ! is_in_transaction($options)) { - $options['readConcern'] = $this->readConcern; + if (! $hasOutputCollection || $this->readConcern->getLevel() !== ReadConcern::MAJORITY) { + $options = $this->inheritReadConcern($options); } - if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; - } - - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } + $options = $this->inheritWriteOptions($options); + $options = $this->inheritTypeMap($options); $operation = new MapReduce($this->databaseName, $this->collectionName, $map, $reduce, $out, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -998,19 +871,12 @@ public function rename(string $toCollectionName, ?string $toDatabaseName = null, $toDatabaseName = $this->databaseName; } - if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; - } - - $server = select_server($this->manager, $options); - - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } + $options = $this->inheritWriteOptions($options); + $options = $this->inheritTypeMap($options); $operation = new RenameCollection($this->databaseName, $this->collectionName, $toDatabaseName, $toCollectionName, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -1028,14 +894,12 @@ public function rename(string $toCollectionName, ?string $toDatabaseName = null, */ public function replaceOne($filter, $replacement, array $options = []) { - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } + $options = $this->inheritWriteOptions($options); + $options = $this->inheritCodec($options); $operation = new ReplaceOne($this->databaseName, $this->collectionName, $filter, $replacement, $options); - $server = select_server($this->manager, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -1053,14 +917,11 @@ public function replaceOne($filter, $replacement, array $options = []) */ public function updateMany($filter, $update, array $options = []) { - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } + $options = $this->inheritWriteOptions($options); $operation = new UpdateMany($this->databaseName, $this->collectionName, $filter, $update, $options); - $server = select_server($this->manager, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -1078,14 +939,11 @@ public function updateMany($filter, $update, array $options = []) */ public function updateOne($filter, $update, array $options = []) { - if (! isset($options['writeConcern']) && ! is_in_transaction($options)) { - $options['writeConcern'] = $this->writeConcern; - } + $options = $this->inheritWriteOptions($options); $operation = new UpdateOne($this->databaseName, $this->collectionName, $filter, $update, $options); - $server = select_server($this->manager, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -1099,30 +957,12 @@ public function updateOne($filter, $update, array $options = []) */ public function watch(array $pipeline = [], array $options = []) { - if (! isset($options['readPreference']) && ! is_in_transaction($options)) { - $options['readPreference'] = $this->readPreference; - } - - $server = select_server($this->manager, $options); - - /* Although change streams require a newer version of the server than - * read concerns, perform the usual wire version check before inheriting - * the collection's read concern. In the event that the server is too - * old, this makes it more likely that users will encounter an error - * related to change streams being unsupported instead of an - * UnsupportedException regarding use of the "readConcern" option from - * the Aggregate operation class. */ - if (! isset($options['readConcern']) && ! is_in_transaction($options)) { - $options['readConcern'] = $this->readConcern; - } - - if (! isset($options['typeMap'])) { - $options['typeMap'] = $this->typeMap; - } + $options = $this->inheritReadOptions($options); + $options = $this->inheritCodecOrTypeMap($options); $operation = new Watch($this->manager, $this->databaseName, $this->collectionName, $pipeline, $options); - return $operation->execute($server); + return $operation->execute(select_server($this->manager, $options)); } /** @@ -1136,6 +976,7 @@ public function watch(array $pipeline = [], array $options = []) public function withOptions(array $options = []) { $options += [ + 'codec' => $this->codec, 'readConcern' => $this->readConcern, 'readPreference' => $this->readPreference, 'typeMap' => $this->typeMap, @@ -1144,4 +985,87 @@ public function withOptions(array $options = []) return new Collection($this->manager, $this->databaseName, $this->collectionName, $options); } + + private function inheritCodec(array $options): array + { + // If the options contain a type map, don't inherit anything + if (isset($options['typeMap'])) { + return $options; + } + + if (! array_key_exists('codec', $options)) { + $options['codec'] = $this->codec; + } + + return $options; + } + + private function inheritCodecOrTypeMap(array $options): array + { + // If the options contain a type map, don't inherit anything + if (isset($options['typeMap'])) { + return $options; + } + + // If this collection does not use a codec, or if a codec was explicitly + // defined in the options, only inherit the type map (if possible) + if (! $this->codec || array_key_exists('codec', $options)) { + return $this->inheritTypeMap($options); + } + + // At this point, we know that we use a codec and the options array did + // not explicitly contain a codec, so we can inherit ours + $options['codec'] = $this->codec; + + return $options; + } + + private function inheritReadConcern(array $options): array + { + // ReadConcern and ReadPreference may not change within a transaction + if (! isset($options['readConcern']) && ! is_in_transaction($options)) { + $options['readConcern'] = $this->readConcern; + } + + return $options; + } + + private function inheritReadOptions(array $options): array + { + $options = $this->inheritReadConcern($options); + + return $this->inheritReadPreference($options); + } + + private function inheritReadPreference(array $options): array + { + // ReadConcern and ReadPreference may not change within a transaction + if (! isset($options['readPreference']) && ! is_in_transaction($options)) { + $options['readPreference'] = $this->readPreference; + } + + return $options; + } + + private function inheritTypeMap(array $options): array + { + // Only inherit the type map if no codec is used + if (! isset($options['typeMap']) && ! isset($options['codec'])) { + $options['typeMap'] = $this->typeMap; + } + + return $options; + } + + private function inheritWriteOptions(array $options): array + { + // WriteConcern may not change within a transaction + if (! is_in_transaction($options)) { + if (! isset($options['writeConcern'])) { + $options['writeConcern'] = $this->writeConcern; + } + } + + return $options; + } } diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php index fd043651c..7091e82af 100644 --- a/src/Exception/InvalidArgumentException.php +++ b/src/Exception/InvalidArgumentException.php @@ -29,6 +29,11 @@ class InvalidArgumentException extends DriverInvalidArgumentException implements Exception { + public static function cannotCombineCodecAndTypeMap(): self + { + return new self('Cannot provide both "codec" and "typeMap" options'); + } + /** * Thrown when an argument or option is expected to be a document. * diff --git a/src/GridFS/Bucket.php b/src/GridFS/Bucket.php index f571586ed..aeba6708f 100644 --- a/src/GridFS/Bucket.php +++ b/src/GridFS/Bucket.php @@ -17,8 +17,9 @@ namespace MongoDB\GridFS; +use Iterator; use MongoDB\Collection; -use MongoDB\Driver\Cursor; +use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; use MongoDB\Driver\Manager; use MongoDB\Driver\ReadConcern; @@ -301,7 +302,7 @@ public function drop() * @see Find::__construct() for supported options * @param array|object $filter Query by which to filter documents * @param array $options Additional options - * @return Cursor + * @return CursorInterface&Iterator * @throws UnsupportedException if options are not supported by the selected server * @throws InvalidArgumentException for parameter/option parsing errors * @throws DriverRuntimeException for other driver errors (e.g. connection errors) diff --git a/src/GridFS/CollectionWrapper.php b/src/GridFS/CollectionWrapper.php index 11ad145a6..16ef8343a 100644 --- a/src/GridFS/CollectionWrapper.php +++ b/src/GridFS/CollectionWrapper.php @@ -18,8 +18,9 @@ namespace MongoDB\GridFS; use ArrayIterator; +use Iterator; use MongoDB\Collection; -use MongoDB\Driver\Cursor; +use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Manager; use MongoDB\Driver\ReadPreference; use MongoDB\Exception\InvalidArgumentException; @@ -104,8 +105,9 @@ public function dropCollections(): void * * @param mixed $id File ID * @param integer $fromChunk Starting chunk (inclusive) + * @return CursorInterface&Iterator */ - public function findChunksByFileId($id, int $fromChunk = 0): Cursor + public function findChunksByFileId($id, int $fromChunk = 0) { return $this->chunksCollection->find( [ @@ -182,7 +184,7 @@ public function findFileById($id): ?object * @see Find::__construct() for supported options * @param array|object $filter Query by which to filter documents * @param array $options Additional options - * @return Cursor + * @return CursorInterface&Iterator */ public function findFiles($filter, array $options = []) { diff --git a/src/GridFS/ReadableStream.php b/src/GridFS/ReadableStream.php index 577384f2b..a0664e821 100644 --- a/src/GridFS/ReadableStream.php +++ b/src/GridFS/ReadableStream.php @@ -17,8 +17,9 @@ namespace MongoDB\GridFS; +use Iterator; use MongoDB\BSON\Binary; -use MongoDB\Driver\Cursor; +use MongoDB\Driver\CursorInterface; use MongoDB\Exception\InvalidArgumentException; use MongoDB\GridFS\Exception\CorruptFileException; @@ -47,7 +48,8 @@ class ReadableStream private int $chunkOffset = 0; - private ?Cursor $chunksIterator = null; + /** @var (CursorInterface&Iterator)|null */ + private ?Iterator $chunksIterator = null; private CollectionWrapper $collectionWrapper; diff --git a/src/Model/ChangeStreamIterator.php b/src/Model/ChangeStreamIterator.php index bf972a163..b41787502 100644 --- a/src/Model/ChangeStreamIterator.php +++ b/src/Model/ChangeStreamIterator.php @@ -17,9 +17,11 @@ namespace MongoDB\Model; +use Iterator; use IteratorIterator; +use MongoDB\BSON\Document; use MongoDB\BSON\Serializable; -use MongoDB\Driver\Cursor; +use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Monitoring\CommandFailedEvent; use MongoDB\Driver\Monitoring\CommandStartedEvent; use MongoDB\Driver\Monitoring\CommandSubscriber; @@ -47,7 +49,7 @@ * * @internal * @template TValue of array|object - * @template-extends IteratorIterator> + * @template-extends IteratorIterator&Iterator> */ class ChangeStreamIterator extends IteratorIterator implements CommandSubscriber { @@ -69,10 +71,18 @@ class ChangeStreamIterator extends IteratorIterator implements CommandSubscriber /** * @internal * @param array|object|null $initialResumeToken - * @psalm-param Cursor $cursor + * @psalm-param CursorInterface&Iterator $cursor */ - public function __construct(Cursor $cursor, int $firstBatchSize, $initialResumeToken, ?object $postBatchResumeToken) + public function __construct(CursorInterface $cursor, int $firstBatchSize, $initialResumeToken, ?object $postBatchResumeToken) { + if (! $cursor instanceof Iterator) { + throw InvalidArgumentException::invalidType( + '$cursor', + $cursor, + CursorInterface::class . '&' . Iterator::class, + ); + } + if (isset($initialResumeToken) && ! is_document($initialResumeToken)) { throw InvalidArgumentException::expectedDocumentType('$initialResumeToken', $initialResumeToken); } @@ -139,11 +149,14 @@ public function current() * iterator. This could be side-stepped due to the class not being final, * but it's very much an invalid use-case. This method can be dropped in 2.0 * once the class is final. + * + * @return CursorInterface&Iterator */ - final public function getInnerIterator(): Cursor + final public function getInnerIterator(): Iterator { $cursor = parent::getInnerIterator(); - assert($cursor instanceof Cursor); + assert($cursor instanceof CursorInterface); + assert($cursor instanceof Iterator); return $cursor; } @@ -244,9 +257,17 @@ private function extractResumeToken($document) return $this->extractResumeToken($document->bsonSerialize()); } - $resumeToken = is_array($document) - ? ($document['_id'] ?? null) - : ($document->_id ?? null); + if ($document instanceof Document) { + $resumeToken = $document->get('_id'); + + if ($resumeToken instanceof Document) { + $resumeToken = $resumeToken->toPHP(); + } + } else { + $resumeToken = is_array($document) + ? ($document['_id'] ?? null) + : ($document->_id ?? null); + } if (! isset($resumeToken)) { $this->isValid = false; diff --git a/src/Model/CodecCursor.php b/src/Model/CodecCursor.php new file mode 100644 index 000000000..b3b5061ae --- /dev/null +++ b/src/Model/CodecCursor.php @@ -0,0 +1,131 @@ + + * @template-implements Iterator + */ +class CodecCursor implements CursorInterface, Iterator +{ + private const TYPEMAP = ['root' => 'bson']; + + private Cursor $cursor; + + /** @var DocumentCodec */ + private DocumentCodec $codec; + + /** @var TValue|null */ + private ?object $current = null; + + /** @return TValue */ + public function current(): ?object + { + if (! $this->current && $this->valid()) { + $value = $this->cursor->current(); + assert($value instanceof Document); + $this->current = $this->codec->decode($value); + } + + return $this->current; + } + + /** + * @template NativeClass of Object + * @param DocumentCodec $codec + * @return self + */ + public static function fromCursor(Cursor $cursor, DocumentCodec $codec): self + { + $cursor->setTypeMap(self::TYPEMAP); + + return new self($cursor, $codec); + } + + public function getId(): CursorId + { + return $this->cursor->getId(); + } + + public function getServer(): Server + { + return $this->cursor->getServer(); + } + + public function isDead(): bool + { + return $this->cursor->isDead(); + } + + public function key(): int + { + return $this->cursor->key(); + } + + public function next(): void + { + $this->current = null; + $this->cursor->next(); + } + + public function rewind(): void + { + $this->current = null; + $this->cursor->rewind(); + } + + public function setTypeMap(array $typemap): void + { + // Not supported + trigger_error(sprintf('Discarding type map for %s', __METHOD__), E_USER_WARNING); + } + + /** @return array */ + public function toArray(): array + { + return iterator_to_array($this); + } + + public function valid(): bool + { + return $this->cursor->valid(); + } + + /** @param DocumentCodec $codec */ + private function __construct(Cursor $cursor, DocumentCodec $codec) + { + $this->cursor = $cursor; + $this->codec = $codec; + } +} diff --git a/src/Operation/Aggregate.php b/src/Operation/Aggregate.php index 2678b7c73..0b49748de 100644 --- a/src/Operation/Aggregate.php +++ b/src/Operation/Aggregate.php @@ -17,8 +17,11 @@ namespace MongoDB\Operation; +use Iterator; +use MongoDB\Codec\DocumentCodec; use MongoDB\Driver\Command; use MongoDB\Driver\Cursor; +use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; use MongoDB\Driver\ReadConcern; use MongoDB\Driver\ReadPreference; @@ -28,6 +31,7 @@ use MongoDB\Exception\InvalidArgumentException; use MongoDB\Exception\UnexpectedValueException; use MongoDB\Exception\UnsupportedException; +use MongoDB\Model\CodecCursor; use stdClass; use function is_array; @@ -72,6 +76,9 @@ class Aggregate implements Executable, Explainable * circumvent document level validation. This only applies when an $out * or $merge stage is specified. * + * * codec (MongoDB\Codec\DocumentCodec): Codec used to decode documents + * from BSON to PHP objects. + * * * collation (document): Collation specification. * * * comment (mixed): BSON value to attach as a comment to this command. @@ -137,6 +144,10 @@ public function __construct(string $databaseName, ?string $collectionName, array throw InvalidArgumentException::invalidType('"bypassDocumentValidation" option', $options['bypassDocumentValidation'], 'boolean'); } + if (isset($options['codec']) && ! $options['codec'] instanceof DocumentCodec) { + throw InvalidArgumentException::invalidType('"codec" option', $options['codec'], DocumentCodec::class); + } + if (isset($options['collation']) && ! is_document($options['collation'])) { throw InvalidArgumentException::expectedDocumentType('"collation" option', $options['collation']); } @@ -193,6 +204,10 @@ public function __construct(string $databaseName, ?string $collectionName, array unset($options['writeConcern']); } + if (isset($options['codec']) && isset($options['typeMap'])) { + throw InvalidArgumentException::cannotCombineCodecAndTypeMap(); + } + $this->isWrite = is_last_pipeline_operator_write($pipeline) && ! ($options['explain'] ?? false); if ($this->isWrite) { @@ -213,7 +228,7 @@ public function __construct(string $databaseName, ?string $collectionName, array * Execute the operation. * * @see Executable::execute() - * @return Cursor + * @return CursorInterface&Iterator * @throws UnexpectedValueException if the command response was malformed * @throws UnsupportedException if read concern or write concern is used and unsupported * @throws DriverRuntimeException for other driver errors (e.g. connection errors) @@ -238,6 +253,10 @@ public function execute(Server $server) $cursor = $this->executeCommand($server, $command); + if (isset($this->options['codec'])) { + return CodecCursor::fromCursor($cursor, $this->options['codec']); + } + if (isset($this->options['typeMap'])) { $cursor->setTypeMap($this->options['typeMap']); } diff --git a/src/Operation/BulkWrite.php b/src/Operation/BulkWrite.php index 495bdb819..4457d9930 100644 --- a/src/Operation/BulkWrite.php +++ b/src/Operation/BulkWrite.php @@ -18,6 +18,7 @@ namespace MongoDB\Operation; use MongoDB\BulkWriteResult; +use MongoDB\Codec\DocumentCodec; use MongoDB\Driver\BulkWrite as Bulk; use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; use MongoDB\Driver\Server; @@ -100,6 +101,10 @@ class BulkWrite implements Executable * * bypassDocumentValidation (boolean): If true, allows the write to * circumvent document level validation. The default is false. * + * * codec (MongoDB\Codec\DocumentCodec): Codec used to decode documents + * from BSON to PHP objects. This option is also used to encode PHP + * objects into BSON for insertOne and replaceOne operations. + * * * comment (mixed): BSON value to attach as a comment to this command(s) * associated with this bulk write. * @@ -134,6 +139,139 @@ public function __construct(string $databaseName, string $collectionName, array throw new InvalidArgumentException('$operations is not a list'); } + $options += ['ordered' => true]; + + if (isset($options['bypassDocumentValidation']) && ! is_bool($options['bypassDocumentValidation'])) { + throw InvalidArgumentException::invalidType('"bypassDocumentValidation" option', $options['bypassDocumentValidation'], 'boolean'); + } + + if (isset($options['codec']) && ! $options['codec'] instanceof DocumentCodec) { + throw InvalidArgumentException::invalidType('"codec" option', $options['codec'], DocumentCodec::class); + } + + if (! is_bool($options['ordered'])) { + throw InvalidArgumentException::invalidType('"ordered" option', $options['ordered'], 'boolean'); + } + + if (isset($options['session']) && ! $options['session'] instanceof Session) { + throw InvalidArgumentException::invalidType('"session" option', $options['session'], Session::class); + } + + if (isset($options['writeConcern']) && ! $options['writeConcern'] instanceof WriteConcern) { + throw InvalidArgumentException::invalidType('"writeConcern" option', $options['writeConcern'], WriteConcern::class); + } + + if (isset($options['let']) && ! is_document($options['let'])) { + throw InvalidArgumentException::expectedDocumentType('"let" option', $options['let']); + } + + if (isset($options['bypassDocumentValidation']) && ! $options['bypassDocumentValidation']) { + unset($options['bypassDocumentValidation']); + } + + if (isset($options['writeConcern']) && $options['writeConcern']->isDefault()) { + unset($options['writeConcern']); + } + + $this->databaseName = $databaseName; + $this->collectionName = $collectionName; + $this->operations = $this->validateOperations($operations, $options['codec'] ?? null); + $this->options = $options; + } + + /** + * Execute the operation. + * + * @see Executable::execute() + * @return BulkWriteResult + * @throws UnsupportedException if write concern is used and unsupported + * @throws DriverRuntimeException for other driver errors (e.g. connection errors) + */ + public function execute(Server $server) + { + $inTransaction = isset($this->options['session']) && $this->options['session']->isInTransaction(); + if ($inTransaction && isset($this->options['writeConcern'])) { + throw UnsupportedException::writeConcernNotSupportedInTransaction(); + } + + $bulk = new Bulk($this->createBulkWriteOptions()); + $insertedIds = []; + + foreach ($this->operations as $i => $operation) { + $type = key($operation); + $args = current($operation); + + switch ($type) { + case self::DELETE_MANY: + case self::DELETE_ONE: + $bulk->delete($args[0], $args[1]); + break; + + case self::INSERT_ONE: + $insertedIds[$i] = $bulk->insert($args[0]); + break; + + case self::UPDATE_MANY: + case self::UPDATE_ONE: + case self::REPLACE_ONE: + $bulk->update($args[0], $args[1], $args[2]); + break; + } + } + + $writeResult = $server->executeBulkWrite($this->databaseName . '.' . $this->collectionName, $bulk, $this->createExecuteOptions()); + + return new BulkWriteResult($writeResult, $insertedIds); + } + + /** + * Create options for constructing the bulk write. + * + * @see https://php.net/manual/en/mongodb-driver-bulkwrite.construct.php + */ + private function createBulkWriteOptions(): array + { + $options = ['ordered' => $this->options['ordered']]; + + foreach (['bypassDocumentValidation', 'comment'] as $option) { + if (isset($this->options[$option])) { + $options[$option] = $this->options[$option]; + } + } + + if (isset($this->options['let'])) { + $options['let'] = (object) $this->options['let']; + } + + return $options; + } + + /** + * Create options for executing the bulk write. + * + * @see https://php.net/manual/en/mongodb-driver-server.executebulkwrite.php + */ + private function createExecuteOptions(): array + { + $options = []; + + if (isset($this->options['session'])) { + $options['session'] = $this->options['session']; + } + + if (isset($this->options['writeConcern'])) { + $options['writeConcern'] = $this->options['writeConcern']; + } + + return $options; + } + + /** + * @param array[] $operations + * @return array[] + */ + private function validateOperations(array $operations, ?DocumentCodec $codec): array + { foreach ($operations as $i => $operation) { if (! is_array($operation)) { throw InvalidArgumentException::invalidType(sprintf('$operations[%d]', $i), $operation, 'array'); @@ -156,6 +294,12 @@ public function __construct(string $databaseName, string $collectionName, array switch ($type) { case self::INSERT_ONE: + // $args[0] was already validated above. Since DocumentCodec::encode will always return a Document + // instance, there is no need to re-validate the returned value here. + if ($codec) { + $operations[$i][$type][0] = $codec->encode($args[0]); + } + break; case self::DELETE_MANY: @@ -183,6 +327,10 @@ public function __construct(string $databaseName, string $collectionName, array throw new InvalidArgumentException(sprintf('Missing second argument for $operations[%d]["%s"]', $i, $type)); } + if ($codec) { + $operations[$i][$type][1] = $codec->encode($args[1]); + } + if (! is_document($args[1])) { throw InvalidArgumentException::expectedDocumentType(sprintf('$operations[%d]["%s"][1]', $i, $type), $args[1]); } @@ -265,125 +413,6 @@ public function __construct(string $databaseName, string $collectionName, array } } - $options += ['ordered' => true]; - - if (isset($options['bypassDocumentValidation']) && ! is_bool($options['bypassDocumentValidation'])) { - throw InvalidArgumentException::invalidType('"bypassDocumentValidation" option', $options['bypassDocumentValidation'], 'boolean'); - } - - if (! is_bool($options['ordered'])) { - throw InvalidArgumentException::invalidType('"ordered" option', $options['ordered'], 'boolean'); - } - - if (isset($options['session']) && ! $options['session'] instanceof Session) { - throw InvalidArgumentException::invalidType('"session" option', $options['session'], Session::class); - } - - if (isset($options['writeConcern']) && ! $options['writeConcern'] instanceof WriteConcern) { - throw InvalidArgumentException::invalidType('"writeConcern" option', $options['writeConcern'], WriteConcern::class); - } - - if (isset($options['let']) && ! is_document($options['let'])) { - throw InvalidArgumentException::expectedDocumentType('"let" option', $options['let']); - } - - if (isset($options['bypassDocumentValidation']) && ! $options['bypassDocumentValidation']) { - unset($options['bypassDocumentValidation']); - } - - if (isset($options['writeConcern']) && $options['writeConcern']->isDefault()) { - unset($options['writeConcern']); - } - - $this->databaseName = $databaseName; - $this->collectionName = $collectionName; - $this->operations = $operations; - $this->options = $options; - } - - /** - * Execute the operation. - * - * @see Executable::execute() - * @return BulkWriteResult - * @throws UnsupportedException if write concern is used and unsupported - * @throws DriverRuntimeException for other driver errors (e.g. connection errors) - */ - public function execute(Server $server) - { - $inTransaction = isset($this->options['session']) && $this->options['session']->isInTransaction(); - if ($inTransaction && isset($this->options['writeConcern'])) { - throw UnsupportedException::writeConcernNotSupportedInTransaction(); - } - - $bulk = new Bulk($this->createBulkWriteOptions()); - $insertedIds = []; - - foreach ($this->operations as $i => $operation) { - $type = key($operation); - $args = current($operation); - - switch ($type) { - case self::DELETE_MANY: - case self::DELETE_ONE: - $bulk->delete($args[0], $args[1]); - break; - - case self::INSERT_ONE: - $insertedIds[$i] = $bulk->insert($args[0]); - break; - - case self::REPLACE_ONE: - case self::UPDATE_MANY: - case self::UPDATE_ONE: - $bulk->update($args[0], $args[1], $args[2]); - } - } - - $writeResult = $server->executeBulkWrite($this->databaseName . '.' . $this->collectionName, $bulk, $this->createExecuteOptions()); - - return new BulkWriteResult($writeResult, $insertedIds); - } - - /** - * Create options for constructing the bulk write. - * - * @see https://php.net/manual/en/mongodb-driver-bulkwrite.construct.php - */ - private function createBulkWriteOptions(): array - { - $options = ['ordered' => $this->options['ordered']]; - - foreach (['bypassDocumentValidation', 'comment'] as $option) { - if (isset($this->options[$option])) { - $options[$option] = $this->options[$option]; - } - } - - if (isset($this->options['let'])) { - $options['let'] = (object) $this->options['let']; - } - - return $options; - } - - /** - * Create options for executing the bulk write. - * - * @see https://php.net/manual/en/mongodb-driver-server.executebulkwrite.php - */ - private function createExecuteOptions(): array - { - $options = []; - - if (isset($this->options['session'])) { - $options['session'] = $this->options['session']; - } - - if (isset($this->options['writeConcern'])) { - $options['writeConcern'] = $this->options['writeConcern']; - } - - return $options; + return $operations; } } diff --git a/src/Operation/Find.php b/src/Operation/Find.php index 622b5a540..23b33d482 100644 --- a/src/Operation/Find.php +++ b/src/Operation/Find.php @@ -17,7 +17,9 @@ namespace MongoDB\Operation; -use MongoDB\Driver\Cursor; +use Iterator; +use MongoDB\Codec\DocumentCodec; +use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; use MongoDB\Driver\Query; use MongoDB\Driver\ReadConcern; @@ -26,6 +28,7 @@ use MongoDB\Driver\Session; use MongoDB\Exception\InvalidArgumentException; use MongoDB\Exception\UnsupportedException; +use MongoDB\Model\CodecCursor; use function is_array; use function is_bool; @@ -74,6 +77,9 @@ class Find implements Executable, Explainable * * * batchSize (integer): The number of documents to return per batch. * + * * codec (MongoDB\Codec\DocumentCodec): Codec used to decode documents + * from BSON to PHP objects. + * * * collation (document): Collation specification. * * * comment (mixed): BSON value to attach as a comment to this command. @@ -176,6 +182,10 @@ public function __construct(string $databaseName, string $collectionName, $filte throw InvalidArgumentException::invalidType('"batchSize" option', $options['batchSize'], 'integer'); } + if (isset($options['codec']) && ! $options['codec'] instanceof DocumentCodec) { + throw InvalidArgumentException::invalidType('"codec" option', $options['codec'], DocumentCodec::class); + } + if (isset($options['collation']) && ! is_document($options['collation'])) { throw InvalidArgumentException::expectedDocumentType('"collation" option', $options['collation']); } @@ -290,6 +300,10 @@ public function __construct(string $databaseName, string $collectionName, $filte trigger_error('The "maxScan" option is deprecated and will be removed in a future release', E_USER_DEPRECATED); } + if (isset($options['codec']) && isset($options['typeMap'])) { + throw InvalidArgumentException::cannotCombineCodecAndTypeMap(); + } + $this->databaseName = $databaseName; $this->collectionName = $collectionName; $this->filter = $filter; @@ -300,7 +314,7 @@ public function __construct(string $databaseName, string $collectionName, $filte * Execute the operation. * * @see Executable::execute() - * @return Cursor + * @return CursorInterface&Iterator * @throws UnsupportedException if read concern is used and unsupported * @throws DriverRuntimeException for other driver errors (e.g. connection errors) */ @@ -313,6 +327,10 @@ public function execute(Server $server) $cursor = $server->executeQuery($this->databaseName . '.' . $this->collectionName, new Query($this->filter, $this->createQueryOptions()), $this->createExecuteOptions()); + if (isset($this->options['codec'])) { + return CodecCursor::fromCursor($cursor, $this->options['codec']); + } + if (isset($this->options['typeMap'])) { $cursor->setTypeMap($this->options['typeMap']); } diff --git a/src/Operation/FindAndModify.php b/src/Operation/FindAndModify.php index 8f23d1237..300f4e97a 100644 --- a/src/Operation/FindAndModify.php +++ b/src/Operation/FindAndModify.php @@ -17,6 +17,8 @@ namespace MongoDB\Operation; +use MongoDB\BSON\Document; +use MongoDB\Codec\DocumentCodec; use MongoDB\Driver\Command; use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; use MongoDB\Driver\Server; @@ -27,6 +29,7 @@ use MongoDB\Exception\UnsupportedException; use function array_key_exists; +use function assert; use function current; use function is_array; use function is_bool; @@ -68,6 +71,9 @@ class FindAndModify implements Executable, Explainable * * arrayFilters (document array): A set of filters specifying to which * array elements an update should apply. * + * * codec (MongoDB\Codec\DocumentCodec): Codec used to decode documents + * from BSON to PHP objects. + * * * collation (document): Collation specification. * * * comment (mixed): BSON value to attach as a comment to this command. @@ -137,6 +143,10 @@ public function __construct(string $databaseName, string $collectionName, array throw InvalidArgumentException::invalidType('"bypassDocumentValidation" option', $options['bypassDocumentValidation'], 'boolean'); } + if (isset($options['codec']) && ! $options['codec'] instanceof DocumentCodec) { + throw InvalidArgumentException::invalidType('"codec" option', $options['codec'], DocumentCodec::class); + } + if (isset($options['collation']) && ! is_document($options['collation'])) { throw InvalidArgumentException::expectedDocumentType('"collation" option', $options['collation']); } @@ -205,6 +215,10 @@ public function __construct(string $databaseName, string $collectionName, array unset($options['writeConcern']); } + if (isset($options['codec']) && isset($options['typeMap'])) { + throw InvalidArgumentException::cannotCombineCodecAndTypeMap(); + } + $this->databaseName = $databaseName; $this->collectionName = $collectionName; $this->options = $options; @@ -243,6 +257,16 @@ public function execute(Server $server) $cursor = $server->executeWriteCommand($this->databaseName, new Command($this->createCommandDocument()), $this->createOptions()); + if (isset($this->options['codec'])) { + $cursor->setTypeMap(['root' => 'bson']); + $result = current($cursor->toArray()); + assert($result instanceof Document); + + $value = $result->get('value'); + + return $value === null ? $value : $this->options['codec']->decode($value); + } + if (isset($this->options['typeMap'])) { $cursor->setTypeMap(create_field_path_type_map($this->options['typeMap'], 'value')); } diff --git a/src/Operation/FindOne.php b/src/Operation/FindOne.php index 8d6430058..48f931fab 100644 --- a/src/Operation/FindOne.php +++ b/src/Operation/FindOne.php @@ -40,6 +40,9 @@ class FindOne implements Executable, Explainable * * Supported options: * + * * codec (MongoDB\Codec\DocumentCodec): Codec used to decode documents + * from BSON to PHP objects. + * * * collation (document): Collation specification. * * * comment (mixed): BSON value to attach as a comment to this command. diff --git a/src/Operation/FindOneAndDelete.php b/src/Operation/FindOneAndDelete.php index 39381b1fb..cae2a1788 100644 --- a/src/Operation/FindOneAndDelete.php +++ b/src/Operation/FindOneAndDelete.php @@ -39,6 +39,9 @@ class FindOneAndDelete implements Executable, Explainable * * Supported options: * + * * codec (MongoDB\Codec\DocumentCodec): Codec used to decode documents + * from BSON to PHP objects. + * * * collation (document): Collation specification. * * * comment (mixed): BSON value to attach as a comment to this command. diff --git a/src/Operation/FindOneAndReplace.php b/src/Operation/FindOneAndReplace.php index 34fe6a2d8..20412d645 100644 --- a/src/Operation/FindOneAndReplace.php +++ b/src/Operation/FindOneAndReplace.php @@ -17,6 +17,7 @@ namespace MongoDB\Operation; +use MongoDB\Codec\DocumentCodec; use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; use MongoDB\Driver\Server; use MongoDB\Exception\InvalidArgumentException; @@ -49,6 +50,9 @@ class FindOneAndReplace implements Executable, Explainable * * bypassDocumentValidation (boolean): If true, allows the write to * circumvent document level validation. * + * * codec (MongoDB\Codec\DocumentCodec): Codec used to decode documents + * from BSON to PHP objects. + * * * collation (document): Collation specification. * * * comment (mixed): BSON value to attach as a comment to this command. @@ -104,21 +108,8 @@ public function __construct(string $databaseName, string $collectionName, $filte throw InvalidArgumentException::expectedDocumentType('$filter', $filter); } - if (! is_document($replacement)) { - throw InvalidArgumentException::expectedDocumentType('$replacement', $replacement); - } - - // Treat empty arrays as replacement documents for BC - if ($replacement === []) { - $replacement = (object) $replacement; - } - - if (is_first_key_operator($replacement)) { - throw new InvalidArgumentException('First key in $replacement is an update operator'); - } - - if (is_pipeline($replacement, true /* allowEmpty */)) { - throw new InvalidArgumentException('$replacement is an update pipeline'); + if (isset($options['codec']) && ! $options['codec'] instanceof DocumentCodec) { + throw InvalidArgumentException::invalidType('"codec" option', $options['codec'], DocumentCodec::class); } if (isset($options['projection']) && ! is_document($options['projection'])) { @@ -147,6 +138,8 @@ public function __construct(string $databaseName, string $collectionName, $filte unset($options['projection'], $options['returnDocument']); + $replacement = $this->validateReplacement($replacement, $options['codec'] ?? null); + $this->findAndModify = new FindAndModify( $databaseName, $collectionName, @@ -177,4 +170,34 @@ public function getCommandDocument() { return $this->findAndModify->getCommandDocument(); } + + /** + * @param array|object $replacement + * @return array|object + */ + private function validateReplacement($replacement, ?DocumentCodec $codec) + { + if (isset($codec)) { + $replacement = $codec->encode($replacement); + } + + if (! is_document($replacement)) { + throw InvalidArgumentException::expectedDocumentType('$replacement', $replacement); + } + + // Treat empty arrays as replacement documents for BC + if ($replacement === []) { + $replacement = (object) $replacement; + } + + if (is_first_key_operator($replacement)) { + throw new InvalidArgumentException('First key in $replacement is an update operator'); + } + + if (is_pipeline($replacement, true /* allowEmpty */)) { + throw new InvalidArgumentException('$replacement is an update pipeline'); + } + + return $replacement; + } } diff --git a/src/Operation/FindOneAndUpdate.php b/src/Operation/FindOneAndUpdate.php index ae8374ba4..783888b1f 100644 --- a/src/Operation/FindOneAndUpdate.php +++ b/src/Operation/FindOneAndUpdate.php @@ -54,6 +54,9 @@ class FindOneAndUpdate implements Executable, Explainable * * bypassDocumentValidation (boolean): If true, allows the write to * circumvent document level validation. * + * * codec (MongoDB\Codec\DocumentCodec): Codec used to decode documents + * from BSON to PHP objects. + * * * collation (document): Collation specification. * * * comment (mixed): BSON value to attach as a comment to this command. diff --git a/src/Operation/InsertMany.php b/src/Operation/InsertMany.php index 1d5cc981b..42b3a344f 100644 --- a/src/Operation/InsertMany.php +++ b/src/Operation/InsertMany.php @@ -17,6 +17,7 @@ namespace MongoDB\Operation; +use MongoDB\Codec\DocumentCodec; use MongoDB\Driver\BulkWrite as Bulk; use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; use MongoDB\Driver\Server; @@ -56,6 +57,9 @@ class InsertMany implements Executable * * bypassDocumentValidation (boolean): If true, allows the write to * circumvent document level validation. * + * * codec (MongoDB\Codec\DocumentCodec): Codec used to encode PHP objects + * into BSON. + * * * comment (mixed): BSON value to attach as a comment to the command(s) * associated with this insert. * @@ -69,34 +73,24 @@ class InsertMany implements Executable * * * writeConcern (MongoDB\Driver\WriteConcern): Write concern. * - * @param string $databaseName Database name - * @param string $collectionName Collection name - * @param array[]|object[] $documents List of documents to insert - * @param array $options Command options + * @param string $databaseName Database name + * @param string $collectionName Collection name + * @param list $documents List of documents to insert + * @param array $options Command options * @throws InvalidArgumentException for parameter/option parsing errors */ public function __construct(string $databaseName, string $collectionName, array $documents, array $options = []) { - if (empty($documents)) { - throw new InvalidArgumentException('$documents is empty'); - } - - if (! array_is_list($documents)) { - throw new InvalidArgumentException('$documents is not a list'); - } - - foreach ($documents as $i => $document) { - if (! is_document($document)) { - throw InvalidArgumentException::expectedDocumentType(sprintf('$documents[%d]', $i), $document); - } - } - $options += ['ordered' => true]; if (isset($options['bypassDocumentValidation']) && ! is_bool($options['bypassDocumentValidation'])) { throw InvalidArgumentException::invalidType('"bypassDocumentValidation" option', $options['bypassDocumentValidation'], 'boolean'); } + if (isset($options['codec']) && ! $options['codec'] instanceof DocumentCodec) { + throw InvalidArgumentException::invalidType('"codec" option', $options['codec'], DocumentCodec::class); + } + if (! is_bool($options['ordered'])) { throw InvalidArgumentException::invalidType('"ordered" option', $options['ordered'], 'boolean'); } @@ -119,7 +113,7 @@ public function __construct(string $databaseName, string $collectionName, array $this->databaseName = $databaseName; $this->collectionName = $collectionName; - $this->documents = $documents; + $this->documents = $this->validateDocuments($documents, $options['codec'] ?? null); $this->options = $options; } @@ -187,4 +181,31 @@ private function createExecuteOptions(): array return $options; } + + /** + * @param list $documents + * @return list + */ + private function validateDocuments(array $documents, ?DocumentCodec $codec): array + { + if (empty($documents)) { + throw new InvalidArgumentException('$documents is empty'); + } + + if (! array_is_list($documents)) { + throw new InvalidArgumentException('$documents is not a list'); + } + + foreach ($documents as $i => $document) { + if ($codec) { + $document = $documents[$i] = $codec->encode($document); + } + + if (! is_document($document)) { + throw InvalidArgumentException::expectedDocumentType(sprintf('$documents[%d]', $i), $document); + } + } + + return $documents; + } } diff --git a/src/Operation/InsertOne.php b/src/Operation/InsertOne.php index 1b93b4fc5..f745664d7 100644 --- a/src/Operation/InsertOne.php +++ b/src/Operation/InsertOne.php @@ -17,6 +17,7 @@ namespace MongoDB\Operation; +use MongoDB\Codec\DocumentCodec; use MongoDB\Driver\BulkWrite as Bulk; use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; use MongoDB\Driver\Server; @@ -54,6 +55,9 @@ class InsertOne implements Executable * * bypassDocumentValidation (boolean): If true, allows the write to * circumvent document level validation. * + * * codec (MongoDB\Codec\DocumentCodec): Codec used to encode PHP objects + * into BSON. + * * * comment (mixed): BSON value to attach as a comment to this command. * * This is not supported for servers versions < 4.4. @@ -70,14 +74,14 @@ class InsertOne implements Executable */ public function __construct(string $databaseName, string $collectionName, $document, array $options = []) { - if (! is_document($document)) { - throw InvalidArgumentException::expectedDocumentType('$document', $document); - } - if (isset($options['bypassDocumentValidation']) && ! is_bool($options['bypassDocumentValidation'])) { throw InvalidArgumentException::invalidType('"bypassDocumentValidation" option', $options['bypassDocumentValidation'], 'boolean'); } + if (isset($options['codec']) && ! $options['codec'] instanceof DocumentCodec) { + throw InvalidArgumentException::invalidType('"codec" option', $options['codec'], DocumentCodec::class); + } + if (isset($options['session']) && ! $options['session'] instanceof Session) { throw InvalidArgumentException::invalidType('"session" option', $options['session'], Session::class); } @@ -96,7 +100,7 @@ public function __construct(string $databaseName, string $collectionName, $docum $this->databaseName = $databaseName; $this->collectionName = $collectionName; - $this->document = $document; + $this->document = $this->validateDocument($document, $options['codec'] ?? null); $this->options = $options; } @@ -116,6 +120,7 @@ public function execute(Server $server) } $bulk = new Bulk($this->createBulkWriteOptions()); + $insertedId = $bulk->insert($this->document); $writeResult = $server->executeBulkWrite($this->databaseName . '.' . $this->collectionName, $bulk, $this->createExecuteOptions()); @@ -160,4 +165,21 @@ private function createExecuteOptions(): array return $options; } + + /** + * @param array|object $document + * @return array|object + */ + private function validateDocument($document, ?DocumentCodec $codec) + { + if ($codec) { + $document = $codec->encode($document); + } + + if (! is_document($document)) { + throw InvalidArgumentException::expectedDocumentType('$document', $document); + } + + return $document; + } } diff --git a/src/Operation/ReplaceOne.php b/src/Operation/ReplaceOne.php index c56d9c94d..943206761 100644 --- a/src/Operation/ReplaceOne.php +++ b/src/Operation/ReplaceOne.php @@ -17,6 +17,7 @@ namespace MongoDB\Operation; +use MongoDB\Codec\DocumentCodec; use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; use MongoDB\Driver\Server; use MongoDB\Exception\InvalidArgumentException; @@ -45,6 +46,9 @@ class ReplaceOne implements Executable * * bypassDocumentValidation (boolean): If true, allows the write to * circumvent document level validation. * + * * codec (MongoDB\Codec\DocumentCodec): Codec used to encode PHP objects + * into BSON. + * * * collation (document): Collation specification. * * * comment (mixed): BSON value to attach as a comment to this command. @@ -79,28 +83,19 @@ class ReplaceOne implements Executable */ public function __construct(string $databaseName, string $collectionName, $filter, $replacement, array $options = []) { - if (! is_document($replacement)) { - throw InvalidArgumentException::expectedDocumentType('$replacement', $replacement); - } - - // Treat empty arrays as replacement documents for BC - if ($replacement === []) { - $replacement = (object) $replacement; + if (isset($options['codec']) && ! $options['codec'] instanceof DocumentCodec) { + throw InvalidArgumentException::invalidType('"codec" option', $options['codec'], DocumentCodec::class); } - if (is_first_key_operator($replacement)) { - throw new InvalidArgumentException('First key in $replacement is an update operator'); - } - - if (is_pipeline($replacement, true /* allowEmpty */)) { - throw new InvalidArgumentException('$replacement is an update pipeline'); + if (isset($options['codec'], $options['typeMap'])) { + throw InvalidArgumentException::cannotCombineCodecAndTypeMap(); } $this->update = new Update( $databaseName, $collectionName, $filter, - $replacement, + $this->validateReplacement($replacement, $options['codec'] ?? null), ['multi' => false] + $options, ); } @@ -117,4 +112,34 @@ public function execute(Server $server) { return $this->update->execute($server); } + + /** + * @param array|object $replacement + * @return array|object + */ + private function validateReplacement($replacement, ?DocumentCodec $codec) + { + if ($codec) { + $replacement = $codec->encode($replacement); + } + + if (! is_document($replacement)) { + throw InvalidArgumentException::expectedDocumentType('$replacement', $replacement); + } + + // Treat empty arrays as replacement documents for BC + if ($replacement === []) { + $replacement = (object) $replacement; + } + + if (is_first_key_operator($replacement)) { + throw new InvalidArgumentException('First key in $replacement is an update operator'); + } + + if (is_pipeline($replacement, true /* allowEmpty */)) { + throw new InvalidArgumentException('$replacement is an update pipeline'); + } + + return $replacement; + } } diff --git a/src/Operation/Watch.php b/src/Operation/Watch.php index ee1fa0b2a..075c86d70 100644 --- a/src/Operation/Watch.php +++ b/src/Operation/Watch.php @@ -17,9 +17,11 @@ namespace MongoDB\Operation; +use Iterator; use MongoDB\BSON\TimestampInterface; use MongoDB\ChangeStream; -use MongoDB\Driver\Cursor; +use MongoDB\Codec\DocumentCodec; +use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Exception\RuntimeException; use MongoDB\Driver\Manager; use MongoDB\Driver\Monitoring\CommandFailedEvent; @@ -91,6 +93,8 @@ class Watch implements Executable, /* @internal */ CommandSubscriber private ?object $postBatchResumeToken = null; + private ?DocumentCodec $codec; + /** * Constructs an aggregate command for creating a change stream. * @@ -98,6 +102,9 @@ class Watch implements Executable, /* @internal */ CommandSubscriber * * * batchSize (integer): The number of documents to return per batch. * + * * codec (MongoDB\Codec\DocumentCodec): Codec used to decode documents + * from BSON to PHP objects. + * * * collation (document): Specifies a collation. * * * comment (mixed): BSON value to attach as a comment to this command. @@ -199,6 +206,14 @@ public function __construct(Manager $manager, ?string $databaseName, ?string $co 'readPreference' => new ReadPreference(ReadPreference::PRIMARY), ]; + if (isset($options['codec']) && ! $options['codec'] instanceof DocumentCodec) { + throw InvalidArgumentException::invalidType('"codec" option', $options['codec'], DocumentCodec::class); + } + + if (isset($options['codec']) && isset($options['typeMap'])) { + throw InvalidArgumentException::cannotCombineCodecAndTypeMap(); + } + if (array_key_exists('fullDocument', $options) && ! is_string($options['fullDocument'])) { throw InvalidArgumentException::invalidType('"fullDocument" option', $options['fullDocument'], 'string'); } @@ -254,6 +269,7 @@ public function __construct(Manager $manager, ?string $databaseName, ?string $co $this->databaseName = $databaseName; $this->collectionName = $collectionName; $this->pipeline = $pipeline; + $this->codec = $options['codec'] ?? null; $this->aggregate = $this->createAggregate(); } @@ -314,6 +330,7 @@ public function execute(Server $server) return new ChangeStream( $this->createChangeStreamIterator($server), fn ($resumeToken, $hasAdvanced): ChangeStreamIterator => $this->resume($resumeToken, $hasAdvanced), + $this->codec, ); } @@ -348,8 +365,10 @@ private function createChangeStreamIterator(Server $server): ChangeStreamIterato * * The command will be executed using APM so that we can capture data from * its response (e.g. firstBatch size, postBatchResumeToken). + * + * @return CursorInterface&Iterator */ - private function executeAggregate(Server $server): Cursor + private function executeAggregate(Server $server) { addSubscriber($this); diff --git a/tests/Collection/CodecCollectionFunctionalTest.php b/tests/Collection/CodecCollectionFunctionalTest.php new file mode 100644 index 000000000..2eb0c09dd --- /dev/null +++ b/tests/Collection/CodecCollectionFunctionalTest.php @@ -0,0 +1,534 @@ +collection = new Collection( + $this->manager, + $this->getDatabaseName(), + $this->getCollectionName(), + ['codec' => new TestDocumentCodec()], + ); + } + + public static function provideAggregateOptions(): Generator + { + yield 'Default codec' => [ + 'expected' => [ + TestObject::createDecodedForFixture(2), + TestObject::createDecodedForFixture(3), + ], + 'options' => [], + ]; + + yield 'No codec' => [ + 'expected' => [ + self::createFixtureResult(2), + self::createFixtureResult(3), + ], + 'options' => ['codec' => null], + ]; + + yield 'BSON type map' => [ + 'expected' => [ + Document::fromPHP(self::createFixtureResult(2)), + Document::fromPHP(self::createFixtureResult(3)), + ], + 'options' => ['typeMap' => ['root' => 'bson']], + ]; + } + + /** @dataProvider provideAggregateOptions */ + public function testAggregate($expected, $options): void + { + $this->createFixtures(3); + + $cursor = $this->collection->aggregate([['$match' => ['_id' => ['$gt' => 1]]]], $options); + + $this->assertEquals($expected, $cursor->toArray()); + } + + public function testAggregateWithCodecAndTypemap(): void + { + $options = [ + 'codec' => new TestDocumentCodec(), + 'typeMap' => ['root' => 'array', 'document' => 'array'], + ]; + + $this->expectExceptionObject(InvalidArgumentException::cannotCombineCodecAndTypeMap()); + $this->collection->aggregate([['$match' => ['_id' => ['$gt' => 1]]]], $options); + } + + public static function provideBulkWriteOptions(): Generator + { + $replacedObject = TestObject::createDecodedForFixture(3); + $replacedObject->x->foo = 'baz'; + + yield 'Default codec' => [ + 'expected' => [ + TestObject::createDecodedForFixture(1), + TestObject::createDecodedForFixture(2), + $replacedObject, + TestObject::createDecodedForFixture(4), + ], + 'options' => [], + ]; + + $replacedObject = new BSONDocument(['_id' => 3, 'id' => 3, 'x' => new BSONDocument(['foo' => 'baz']), 'decoded' => false]); + $replacedObject->x->foo = 'baz'; + + yield 'No codec' => [ + 'expected' => [ + self::createFixtureResult(1), + self::createFixtureResult(2), + $replacedObject, + self::createObjectFixtureResult(4, true), + ], + 'options' => ['codec' => null], + ]; + + yield 'BSON type map' => [ + 'expected' => [ + Document::fromPHP(self::createFixtureResult(1)), + Document::fromPHP(self::createFixtureResult(2)), + Document::fromPHP($replacedObject), + Document::fromPHP(self::createObjectFixtureResult(4, true)), + ], + 'options' => ['typeMap' => ['root' => 'bson']], + ]; + } + + /** @dataProvider provideBulkWriteOptions */ + public function testBulkWrite($expected, $options): void + { + $this->createFixtures(3); + + $replaceObject = TestObject::createForFixture(3); + $replaceObject->x->foo = 'baz'; + + $operations = [ + ['insertOne' => [TestObject::createForFixture(4)]], + ['replaceOne' => [['_id' => 3], $replaceObject]], + ]; + + $result = $this->collection->bulkWrite($operations, $options); + + $this->assertInstanceOf(BulkWriteResult::class, $result); + $this->assertSame(1, $result->getInsertedCount()); + $this->assertSame(1, $result->getMatchedCount()); + $this->assertSame(1, $result->getModifiedCount()); + + // Extract inserted ID when not using codec as it's an automatically generated ObjectId + if ($expected[3] instanceof BSONDocument && $expected[3]->_id === null) { + $expected[3]->_id = $result->getInsertedIds()[0]; + } + + if ($expected[3] instanceof Document && $expected[3]->get('_id') === null) { + $inserted = $expected[3]->toPHP(); + $inserted->_id = $result->getInsertedIds()[0]; + $expected[3] = Document::fromPHP($inserted); + } + + $this->assertEquals( + $expected, + $this->collection->find([], $options)->toArray(), + ); + } + + public function provideFindOneAndModifyOptions(): Generator + { + yield 'Default codec' => [ + 'expected' => TestObject::createDecodedForFixture(1), + 'options' => [], + ]; + + yield 'No codec' => [ + 'expected' => self::createFixtureResult(1), + 'options' => ['codec' => null], + ]; + + yield 'BSON type map' => [ + 'expected' => Document::fromPHP(self::createFixtureResult(1)), + 'options' => ['typeMap' => ['root' => 'bson']], + ]; + } + + /** @dataProvider provideFindOneAndModifyOptions */ + public function testFindOneAndDelete($expected, $options): void + { + $this->createFixtures(1); + + $result = $this->collection->findOneAndDelete(['_id' => 1], $options); + + self::assertEquals($expected, $result); + } + + public function testFindOneAndDeleteWithCodecAndTypemap(): void + { + $options = [ + 'codec' => new TestDocumentCodec(), + 'typeMap' => ['root' => 'array', 'document' => 'array'], + ]; + + $this->expectExceptionObject(InvalidArgumentException::cannotCombineCodecAndTypeMap()); + $this->collection->findOneAndDelete(['_id' => 1], $options); + } + + /** @dataProvider provideFindOneAndModifyOptions */ + public function testFindOneAndUpdate($expected, $options): void + { + $this->createFixtures(1); + + $result = $this->collection->findOneAndUpdate(['_id' => 1], ['$set' => ['x.foo' => 'baz']], $options); + + self::assertEquals($expected, $result); + } + + public function testFindOneAndUpdateWithCodecAndTypemap(): void + { + $options = [ + 'codec' => new TestDocumentCodec(), + 'typeMap' => ['root' => 'array', 'document' => 'array'], + ]; + + $this->expectExceptionObject(InvalidArgumentException::cannotCombineCodecAndTypeMap()); + $this->collection->findOneAndUpdate(['_id' => 1], ['$set' => ['x.foo' => 'baz']], $options); + } + + public static function provideFindOneAndReplaceOptions(): Generator + { + $replacedObject = TestObject::createDecodedForFixture(1); + $replacedObject->x->foo = 'baz'; + + yield 'Default codec' => [ + 'expected' => $replacedObject, + 'options' => [], + ]; + + $replacedObject = self::createObjectFixtureResult(1); + $replacedObject->x->foo = 'baz'; + + yield 'No codec' => [ + 'expected' => $replacedObject, + 'options' => ['codec' => null], + ]; + + yield 'BSON type map' => [ + 'expected' => Document::FromPHP($replacedObject), + 'options' => ['typeMap' => ['root' => 'bson']], + ]; + } + + /** @dataProvider provideFindOneAndReplaceOptions */ + public function testFindOneAndReplace($expected, $options): void + { + $this->createFixtures(1); + + $replaceObject = TestObject::createForFixture(1); + $replaceObject->x->foo = 'baz'; + + $result = $this->collection->findOneAndReplace( + ['_id' => 1], + $replaceObject, + $options + ['returnDocument' => FindOneAndReplace::RETURN_DOCUMENT_AFTER], + ); + + self::assertEquals($expected, $result); + } + + public function testFindOneAndReplaceWithCodecAndTypemap(): void + { + $options = [ + 'codec' => new TestDocumentCodec(), + 'typeMap' => ['root' => 'array', 'document' => 'array'], + ]; + + $this->expectExceptionObject(InvalidArgumentException::cannotCombineCodecAndTypeMap()); + $this->collection->findOneAndReplace(['_id' => 1], TestObject::createForFixture(1), $options); + } + + public static function provideFindOptions(): Generator + { + yield 'Default codec' => [ + 'expected' => [ + TestObject::createDecodedForFixture(1), + TestObject::createDecodedForFixture(2), + TestObject::createDecodedForFixture(3), + ], + 'options' => [], + ]; + + yield 'No codec' => [ + 'expected' => [ + self::createFixtureResult(1), + self::createFixtureResult(2), + self::createFixtureResult(3), + ], + 'options' => ['codec' => null], + ]; + + yield 'BSON type map' => [ + 'expected' => [ + Document::fromPHP(self::createFixtureResult(1)), + Document::fromPHP(self::createFixtureResult(2)), + Document::fromPHP(self::createFixtureResult(3)), + ], + 'options' => ['typeMap' => ['root' => 'bson']], + ]; + } + + /** @dataProvider provideFindOptions */ + public function testFind($expected, $options): void + { + $this->createFixtures(3); + + $cursor = $this->collection->find([], $options); + + $this->assertEquals($expected, $cursor->toArray()); + } + + public function testFindWithCodecAndTypemap(): void + { + $options = [ + 'codec' => new TestDocumentCodec(), + 'typeMap' => ['root' => 'array', 'document' => 'array'], + ]; + + $this->expectExceptionObject(InvalidArgumentException::cannotCombineCodecAndTypeMap()); + $this->collection->find([], $options); + } + + public static function provideFindOneOptions(): Generator + { + yield 'Default codec' => [ + 'expected' => TestObject::createDecodedForFixture(1), + 'options' => [], + ]; + + yield 'No codec' => [ + 'expected' => self::createFixtureResult(1), + 'options' => ['codec' => null], + ]; + + yield 'BSON type map' => [ + 'expected' => Document::fromPHP(self::createFixtureResult(1)), + 'options' => ['typeMap' => ['root' => 'bson']], + ]; + } + + /** @dataProvider provideFindOneOptions */ + public function testFindOne($expected, $options): void + { + $this->createFixtures(1); + + $document = $this->collection->findOne([], $options); + + $this->assertEquals($expected, $document); + } + + public function testFindOneWithCodecAndTypemap(): void + { + $options = [ + 'codec' => new TestDocumentCodec(), + 'typeMap' => ['root' => 'array', 'document' => 'array'], + ]; + + $this->expectExceptionObject(InvalidArgumentException::cannotCombineCodecAndTypeMap()); + $this->collection->findOne([], $options); + } + + public static function provideInsertManyOptions(): Generator + { + yield 'Default codec' => [ + 'expected' => [ + TestObject::createDecodedForFixture(1), + TestObject::createDecodedForFixture(2), + TestObject::createDecodedForFixture(3), + ], + 'options' => [], + ]; + + yield 'No codec' => [ + 'expected' => [ + self::createObjectFixtureResult(1, true), + self::createObjectFixtureResult(2, true), + self::createObjectFixtureResult(3, true), + ], + 'options' => ['codec' => null], + ]; + + yield 'BSON type map' => [ + 'expected' => [ + Document::fromPHP(self::createObjectFixtureResult(1, true)), + Document::fromPHP(self::createObjectFixtureResult(2, true)), + Document::fromPHP(self::createObjectFixtureResult(3, true)), + ], + 'options' => ['typeMap' => ['root' => 'bson']], + ]; + } + + /** @dataProvider provideInsertManyOptions */ + public function testInsertMany($expected, $options): void + { + $documents = [ + TestObject::createForFixture(1), + TestObject::createForFixture(2), + TestObject::createForFixture(3), + ]; + + $result = $this->collection->insertMany($documents, $options); + $this->assertSame(3, $result->getInsertedCount()); + + // Add missing identifiers. This is relevant for the "No codec" data set, as the encoded document will not have + // an "_id" field and the driver will automatically generate one. + foreach ($expected as $index => &$expectedDocument) { + if ($expectedDocument instanceof BSONDocument && $expectedDocument->_id === null) { + $expectedDocument->_id = $result->getInsertedIds()[$index]; + } + + if ($expectedDocument instanceof Document && $expectedDocument->get('_id') === null) { + $inserted = $expectedDocument->toPHP(); + $inserted->_id = $result->getInsertedIds()[$index]; + $expectedDocument = Document::fromPHP($inserted); + } + } + + $this->assertEquals($expected, $this->collection->find([], $options)->toArray()); + } + + public static function provideInsertOneOptions(): Generator + { + yield 'Default codec' => [ + 'expected' => TestObject::createDecodedForFixture(1), + 'options' => [], + ]; + + yield 'No codec' => [ + 'expected' => self::createObjectFixtureResult(1, true), + 'options' => ['codec' => null], + ]; + + yield 'BSON type map' => [ + 'expected' => Document::fromPHP(self::createObjectFixtureResult(1, true)), + 'options' => ['typeMap' => ['root' => 'bson']], + ]; + } + + /** @dataProvider provideInsertOneOptions */ + public function testInsertOne($expected, $options): void + { + $result = $this->collection->insertOne(TestObject::createForFixture(1), $options); + $this->assertSame(1, $result->getInsertedCount()); + + // Add missing identifiers. This is relevant for the "No codec" data set, as the encoded document will have an + // automatically generated identifier, which needs to be used in the expected document. + if ($expected instanceof BSONDocument && $expected->_id === null) { + $expected->_id = $result->getInsertedId(); + } + + if ($expected instanceof Document && $expected->get('_id') === null) { + $inserted = $expected->toPHP(); + $inserted->_id = $result->getInsertedId(); + $expected = Document::fromPHP($inserted); + } + + $this->assertEquals($expected, $this->collection->findOne([], $options)); + } + + public static function provideReplaceOneOptions(): Generator + { + $replacedObject = TestObject::createDecodedForFixture(1); + $replacedObject->x->foo = 'baz'; + + yield 'Default codec' => [ + 'expected' => $replacedObject, + 'options' => [], + ]; + + $replacedObject = self::createObjectFixtureResult(1); + $replacedObject->x->foo = 'baz'; + + yield 'No codec' => [ + 'expected' => $replacedObject, + 'options' => ['codec' => null], + ]; + + yield 'BSON type map' => [ + 'expected' => Document::fromPHP($replacedObject), + 'options' => ['typeMap' => ['root' => 'bson']], + ]; + } + + /** @dataProvider provideReplaceOneOptions */ + public function testReplaceOne($expected, $options): void + { + $this->createFixtures(1); + + $replaceObject = TestObject::createForFixture(1); + $replaceObject->x->foo = 'baz'; + + $result = $this->collection->replaceOne(['_id' => 1], $replaceObject, $options); + $this->assertSame(1, $result->getMatchedCount()); + $this->assertSame(1, $result->getModifiedCount()); + + $this->assertEquals($expected, $this->collection->findOne([], $options)); + } + + public function testReplaceOneWithCodecAndTypemap(): void + { + $options = [ + 'codec' => new TestDocumentCodec(), + 'typeMap' => ['root' => 'array', 'document' => 'array'], + ]; + + $this->expectExceptionObject(InvalidArgumentException::cannotCombineCodecAndTypeMap()); + $this->collection->replaceOne(['_id' => 1], ['foo' => 'bar'], $options); + } + + /** + * Create data fixtures. + */ + private function createFixtures(int $n, array $executeBulkWriteOptions = []): void + { + $bulkWrite = new BulkWrite(['ordered' => true]); + + for ($i = 1; $i <= $n; $i++) { + $bulkWrite->insert(TestObject::createDocument($i)); + } + + $result = $this->manager->executeBulkWrite($this->getNamespace(), $bulkWrite, $executeBulkWriteOptions); + + $this->assertEquals($n, $result->getInsertedCount()); + } + + private static function createFixtureResult(int $id): BSONDocument + { + return new BSONDocument(['_id' => $id, 'x' => new BSONDocument(['foo' => 'bar'])]); + } + + private static function createObjectFixtureResult(int $id, bool $isInserted = false): BSONDocument + { + return new BSONDocument([ + '_id' => $isInserted ? null : $id, + 'id' => $id, + 'x' => new BSONDocument(['foo' => 'bar']), + 'decoded' => false, + ]); + } +} diff --git a/tests/Collection/CollectionFunctionalTest.php b/tests/Collection/CollectionFunctionalTest.php index ee38671df..709ec6c5a 100644 --- a/tests/Collection/CollectionFunctionalTest.php +++ b/tests/Collection/CollectionFunctionalTest.php @@ -66,6 +66,7 @@ public function testConstructorOptionTypeChecks(array $options): void public function provideInvalidConstructorOptions(): array { return $this->createOptionDataProvider([ + 'codec' => $this->getInvalidDocumentCodecValues(), 'readConcern' => $this->getInvalidReadConcernValues(), 'readPreference' => $this->getInvalidReadPreferenceValues(), 'typeMap' => $this->getInvalidArrayValues(), diff --git a/tests/Fixtures/Codec/TestDocumentCodec.php b/tests/Fixtures/Codec/TestDocumentCodec.php new file mode 100644 index 000000000..3df41e304 --- /dev/null +++ b/tests/Fixtures/Codec/TestDocumentCodec.php @@ -0,0 +1,71 @@ +id = $value->get('_id'); + $object->decoded = true; + + $object->x = new TestNestedObject(); + $object->x->foo = $value->get('x')->get('foo'); + + return $object; + } + + public function canEncode($value): bool + { + return $value instanceof TestObject; + } + + public function encode($value): Document + { + if (! $value instanceof TestObject) { + throw UnsupportedValueException::invalidEncodableValue($value); + } + + return Document::fromPHP([ + '_id' => $value->id, + 'x' => Document::fromPHP(['foo' => $value->x->foo]), + 'encoded' => true, + ]); + } +} diff --git a/tests/Fixtures/Document/TestNestedObject.php b/tests/Fixtures/Document/TestNestedObject.php new file mode 100644 index 000000000..3c3354ecf --- /dev/null +++ b/tests/Fixtures/Document/TestNestedObject.php @@ -0,0 +1,23 @@ + $id, + 'x' => ['foo' => 'bar'], + ]); + } + + public static function createForFixture(int $id): self + { + $instance = new self(); + $instance->id = $id; + + $instance->x = new TestNestedObject(); + $instance->x->foo = 'bar'; + + return $instance; + } + + public static function createDecodedForFixture(int $id): self + { + $instance = self::createForFixture($id); + $instance->decoded = true; + + return $instance; + } +} diff --git a/tests/Model/CodecCursorFunctionalTest.php b/tests/Model/CodecCursorFunctionalTest.php new file mode 100644 index 000000000..563cbbbdf --- /dev/null +++ b/tests/Model/CodecCursorFunctionalTest.php @@ -0,0 +1,30 @@ +dropCollection($this->getDatabaseName(), $this->getCollectionName()); + } + + public function testSetTypeMap(): void + { + $collection = self::createTestClient()->selectCollection($this->getDatabaseName(), $this->getCollectionName()); + $cursor = $collection->find(); + + $codecCursor = CodecCursor::fromCursor($cursor, $this->createMock(DocumentCodec::class)); + + $this->expectWarning(); + $this->expectWarningMessage('Discarding type map for MongoDB\Model\CodecCursor::setTypeMap'); + + $codecCursor->setTypeMap(['root' => 'array']); + } +} diff --git a/tests/Operation/AggregateFunctionalTest.php b/tests/Operation/AggregateFunctionalTest.php index 4aad0a667..3d1057ac9 100644 --- a/tests/Operation/AggregateFunctionalTest.php +++ b/tests/Operation/AggregateFunctionalTest.php @@ -9,6 +9,8 @@ use MongoDB\Driver\WriteConcern; use MongoDB\Operation\Aggregate; use MongoDB\Tests\CommandObserver; +use MongoDB\Tests\Fixtures\Codec\TestDocumentCodec; +use MongoDB\Tests\Fixtures\Document\TestObject; use stdClass; use function current; @@ -335,6 +337,30 @@ public function testReadPreferenceWithinTransaction(): void } } + public function testCodecOption(): void + { + $this->createFixtures(3); + + $codec = new TestDocumentCodec(); + + $operation = new Aggregate( + $this->getDatabaseName(), + $this->getCollectionName(), + [['$match' => ['_id' => ['$gt' => 1]]]], + ['codec' => $codec], + ); + + $cursor = $operation->execute($this->getPrimaryServer()); + + $this->assertEquals( + [ + TestObject::createDecodedForFixture(2), + TestObject::createDecodedForFixture(3), + ], + $cursor->toArray(), + ); + } + /** * Create data fixtures. */ @@ -343,10 +369,7 @@ private function createFixtures(int $n, array $executeBulkWriteOptions = []): vo $bulkWrite = new BulkWrite(['ordered' => true]); for ($i = 1; $i <= $n; $i++) { - $bulkWrite->insert([ - '_id' => $i, - 'x' => (object) ['foo' => 'bar'], - ]); + $bulkWrite->insert(TestObject::createDocument($i)); } $result = $this->manager->executeBulkWrite($this->getNamespace(), $bulkWrite, $executeBulkWriteOptions); diff --git a/tests/Operation/AggregateTest.php b/tests/Operation/AggregateTest.php index 433278ebb..81128dca4 100644 --- a/tests/Operation/AggregateTest.php +++ b/tests/Operation/AggregateTest.php @@ -30,6 +30,7 @@ public function provideInvalidConstructorOptions() 'allowDiskUse' => $this->getInvalidBooleanValues(), 'batchSize' => $this->getInvalidIntegerValues(), 'bypassDocumentValidation' => $this->getInvalidBooleanValues(), + 'codec' => $this->getInvalidDocumentCodecValues(), 'collation' => $this->getInvalidDocumentValues(), 'hint' => $this->getInvalidHintValues(), 'let' => $this->getInvalidDocumentValues(), diff --git a/tests/Operation/BulkWriteFunctionalTest.php b/tests/Operation/BulkWriteFunctionalTest.php index c1089150d..46958e324 100644 --- a/tests/Operation/BulkWriteFunctionalTest.php +++ b/tests/Operation/BulkWriteFunctionalTest.php @@ -12,6 +12,8 @@ use MongoDB\Model\BSONDocument; use MongoDB\Operation\BulkWrite; use MongoDB\Tests\CommandObserver; +use MongoDB\Tests\Fixtures\Codec\TestDocumentCodec; +use MongoDB\Tests\Fixtures\Document\TestObject; use stdClass; use function is_array; @@ -449,6 +451,47 @@ public function testBulkWriteWithPipelineUpdates(): void $this->assertSameDocuments($expected, $this->collection->find()); } + public function testCodecOption(): void + { + $this->createFixtures(3); + + $codec = new TestDocumentCodec(); + + $replaceObject = TestObject::createForFixture(3); + $replaceObject->x->foo = 'baz'; + + $operations = [ + ['insertOne' => [TestObject::createForFixture(4)]], + ['replaceOne' => [['_id' => 3], $replaceObject]], + ]; + + $operation = new BulkWrite( + $this->getDatabaseName(), + $this->getCollectionName(), + $operations, + ['codec' => $codec], + ); + + $result = $operation->execute($this->getPrimaryServer()); + + $this->assertInstanceOf(BulkWriteResult::class, $result); + $this->assertSame(1, $result->getInsertedCount()); + $this->assertSame(1, $result->getMatchedCount()); + $this->assertSame(1, $result->getModifiedCount()); + + $replacedObject = TestObject::createDecodedForFixture(3); + $replacedObject->x->foo = 'baz'; + + // Only read the last two documents as the other two don't fit our codec + $this->assertEquals( + [ + $replacedObject, + TestObject::createDecodedForFixture(4), + ], + $this->collection->find(['_id' => ['$gte' => 3]], ['codec' => $codec])->toArray(), + ); + } + /** * Create data fixtures. */ diff --git a/tests/Operation/BulkWriteTest.php b/tests/Operation/BulkWriteTest.php index 5da144343..a307f5b01 100644 --- a/tests/Operation/BulkWriteTest.php +++ b/tests/Operation/BulkWriteTest.php @@ -3,7 +3,9 @@ namespace MongoDB\Tests\Operation; use MongoDB\Exception\InvalidArgumentException; +use MongoDB\Exception\UnsupportedValueException; use MongoDB\Operation\BulkWrite; +use MongoDB\Tests\Fixtures\Codec\TestDocumentCodec; class BulkWriteTest extends TestCase { @@ -63,6 +65,18 @@ public function testInsertOneDocumentArgumentTypeCheck($document): void ]); } + public function testInsertOneWithCodecRejectsInvalidDocuments(): void + { + $this->expectExceptionObject(UnsupportedValueException::invalidEncodableValue([])); + + new BulkWrite( + $this->getDatabaseName(), + $this->getCollectionName(), + [[BulkWrite::INSERT_ONE => [['x' => 1]]]], + ['codec' => new TestDocumentCodec()], + ); + } + public function testDeleteManyFilterArgumentMissing(): void { $this->expectException(InvalidArgumentException::class); @@ -223,6 +237,18 @@ public function provideInvalidBooleanValues() return $this->wrapValuesForDataProvider($this->getInvalidBooleanValues()); } + public function testReplaceOneWithCodecRejectsInvalidDocuments(): void + { + $this->expectExceptionObject(UnsupportedValueException::invalidEncodableValue([])); + + new BulkWrite( + $this->getDatabaseName(), + $this->getCollectionName(), + [[BulkWrite::REPLACE_ONE => [['x' => 1], ['y' => 1]]]], + ['codec' => new TestDocumentCodec()], + ); + } + public function testUpdateManyFilterArgumentMissing(): void { $this->expectException(InvalidArgumentException::class); @@ -425,6 +451,7 @@ public function provideInvalidConstructorOptions() { return $this->createOptionDataProvider([ 'bypassDocumentValidation' => $this->getInvalidBooleanValues(), + 'codec' => $this->getInvalidDocumentCodecValues(), 'ordered' => $this->getInvalidBooleanValues(true), 'session' => $this->getInvalidSessionValues(), 'writeConcern' => $this->getInvalidWriteConcernValues(), diff --git a/tests/Operation/FindAndModifyFunctionalTest.php b/tests/Operation/FindAndModifyFunctionalTest.php index 51723a93c..05e819132 100644 --- a/tests/Operation/FindAndModifyFunctionalTest.php +++ b/tests/Operation/FindAndModifyFunctionalTest.php @@ -10,6 +10,8 @@ use MongoDB\Model\BSONDocument; use MongoDB\Operation\FindAndModify; use MongoDB\Tests\CommandObserver; +use MongoDB\Tests\Fixtures\Codec\TestDocumentCodec; +use MongoDB\Tests\Fixtures\Document\TestObject; use stdClass; class FindAndModifyFunctionalTest extends FunctionalTestCase @@ -279,6 +281,93 @@ public function provideTypeMapOptionsAndExpectedDocument() ]; } + public function testFindOneAndDeleteWithCodec(): void + { + $this->createFixtures(1); + + $operation = new FindAndModify( + $this->getDatabaseName(), + $this->getCollectionName(), + ['remove' => true, 'codec' => new TestDocumentCodec()], + ); + + $result = $operation->execute($this->getPrimaryServer()); + + self::assertEquals(TestObject::createDecodedForFixture(1), $result); + } + + public function testFindOneAndDeleteNothingWithCodec(): void + { + // When the query does not match any documents, the operation returns null + $operation = new FindAndModify( + $this->getDatabaseName(), + $this->getCollectionName(), + ['remove' => true, 'codec' => new TestDocumentCodec()], + ); + + $result = $operation->execute($this->getPrimaryServer()); + + self::assertNull($result); + } + + public function testFindOneAndUpdateWithCodec(): void + { + $this->createFixtures(1); + + $operation = new FindAndModify( + $this->getDatabaseName(), + $this->getCollectionName(), + ['update' => ['$set' => ['x.foo' => 'baz']], 'codec' => new TestDocumentCodec()], + ); + + $result = $operation->execute($this->getPrimaryServer()); + + self::assertEquals(TestObject::createDecodedForFixture(1), $result); + } + + public function testFindOneAndUpdateNothingWithCodec(): void + { + // When the query does not match any documents, the operation returns null + $operation = new FindAndModify( + $this->getDatabaseName(), + $this->getCollectionName(), + ['update' => ['$set' => ['x.foo' => 'baz']], 'codec' => new TestDocumentCodec()], + ); + + $result = $operation->execute($this->getPrimaryServer()); + + self::assertNull($result); + } + + public function testFindOneAndReplaceWithCodec(): void + { + $this->createFixtures(1); + + $operation = new FindAndModify( + $this->getDatabaseName(), + $this->getCollectionName(), + ['update' => ['_id' => 1], 'codec' => new TestDocumentCodec()], + ); + + $result = $operation->execute($this->getPrimaryServer()); + + self::assertEquals(TestObject::createDecodedForFixture(1), $result); + } + + public function testFindOneAndReplaceNothingWithCodec(): void + { + // When the query does not match any documents, the operation returns null + $operation = new FindAndModify( + $this->getDatabaseName(), + $this->getCollectionName(), + ['update' => ['_id' => 1], 'codec' => new TestDocumentCodec()], + ); + + $result = $operation->execute($this->getPrimaryServer()); + + self::assertNull($result); + } + /** * Create data fixtures. */ @@ -287,10 +376,7 @@ private function createFixtures(int $n): void $bulkWrite = new BulkWrite(['ordered' => true]); for ($i = 1; $i <= $n; $i++) { - $bulkWrite->insert([ - '_id' => $i, - 'x' => (object) ['foo' => 'bar'], - ]); + $bulkWrite->insert(TestObject::createDocument($i)); } $result = $this->manager->executeBulkWrite($this->getNamespace(), $bulkWrite); diff --git a/tests/Operation/FindAndModifyTest.php b/tests/Operation/FindAndModifyTest.php index f2de657c4..65676d289 100644 --- a/tests/Operation/FindAndModifyTest.php +++ b/tests/Operation/FindAndModifyTest.php @@ -20,6 +20,7 @@ public function provideInvalidConstructorOptions() return $this->createOptionDataProvider([ 'arrayFilters' => $this->getInvalidArrayValues(), 'bypassDocumentValidation' => $this->getInvalidBooleanValues(), + 'codec' => $this->getInvalidDocumentCodecValues(), 'collation' => $this->getInvalidDocumentValues(), 'fields' => $this->getInvalidDocumentValues(), 'maxTimeMS' => $this->getInvalidIntegerValues(), diff --git a/tests/Operation/FindFunctionalTest.php b/tests/Operation/FindFunctionalTest.php index 53b50f4da..2b70a421b 100644 --- a/tests/Operation/FindFunctionalTest.php +++ b/tests/Operation/FindFunctionalTest.php @@ -9,6 +9,8 @@ use MongoDB\Operation\CreateIndexes; use MongoDB\Operation\Find; use MongoDB\Tests\CommandObserver; +use MongoDB\Tests\Fixtures\Codec\TestDocumentCodec; +use MongoDB\Tests\Fixtures\Document\TestObject; use stdClass; use function microtime; @@ -196,6 +198,25 @@ public function provideTypeMapOptionsAndExpectedDocuments() ]; } + public function testCodecOption(): void + { + $this->createFixtures(3); + + $codec = new TestDocumentCodec(); + + $operation = new Find($this->getDatabaseName(), $this->getCollectionName(), [], ['codec' => $codec]); + $cursor = $operation->execute($this->getPrimaryServer()); + + $this->assertEquals( + [ + TestObject::createDecodedForFixture(1), + TestObject::createDecodedForFixture(2), + TestObject::createDecodedForFixture(3), + ], + $cursor->toArray(), + ); + } + public function testMaxAwaitTimeMS(): void { $maxAwaitTimeMS = 100; @@ -302,10 +323,7 @@ private function createFixtures(int $n, array $executeBulkWriteOptions = []): vo $bulkWrite = new BulkWrite(['ordered' => true]); for ($i = 1; $i <= $n; $i++) { - $bulkWrite->insert([ - '_id' => $i, - 'x' => (object) ['foo' => 'bar'], - ]); + $bulkWrite->insert(TestObject::createDocument($i)); } $result = $this->manager->executeBulkWrite($this->getNamespace(), $bulkWrite, $executeBulkWriteOptions); diff --git a/tests/Operation/FindOneAndReplaceFunctionalTest.php b/tests/Operation/FindOneAndReplaceFunctionalTest.php new file mode 100644 index 000000000..9bbd8047c --- /dev/null +++ b/tests/Operation/FindOneAndReplaceFunctionalTest.php @@ -0,0 +1,63 @@ +createFixtures(1); + + (new CommandObserver())->observe( + function (): void { + $replaceObject = TestObject::createForFixture(1); + $replaceObject->x->foo = 'baz'; + + $operation = new FindOneAndReplace( + $this->getDatabaseName(), + $this->getCollectionName(), + ['_id' => 1], + $replaceObject, + ['codec' => new TestDocumentCodec()], + ); + + $this->assertEquals( + TestObject::createDecodedForFixture(1), + $operation->execute($this->getPrimaryServer()), + ); + }, + function (array $event): void { + $this->assertEquals( + (object) [ + '_id' => 1, + 'x' => (object) ['foo' => 'baz'], + 'encoded' => true, + ], + $event['started']->getCommand()->update ?? null, + ); + }, + ); + } + + /** + * Create data fixtures. + */ + private function createFixtures(int $n): void + { + $bulkWrite = new BulkWrite(['ordered' => true]); + + for ($i = 1; $i <= $n; $i++) { + $bulkWrite->insert(TestObject::createDocument($i)); + } + + $result = $this->manager->executeBulkWrite($this->getNamespace(), $bulkWrite); + + $this->assertEquals($n, $result->getInsertedCount()); + } +} diff --git a/tests/Operation/FindOneAndReplaceTest.php b/tests/Operation/FindOneAndReplaceTest.php index 73951596d..94075b89d 100644 --- a/tests/Operation/FindOneAndReplaceTest.php +++ b/tests/Operation/FindOneAndReplaceTest.php @@ -60,6 +60,7 @@ public function testConstructorOptionTypeChecks(array $options): void public function provideInvalidConstructorOptions() { return $this->createOptionDataProvider([ + 'codec' => $this->getInvalidDocumentCodecValues(), 'projection' => $this->getInvalidDocumentValues(), 'returnDocument' => $this->getInvalidIntegerValues(true), ]); diff --git a/tests/Operation/FindOneFunctionalTest.php b/tests/Operation/FindOneFunctionalTest.php index d18d056e3..57e01f442 100644 --- a/tests/Operation/FindOneFunctionalTest.php +++ b/tests/Operation/FindOneFunctionalTest.php @@ -4,6 +4,8 @@ use MongoDB\Driver\BulkWrite; use MongoDB\Operation\FindOne; +use MongoDB\Tests\Fixtures\Codec\TestDocumentCodec; +use MongoDB\Tests\Fixtures\Document\TestObject; class FindOneFunctionalTest extends FunctionalTestCase { @@ -36,6 +38,18 @@ public function provideTypeMapOptionsAndExpectedDocument() ]; } + public function testCodecOption(): void + { + $this->createFixtures(1); + + $codec = new TestDocumentCodec(); + + $operation = new FindOne($this->getDatabaseName(), $this->getCollectionName(), [], ['codec' => $codec]); + $document = $operation->execute($this->getPrimaryServer()); + + $this->assertEquals(TestObject::createDecodedForFixture(1), $document); + } + /** * Create data fixtures. */ @@ -44,10 +58,7 @@ private function createFixtures(int $n): void $bulkWrite = new BulkWrite(['ordered' => true]); for ($i = 1; $i <= $n; $i++) { - $bulkWrite->insert([ - '_id' => $i, - 'x' => (object) ['foo' => 'bar'], - ]); + $bulkWrite->insert(TestObject::createDocument($i)); } $result = $this->manager->executeBulkWrite($this->getNamespace(), $bulkWrite); diff --git a/tests/Operation/FindTest.php b/tests/Operation/FindTest.php index 3a24ed3ee..68341d2ac 100644 --- a/tests/Operation/FindTest.php +++ b/tests/Operation/FindTest.php @@ -28,6 +28,7 @@ public function provideInvalidConstructorOptions() return $this->createOptionDataProvider([ 'allowPartialResults' => $this->getInvalidBooleanValues(), 'batchSize' => $this->getInvalidIntegerValues(), + 'codec' => $this->getInvalidDocumentCodecValues(), 'collation' => $this->getInvalidDocumentValues(), 'cursorType' => $this->getInvalidIntegerValues(), 'hint' => $this->getInvalidHintValues(), diff --git a/tests/Operation/InsertManyFunctionalTest.php b/tests/Operation/InsertManyFunctionalTest.php index b8ca886be..ae17f7342 100644 --- a/tests/Operation/InsertManyFunctionalTest.php +++ b/tests/Operation/InsertManyFunctionalTest.php @@ -11,6 +11,8 @@ use MongoDB\Model\BSONDocument; use MongoDB\Operation\InsertMany; use MongoDB\Tests\CommandObserver; +use MongoDB\Tests\Fixtures\Codec\TestDocumentCodec; +use MongoDB\Tests\Fixtures\Document\TestObject; class InsertManyFunctionalTest extends FunctionalTestCase { @@ -200,4 +202,48 @@ public function testUnacknowledgedWriteConcernAccessesInsertedId(InsertManyResul { $this->assertInstanceOf(ObjectId::class, $result->getInsertedIds()[0]); } + + public function testInsertingWithCodec(): void + { + $documents = [ + TestObject::createForFixture(1), + TestObject::createForFixture(2), + TestObject::createForFixture(3), + ]; + + $expectedDocuments = [ + (object) [ + '_id' => 1, + 'x' => (object) ['foo' => 'bar'], + 'encoded' => true, + ], + (object) [ + '_id' => 2, + 'x' => (object) ['foo' => 'bar'], + 'encoded' => true, + ], + (object) [ + '_id' => 3, + 'x' => (object) ['foo' => 'bar'], + 'encoded' => true, + ], + ]; + + (new CommandObserver())->observe( + function () use ($documents): void { + $operation = new InsertMany( + $this->getDatabaseName(), + $this->getCollectionName(), + $documents, + ['codec' => new TestDocumentCodec()], + ); + + $result = $operation->execute($this->getPrimaryServer()); + $this->assertEquals([1, 2, 3], $result->getInsertedIds()); + }, + function (array $event) use ($expectedDocuments): void { + $this->assertEquals($expectedDocuments, $event['started']->getCommand()->documents ?? null); + }, + ); + } } diff --git a/tests/Operation/InsertManyTest.php b/tests/Operation/InsertManyTest.php index c3edec535..ece556cae 100644 --- a/tests/Operation/InsertManyTest.php +++ b/tests/Operation/InsertManyTest.php @@ -3,7 +3,9 @@ namespace MongoDB\Tests\Operation; use MongoDB\Exception\InvalidArgumentException; +use MongoDB\Exception\UnsupportedValueException; use MongoDB\Operation\InsertMany; +use MongoDB\Tests\Fixtures\Codec\TestDocumentCodec; class InsertManyTest extends TestCase { @@ -40,9 +42,17 @@ public function provideInvalidConstructorOptions() { return $this->createOptionDataProvider([ 'bypassDocumentValidation' => $this->getInvalidBooleanValues(), + 'codec' => $this->getInvalidDocumentCodecValues(), 'ordered' => $this->getInvalidBooleanValues(true), 'session' => $this->getInvalidSessionValues(), 'writeConcern' => $this->getInvalidWriteConcernValues(), ]); } + + public function testCodecRejectsInvalidDocuments(): void + { + $this->expectExceptionObject(UnsupportedValueException::invalidEncodableValue([])); + + new InsertMany($this->getDatabaseName(), $this->getCollectionName(), [['x' => 1]], ['codec' => new TestDocumentCodec()]); + } } diff --git a/tests/Operation/InsertOneFunctionalTest.php b/tests/Operation/InsertOneFunctionalTest.php index 0a158ccb5..2075f0fc0 100644 --- a/tests/Operation/InsertOneFunctionalTest.php +++ b/tests/Operation/InsertOneFunctionalTest.php @@ -11,6 +11,8 @@ use MongoDB\Model\BSONDocument; use MongoDB\Operation\InsertOne; use MongoDB\Tests\CommandObserver; +use MongoDB\Tests\Fixtures\Codec\TestDocumentCodec; +use MongoDB\Tests\Fixtures\Document\TestObject; use stdClass; class InsertOneFunctionalTest extends FunctionalTestCase @@ -191,4 +193,29 @@ public function testUnacknowledgedWriteConcernAccessesInsertedId(InsertOneResult { $this->assertInstanceOf(ObjectId::class, $result->getInsertedId()); } + + public function testInsertingWithCodec(): void + { + (new CommandObserver())->observe( + function (): void { + $document = TestObject::createForFixture(1); + $options = ['codec' => new TestDocumentCodec()]; + + $operation = new InsertOne($this->getDatabaseName(), $this->getCollectionName(), $document, $options); + $result = $operation->execute($this->getPrimaryServer()); + + $this->assertSame(1, $result->getInsertedId()); + }, + function (array $event): void { + $this->assertEquals( + (object) [ + '_id' => 1, + 'x' => (object) ['foo' => 'bar'], + 'encoded' => true, + ], + $event['started']->getCommand()->documents[0] ?? null, + ); + }, + ); + } } diff --git a/tests/Operation/InsertOneTest.php b/tests/Operation/InsertOneTest.php index 17fde0343..ad7212c7c 100644 --- a/tests/Operation/InsertOneTest.php +++ b/tests/Operation/InsertOneTest.php @@ -3,7 +3,9 @@ namespace MongoDB\Tests\Operation; use MongoDB\Exception\InvalidArgumentException; +use MongoDB\Exception\UnsupportedValueException; use MongoDB\Operation\InsertOne; +use MongoDB\Tests\Fixtures\Codec\TestDocumentCodec; class InsertOneTest extends TestCase { @@ -25,8 +27,16 @@ public function provideInvalidConstructorOptions() { return $this->createOptionDataProvider([ 'bypassDocumentValidation' => $this->getInvalidBooleanValues(), + 'codec' => $this->getInvalidDocumentCodecValues(), 'session' => $this->getInvalidSessionValues(), 'writeConcern' => $this->getInvalidWriteConcernValues(), ]); } + + public function testCodecRejectsInvalidDocuments(): void + { + $this->expectExceptionObject(UnsupportedValueException::invalidEncodableValue([])); + + new InsertOne($this->getDatabaseName(), $this->getCollectionName(), ['x' => 1], ['codec' => new TestDocumentCodec()]); + } } diff --git a/tests/Operation/ReplaceOneFunctionalTest.php b/tests/Operation/ReplaceOneFunctionalTest.php new file mode 100644 index 000000000..7a15efbf4 --- /dev/null +++ b/tests/Operation/ReplaceOneFunctionalTest.php @@ -0,0 +1,38 @@ +observe( + function (): void { + $operation = new ReplaceOne( + $this->getDatabaseName(), + $this->getCollectionName(), + ['x' => 1], + TestObject::createForFixture(1), + ['codec' => new TestDocumentCodec()], + ); + + $operation->execute($this->getPrimaryServer()); + }, + function (array $event): void { + $this->assertEquals( + (object) [ + '_id' => 1, + 'x' => (object) ['foo' => 'bar'], + 'encoded' => true, + ], + $event['started']->getCommand()->updates[0]->u ?? null, + ); + }, + ); + } +} diff --git a/tests/Operation/ReplaceOneTest.php b/tests/Operation/ReplaceOneTest.php index 1290f90df..404cdae78 100644 --- a/tests/Operation/ReplaceOneTest.php +++ b/tests/Operation/ReplaceOneTest.php @@ -3,7 +3,9 @@ namespace MongoDB\Tests\Operation; use MongoDB\Exception\InvalidArgumentException; +use MongoDB\Exception\UnsupportedValueException; use MongoDB\Operation\ReplaceOne; +use MongoDB\Tests\Fixtures\Codec\TestDocumentCodec; class ReplaceOneTest extends TestCase { @@ -48,4 +50,25 @@ public function testConstructorReplacementArgumentProhibitsUpdatePipeline($repla $this->expectExceptionMessageMatches('#(\$replacement is an update pipeline)|(Expected \$replacement to have type "document" \(array or object\))#'); new ReplaceOne($this->getDatabaseName(), $this->getCollectionName(), ['x' => 1], $replacement); } + + /** @dataProvider provideInvalidConstructorOptions */ + public function testConstructorOptionsTypeCheck($options): void + { + $this->expectException(InvalidArgumentException::class); + new ReplaceOne($this->getDatabaseName(), $this->getCollectionName(), ['x' => 1], ['y' => 1], $options); + } + + public function provideInvalidConstructorOptions() + { + return $this->createOptionDataProvider([ + 'codec' => $this->getInvalidDocumentCodecValues(), + ]); + } + + public function testCodecRejectsInvalidDocuments(): void + { + $this->expectExceptionObject(UnsupportedValueException::invalidEncodableValue([])); + + new ReplaceOne($this->getDatabaseName(), $this->getCollectionName(), ['x' => 1], ['y' => 1], ['codec' => new TestDocumentCodec()]); + } } diff --git a/tests/Operation/WatchFunctionalTest.php b/tests/Operation/WatchFunctionalTest.php index 447d8cc4a..fb38435cd 100644 --- a/tests/Operation/WatchFunctionalTest.php +++ b/tests/Operation/WatchFunctionalTest.php @@ -3,9 +3,14 @@ namespace MongoDB\Tests\Operation; use Closure; +use Generator; use Iterator; +use MongoDB\BSON\Document; use MongoDB\BSON\TimestampInterface; use MongoDB\ChangeStream; +use MongoDB\Codec\DecodeIfSupported; +use MongoDB\Codec\DocumentCodec; +use MongoDB\Codec\EncodeIfSupported; use MongoDB\Driver\Cursor; use MongoDB\Driver\Exception\CommandException; use MongoDB\Driver\Exception\ConnectionTimeoutException; @@ -15,6 +20,7 @@ use MongoDB\Driver\ReadPreference; use MongoDB\Driver\WriteConcern; use MongoDB\Exception\ResumeTokenException; +use MongoDB\Exception\UnsupportedValueException; use MongoDB\Operation\InsertOne; use MongoDB\Operation\Watch; use MongoDB\Tests\CommandObserver; @@ -52,15 +58,72 @@ public function setUp(): void $this->createCollection($this->getDatabaseName(), $this->getCollectionName()); } + public static function provideCodecOptions(): Generator + { + yield 'No codec' => [ + 'options' => [], + 'getIdentifier' => static fn (object $document): object => $document->_id, + ]; + + $codec = new class implements DocumentCodec { + use DecodeIfSupported; + use EncodeIfSupported; + + public function canDecode($value): bool + { + return $value instanceof Document; + } + + public function canEncode($value): bool + { + return false; + } + + public function decode($value): stdClass + { + if (! $value instanceof Document) { + throw UnsupportedValueException::invalidDecodableValue($value); + } + + return (object) [ + 'id' => $value->get('_id')->toPHP(), + 'fullDocument' => $value->get('fullDocument')->toPHP(), + ]; + } + + public function encode($value): Document + { + return Document::fromPHP([]); + } + }; + + yield 'Codec' => [ + 'options' => ['codec' => $codec], + 'getIdentifier' => static function (object $document): object { + self::assertObjectHasAttribute('id', $document); + + return $document->id; + }, + ]; + } + /** * Prose test 1: "ChangeStream must continuously track the last seen * resumeToken" + * + * @dataProvider provideCodecOptions */ - public function testGetResumeToken(): void + public function testGetResumeToken(array $options, Closure $getIdentifier): void { $this->skipIfServerVersion('>=', '4.0.7', 'postBatchResumeToken is supported'); - $operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $this->defaultOptions); + $operation = new Watch( + $this->manager, + $this->getDatabaseName(), + $this->getCollectionName(), + [], + $options + $this->defaultOptions, + ); $changeStream = $operation->execute($this->getPrimaryServer()); $changeStream->rewind(); @@ -71,16 +134,16 @@ public function testGetResumeToken(): void $this->insertDocument(['x' => 2]); $this->advanceCursorUntilValid($changeStream); - $this->assertSameDocument($changeStream->current()->_id, $changeStream->getResumeToken()); + $this->assertSameDocument($getIdentifier($changeStream->current()), $changeStream->getResumeToken()); $changeStream->next(); $this->assertTrue($changeStream->valid()); - $this->assertSameDocument($changeStream->current()->_id, $changeStream->getResumeToken()); + $this->assertSameDocument($getIdentifier($changeStream->current()), $changeStream->getResumeToken()); $this->insertDocument(['x' => 3]); $this->advanceCursorUntilValid($changeStream); - $this->assertSameDocument($changeStream->current()->_id, $changeStream->getResumeToken()); + $this->assertSameDocument($getIdentifier($changeStream->current()), $changeStream->getResumeToken()); } /** @@ -100,12 +163,20 @@ public function testGetResumeToken(): void * - The batch has been iterated up to but not including the last element. * Expected result: getResumeToken must return the _id of the previous * document returned. + * + * @dataProvider provideCodecOptions */ - public function testGetResumeTokenWithPostBatchResumeToken(): void + public function testGetResumeTokenWithPostBatchResumeToken(array $options, Closure $getIdentifier): void { $this->skipIfServerVersion('<', '4.0.7', 'postBatchResumeToken is not supported'); - $operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $this->defaultOptions); + $operation = new Watch( + $this->manager, + $this->getDatabaseName(), + $this->getCollectionName(), + [], + $options + $this->defaultOptions, + ); $events = []; @@ -144,7 +215,7 @@ function (array $event) use (&$lastEvent): void { $this->assertSame('getMore', $lastEvent['started']->getCommandName()); $postBatchResumeToken = $this->getPostBatchResumeTokenFromReply($lastEvent['succeeded']->getReply()); - $this->assertSameDocument($changeStream->current()->_id, $changeStream->getResumeToken()); + $this->assertSameDocument($getIdentifier($changeStream->current()), $changeStream->getResumeToken()); $changeStream->next(); $this->assertSameDocument($postBatchResumeToken, $changeStream->getResumeToken()); @@ -423,9 +494,16 @@ public function testRewindMultipleTimesWithNoResults(): void $this->assertNull($changeStream->current()); } - public function testNoChangeAfterResumeBeforeInsert(): void + /** @dataProvider provideCodecOptions */ + public function testNoChangeAfterResumeBeforeInsert(array $options): void { - $operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $this->defaultOptions); + $operation = new Watch( + $this->manager, + $this->getDatabaseName(), + $this->getCollectionName(), + [], + $options + $this->defaultOptions, + ); $changeStream = $operation->execute($this->getPrimaryServer()); $this->assertNoCommandExecuted(function () use ($changeStream): void { @@ -437,15 +515,7 @@ public function testNoChangeAfterResumeBeforeInsert(): void $this->advanceCursorUntilValid($changeStream); - $expectedResult = [ - '_id' => $changeStream->current()->_id, - 'operationType' => 'insert', - 'fullDocument' => ['_id' => 1, 'x' => 'foo'], - 'ns' => ['db' => $this->getDatabaseName(), 'coll' => $this->getCollectionName()], - 'documentKey' => ['_id' => 1], - ]; - - $this->assertMatchesDocument($expectedResult, $changeStream->current()); + $this->assertMatchesDocument(['_id' => 1, 'x' => 'foo'], $changeStream->current()->fullDocument); $this->forceChangeStreamResume(); @@ -456,15 +526,7 @@ public function testNoChangeAfterResumeBeforeInsert(): void $this->advanceCursorUntilValid($changeStream); - $expectedResult = [ - '_id' => $changeStream->current()->_id, - 'operationType' => 'insert', - 'fullDocument' => ['_id' => 2, 'x' => 'bar'], - 'ns' => ['db' => $this->getDatabaseName(), 'coll' => $this->getCollectionName()], - 'documentKey' => ['_id' => 2], - ]; - - $this->assertMatchesDocument($expectedResult, $changeStream->current()); + $this->assertMatchesDocument(['_id' => 2, 'x' => 'bar'], $changeStream->current()->fullDocument); } public function testResumeMultipleTimesInSuccession(): void @@ -841,9 +903,16 @@ public function testMaxAwaitTimeMS(): void } } - public function testRewindExtractsResumeTokenAndNextResumes(): void + /** @dataProvider provideCodecOptions */ + public function testRewindExtractsResumeTokenAndNextResumes(array $options, Closure $getIdentifier): void { - $operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $this->defaultOptions); + $operation = new Watch( + $this->manager, + $this->getDatabaseName(), + $this->getCollectionName(), + [], + $options + $this->defaultOptions, + ); $changeStream = $operation->execute($this->getPrimaryServer()); $this->insertDocument(['_id' => 1, 'x' => 'foo']); @@ -859,9 +928,14 @@ public function testRewindExtractsResumeTokenAndNextResumes(): void $this->advanceCursorUntilValid($changeStream); - $resumeToken = $changeStream->current()->_id; - $options = ['resumeAfter' => $resumeToken] + $this->defaultOptions; - $operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $options); + $resumeToken = $getIdentifier($changeStream->current()); + $operation = new Watch( + $this->manager, + $this->getDatabaseName(), + $this->getCollectionName(), + [], + ['resumeAfter' => $resumeToken] + $options + $this->defaultOptions, + ); $changeStream = $operation->execute($this->getPrimaryServer()); $this->assertSameDocument($resumeToken, $changeStream->getResumeToken()); @@ -877,33 +951,26 @@ public function testRewindExtractsResumeTokenAndNextResumes(): void } $this->assertSame(0, $changeStream->key()); - $expectedResult = [ - '_id' => $changeStream->current()->_id, - 'operationType' => 'insert', - 'fullDocument' => ['_id' => 2, 'x' => 'bar'], - 'ns' => ['db' => $this->getDatabaseName(), 'coll' => $this->getCollectionName()], - 'documentKey' => ['_id' => 2], - ]; - $this->assertMatchesDocument($expectedResult, $changeStream->current()); + $this->assertMatchesDocument(['_id' => 2, 'x' => 'bar'], $changeStream->current()->fullDocument); $this->forceChangeStreamResume(); $this->advanceCursorUntilValid($changeStream); $this->assertSame(1, $changeStream->key()); - $expectedResult = [ - '_id' => $changeStream->current()->_id, - 'operationType' => 'insert', - 'fullDocument' => ['_id' => 3, 'x' => 'baz'], - 'ns' => ['db' => $this->getDatabaseName(), 'coll' => $this->getCollectionName()], - 'documentKey' => ['_id' => 3], - ]; - $this->assertMatchesDocument($expectedResult, $changeStream->current()); + $this->assertMatchesDocument(['_id' => 3, 'x' => 'baz'], $changeStream->current()->fullDocument); } - public function testResumeAfterOption(): void + /** @dataProvider provideCodecOptions */ + public function testResumeAfterOption(array $options, Closure $getIdentifier): void { - $operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $this->defaultOptions); + $operation = new Watch( + $this->manager, + $this->getDatabaseName(), + $this->getCollectionName(), + [], + $options + $this->defaultOptions, + ); $changeStream = $operation->execute($this->getPrimaryServer()); $changeStream->rewind(); @@ -914,10 +981,15 @@ public function testResumeAfterOption(): void $this->advanceCursorUntilValid($changeStream); - $resumeToken = $changeStream->current()->_id; + $resumeToken = $getIdentifier($changeStream->current()); - $options = $this->defaultOptions + ['resumeAfter' => $resumeToken]; - $operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $options); + $operation = new Watch( + $this->manager, + $this->getDatabaseName(), + $this->getCollectionName(), + [], + ['resumeAfter' => $resumeToken] + $options + $this->defaultOptions, + ); $changeStream = $operation->execute($this->getPrimaryServer()); $this->assertSameDocument($resumeToken, $changeStream->getResumeToken()); @@ -932,22 +1004,21 @@ public function testResumeAfterOption(): void $this->assertTrue($changeStream->valid()); } - $expectedResult = [ - '_id' => $changeStream->current()->_id, - 'operationType' => 'insert', - 'fullDocument' => ['_id' => 2, 'x' => 'bar'], - 'ns' => ['db' => $this->getDatabaseName(), 'coll' => $this->getCollectionName()], - 'documentKey' => ['_id' => 2], - ]; - - $this->assertMatchesDocument($expectedResult, $changeStream->current()); + $this->assertMatchesDocument(['_id' => 2, 'x' => 'bar'], $changeStream->current()->fullDocument); } - public function testStartAfterOption(): void + /** @dataProvider provideCodecOptions */ + public function testStartAfterOption(array $options, Closure $getIdentifier): void { $this->skipIfServerVersion('<', '4.1.1', 'startAfter is not supported'); - $operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $this->defaultOptions); + $operation = new Watch( + $this->manager, + $this->getDatabaseName(), + $this->getCollectionName(), + [], + $options + $this->defaultOptions, + ); $changeStream = $operation->execute($this->getPrimaryServer()); $changeStream->rewind(); @@ -958,10 +1029,15 @@ public function testStartAfterOption(): void $this->advanceCursorUntilValid($changeStream); - $resumeToken = $changeStream->current()->_id; + $resumeToken = $getIdentifier($changeStream->current()); - $options = $this->defaultOptions + ['startAfter' => $resumeToken]; - $operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $options); + $operation = new Watch( + $this->manager, + $this->getDatabaseName(), + $this->getCollectionName(), + [], + ['startAfter' => $resumeToken] + $options + $this->defaultOptions, + ); $changeStream = $operation->execute($this->getPrimaryServer()); $this->assertSameDocument($resumeToken, $changeStream->getResumeToken()); @@ -976,15 +1052,7 @@ public function testStartAfterOption(): void $this->assertTrue($changeStream->valid()); } - $expectedResult = [ - '_id' => $changeStream->current()->_id, - 'operationType' => 'insert', - 'fullDocument' => ['_id' => 2, 'x' => 'bar'], - 'ns' => ['db' => $this->getDatabaseName(), 'coll' => $this->getCollectionName()], - 'documentKey' => ['_id' => 2], - ]; - - $this->assertMatchesDocument($expectedResult, $changeStream->current()); + $this->assertMatchesDocument(['_id' => 2, 'x' => 'bar'], $changeStream->current()->fullDocument); } /** @dataProvider provideTypeMapOptionsAndExpectedChangeDocument */ @@ -1368,13 +1436,21 @@ public function testGetResumeTokenReturnsOriginalResumeTokenOnEmptyBatch(): void * - getResumeToken must return startAfter from the initial aggregate if the option was specified. * - getResumeToken must return resumeAfter from the initial aggregate if the option was specified. * - If neither the startAfter nor resumeAfter options were specified, the getResumeToken result must be empty. + * + * @dataProvider provideCodecOptions */ - public function testResumeTokenBehaviour(): void + public function testResumeTokenBehaviour(array $options): void { $this->skipIfServerVersion('<', '4.1.1', 'Testing resumeAfter and startAfter can only be tested on servers >= 4.1.1'); $this->skipIfIsShardedCluster('Resume token behaviour can\'t be reliably tested on sharded clusters.'); - $operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $this->defaultOptions); + $operation = new Watch( + $this->manager, + $this->getDatabaseName(), + $this->getCollectionName(), + [], + $options + $this->defaultOptions, + ); $lastOpTime = null; @@ -1398,22 +1474,37 @@ public function testResumeTokenBehaviour(): void $this->insertDocument(['x' => 2]); // Test startAfter option - $options = ['startAfter' => $resumeToken] + $this->defaultOptions; - $operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $options); + $operation = new Watch( + $this->manager, + $this->getDatabaseName(), + $this->getCollectionName(), + [], + ['startAfter' => $resumeToken] + $options + $this->defaultOptions, + ); $changeStream = $operation->execute($this->getPrimaryServer()); $this->assertEquals($resumeToken, $changeStream->getResumeToken()); // Test resumeAfter option - $options = ['resumeAfter' => $resumeToken] + $this->defaultOptions; - $operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $options); + $operation = new Watch( + $this->manager, + $this->getDatabaseName(), + $this->getCollectionName(), + [], + ['resumeAfter' => $resumeToken] + $options + $this->defaultOptions, + ); $changeStream = $operation->execute($this->getPrimaryServer()); $this->assertEquals($resumeToken, $changeStream->getResumeToken()); // Test without option - $options = ['startAtOperationTime' => $lastOpTime] + $this->defaultOptions; - $operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $options); + $operation = new Watch( + $this->manager, + $this->getDatabaseName(), + $this->getCollectionName(), + [], + ['startAtOperationTime' => $lastOpTime] + $options + $this->defaultOptions, + ); $changeStream = $operation->execute($this->getPrimaryServer()); $this->assertNull($changeStream->getResumeToken()); diff --git a/tests/Operation/WatchTest.php b/tests/Operation/WatchTest.php index 1a9e5fa0e..fd97a1c5e 100644 --- a/tests/Operation/WatchTest.php +++ b/tests/Operation/WatchTest.php @@ -4,6 +4,7 @@ use MongoDB\Exception\InvalidArgumentException; use MongoDB\Operation\Watch; +use MongoDB\Tests\Fixtures\Codec\TestDocumentCodec; use stdClass; /** @@ -42,6 +43,7 @@ public function provideInvalidConstructorOptions() { return $this->createOptionDataProvider([ 'batchSize' => $this->getInvalidIntegerValues(), + 'codec' => $this->getInvalidDocumentCodecValues(), 'collation' => $this->getInvalidDocumentValues(), 'fullDocument' => $this->getInvalidStringValues(true), 'fullDocumentBeforeChange' => $this->getInvalidStringValues(), @@ -56,6 +58,14 @@ public function provideInvalidConstructorOptions() ]); } + public function testConstructorRejectsCodecAndTypemap(): void + { + $this->expectExceptionObject(InvalidArgumentException::cannotCombineCodecAndTypeMap()); + + $options = ['codec' => new TestDocumentCodec(), 'typeMap' => ['root' => 'array']]; + new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $options); + } + private function getInvalidTimestampValues() { return [123, 3.14, 'foo', true, [], new stdClass()]; diff --git a/tests/TestCase.php b/tests/TestCase.php index a5d20b837..9b2f69982 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,6 +4,7 @@ use InvalidArgumentException; use MongoDB\BSON\PackedArray; +use MongoDB\Codec\Codec; use MongoDB\Driver\ReadConcern; use MongoDB\Driver\ReadPreference; use MongoDB\Driver\WriteConcern; @@ -248,6 +249,11 @@ protected function getInvalidDocumentValues(bool $includeNull = false): array return array_merge([123, 3.14, 'foo', true, PackedArray::fromPHP([])], $includeNull ? [null] : []); } + protected function getInvalidDocumentCodecValues(): array + { + return [123, 3.14, 'foo', true, [], new stdClass(), $this->createMock(Codec::class)]; + } + /** * Return a list of invalid hint values. */