From aefd02a8016799e40f31935f3e1efe073b0946dc Mon Sep 17 00:00:00 2001 From: Prashant Mital Date: Thu, 13 Jun 2019 19:59:20 -0700 Subject: [PATCH] PYTHON-1798 Support pipelines in update commands --- doc/changelog.rst | 6 + pymongo/collection.py | 8 + pymongo/common.py | 20 ++- pymongo/operations.py | 4 + test/crud/v2/updateWithPipelines.json | 239 ++++++++++++++++++++++++++ test/test_bulk.py | 25 ++- 6 files changed, 290 insertions(+), 12 deletions(-) create mode 100644 test/crud/v2/updateWithPipelines.json diff --git a/doc/changelog.rst b/doc/changelog.rst index 8ea99c26c..13bedba82 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -51,6 +51,12 @@ Version 3.9 adds support for MongoDB 4.2. Highlights include: :mod:`~pymongo.monitoring` for an example. - :meth:`pymongo.collection.Collection.aggregate` and :meth:`pymongo.database.Database.aggregate` now support the ``$merge`` pipeline +- Support for specifying a pipeline or document in + :meth:`~pymongo.collection.Collection.update_one`, + :meth:`~pymongo.collection.Collection.update_many`, + :meth:`~pymongo.collection.Collection.find_one_and_update`, + :meth:`~pymongo.operations.UpdateOne`, and + :meth:`~pymongo.operations.UpdateMany`. .. _URI options specification: https://github.com/mongodb/specifications/blob/master/source/uri-options/uri-options.rst diff --git a/pymongo/collection.py b/pymongo/collection.py index 7d75e43dd..f91fa8c00 100644 --- a/pymongo/collection.py +++ b/pymongo/collection.py @@ -974,6 +974,9 @@ class Collection(common.BaseObject): .. note:: `bypass_document_validation` requires server version **>= 3.2** + .. versionchanged:: 3.9 + Added the ability to accept a pipeline as the `update`. + .. versionchanged:: 3.6 Added the `array_filters` and ``session`` parameters. @@ -1044,6 +1047,9 @@ class Collection(common.BaseObject): .. note:: `bypass_document_validation` requires server version **>= 3.2** + .. versionchanged:: 3.9 + Added the ability to accept a pipeline as the `update`. + .. versionchanged:: 3.6 Added ``array_filters`` and ``session`` parameters. @@ -3087,6 +3093,8 @@ class Collection(common.BaseObject): as keyword arguments (for example maxTimeMS can be used with recent server versions). + .. versionchanged:: 3.9 + Added the ability to accept a pipeline as the `update`. .. versionchanged:: 3.6 Added the `array_filters` and `session` options. .. versionchanged:: 3.4 diff --git a/pymongo/common.py b/pymongo/common.py index df8434c06..a692a9fee 100644 --- a/pymongo/common.py +++ b/pymongo/common.py @@ -457,11 +457,19 @@ def validate_list_or_none(option, value): return validate_list(option, value) +def validate_list_or_mapping(option, value): + """Validates that 'value' is a list or a document.""" + if not isinstance(value, (abc.Mapping, list)): + raise TypeError("%s must either be a list or an instance of dict, " + "bson.son.SON, or any other type that inherits from " + "collections.Mapping" % (option,)) + + def validate_is_mapping(option, value): """Validate the type of method arguments that expect a document.""" if not isinstance(value, abc.Mapping): raise TypeError("%s must be an instance of dict, bson.son.SON, or " - "other type that inherits from " + "any other type that inherits from " "collections.Mapping" % (option,)) @@ -515,12 +523,14 @@ def validate_ok_for_replace(replacement): def validate_ok_for_update(update): """Validate an update document.""" - validate_is_mapping("update", update) - # Update can not be {} + validate_list_or_mapping("update", update) + # Update cannot be {}. if not update: - raise ValueError('update only works with $ operators') + raise ValueError('update cannot be empty') + + is_document = not isinstance(update, list) first = next(iter(update)) - if not first.startswith('$'): + if is_document and not first.startswith('$'): raise ValueError('update only works with $ operators') diff --git a/pymongo/operations.py b/pymongo/operations.py index acec05e1f..6abefc6cc 100644 --- a/pymongo/operations.py +++ b/pymongo/operations.py @@ -243,6 +243,8 @@ class UpdateOne(_UpdateOp): - `array_filters` (optional): A list of filters specifying which array elements an update should apply. Requires MongoDB 3.6+. + .. versionchanged:: 3.9 + Added the ability to accept a pipeline as the `update`. .. versionchanged:: 3.6 Added the `array_filters` option. .. versionchanged:: 3.5 @@ -280,6 +282,8 @@ class UpdateMany(_UpdateOp): - `array_filters` (optional): A list of filters specifying which array elements an update should apply. Requires MongoDB 3.6+. + .. versionchanged:: 3.9 + Added the ability to accept a pipeline as the `update`. .. versionchanged:: 3.6 Added the `array_filters` option. .. versionchanged:: 3.5 diff --git a/test/crud/v2/updateWithPipelines.json b/test/crud/v2/updateWithPipelines.json new file mode 100644 index 000000000..02286b1a2 --- /dev/null +++ b/test/crud/v2/updateWithPipelines.json @@ -0,0 +1,239 @@ +{ + "data": [ + { + "_id": 1, + "x": 1, + "y": 1, + "t": { + "u": { + "v": 1 + } + } + }, + { + "_id": 2, + "x": 2, + "y": 1 + } + ], + "minServerVersion": "4.1.11", + "collection_name": "test", + "database_name": "crud-tests", + "tests": [ + { + "description": "UpdateOne using pipelines", + "operations": [ + { + "name": "updateOne", + "arguments": { + "filter": { + "_id": 1 + }, + "update": [ + { + "$replaceRoot": { + "newRoot": "$t" + } + }, + { + "$addFields": { + "foo": 1 + } + } + ] + }, + "result": { + "matchedCount": 1, + "modifiedCount": 1, + "upsertedCount": 0 + } + } + ], + "expectations": [ + { + "command_started_event": { + "command": { + "update": "test", + "updates": [ + { + "q": { + "_id": 1 + }, + "u": [ + { + "$replaceRoot": { + "newRoot": "$t" + } + }, + { + "$addFields": { + "foo": 1 + } + } + ] + } + ] + }, + "command_name": "update", + "database_name": "crud-tests" + } + } + ], + "outcome": { + "collection": { + "data": [ + { + "_id": 1, + "u": { + "v": 1 + }, + "foo": 1 + }, + { + "_id": 2, + "x": 2, + "y": 1 + } + ] + } + } + }, + { + "description": "UpdateMany using pipelines", + "operations": [ + { + "name": "updateMany", + "arguments": { + "filter": {}, + "update": [ + { + "$project": { + "x": 1 + } + }, + { + "$addFields": { + "foo": 1 + } + } + ] + }, + "result": { + "matchedCount": 2, + "modifiedCount": 2, + "upsertedCount": 0 + } + } + ], + "expectations": [ + { + "command_started_event": { + "command": { + "update": "test", + "updates": [ + { + "q": {}, + "u": [ + { + "$project": { + "x": 1 + } + }, + { + "$addFields": { + "foo": 1 + } + } + ], + "multi": true + } + ] + }, + "command_name": "update", + "database_name": "crud-tests" + } + } + ], + "outcome": { + "collection": { + "data": [ + { + "_id": 1, + "x": 1, + "foo": 1 + }, + { + "_id": 2, + "x": 2, + "foo": 1 + } + ] + } + } + }, + { + "description": "FindOneAndUpdate using pipelines", + "operations": [ + { + "name": "findOneAndUpdate", + "arguments": { + "filter": { + "_id": 1 + }, + "update": [ + { + "$project": { + "x": 1 + } + }, + { + "$addFields": { + "foo": 1 + } + } + ] + } + } + ], + "expectations": [ + { + "command_started_event": { + "command": { + "findAndModify": "test", + "update": [ + { + "$project": { + "x": 1 + } + }, + { + "$addFields": { + "foo": 1 + } + } + ] + }, + "command_name": "findAndModify", + "database_name": "crud-tests" + } + } + ], + "outcome": { + "collection": { + "data": [ + { + "_id": 1, + "x": 1, + "foo": 1 + }, + { + "_id": 2, + "x": 2, + "y": 1 + } + ] + } + } + } + ] +} diff --git a/test/test_bulk.py b/test/test_bulk.py index dd62e7fea..81bb35c33 100644 --- a/test/test_bulk.py +++ b/test/test_bulk.py @@ -139,7 +139,7 @@ class TestBulk(BulkTestBase): self.assertEqual(1, result.inserted_count) self.assertEqual(1, self.coll.count_documents({})) - def test_update_many(self): + def _test_update_many(self, update): expected = { 'nMatched': 2, @@ -153,12 +153,18 @@ class TestBulk(BulkTestBase): } self.coll.insert_many([{}, {}]) - result = self.coll.bulk_write([UpdateMany({}, - {'$set': {'foo': 'bar'}})]) + result = self.coll.bulk_write([UpdateMany({}, update)]) self.assertEqualResponse(expected, result.bulk_api_result) self.assertEqual(2, result.matched_count) self.assertTrue(result.modified_count in (2, None)) + def test_update_many(self): + self._test_update_many({'$set': {'foo': 'bar'}}) + + @client_context.require_version_min(4, 1, 11) + def test_update_many_pipeline(self): + self._test_update_many([{'$set': {'foo': 'bar'}}]) + @client_context.require_version_max(3, 5, 5) def test_array_filters_unsupported(self): requests = [ @@ -184,8 +190,7 @@ class TestBulk(BulkTestBase): self.assertRaises(ConfigurationError, coll.bulk_write, [update_one]) self.assertRaises(ConfigurationError, coll.bulk_write, [update_many]) - def test_update_one(self): - + def _test_update_one(self, update): expected = { 'nMatched': 1, 'nModified': 1, @@ -199,12 +204,18 @@ class TestBulk(BulkTestBase): self.coll.insert_many([{}, {}]) - result = self.coll.bulk_write([UpdateOne({}, - {'$set': {'foo': 'bar'}})]) + result = self.coll.bulk_write([UpdateOne({}, update)]) self.assertEqualResponse(expected, result.bulk_api_result) self.assertEqual(1, result.matched_count) self.assertTrue(result.modified_count in (1, None)) + def test_update_one(self): + self._test_update_one({'$set': {'foo': 'bar'}}) + + @client_context.require_version_min(4, 1, 11) + def test_update_one_pipeline(self): + self._test_update_one([{'$set': {'foo': 'bar'}}]) + def test_replace_one(self): expected = {