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. */