From 2b1fdb091d61d45d6b04d1baa3f8d95da54aa200 Mon Sep 17 00:00:00 2001 From: Prashant Mital <5883388+prashantmital@users.noreply.github.com> Date: Wed, 20 Feb 2019 16:21:39 -0800 Subject: [PATCH] SPEC 1153: Adding readConcern support to aggregation with $out (#400) --- pymongo/collection.py | 12 +- .../{ => v1}/read/aggregate-collation.json | 0 test/crud/{ => v1}/read/aggregate-out.json | 0 test/crud/{ => v1}/read/aggregate.json | 0 test/crud/{ => v1}/read/count-collation.json | 0 test/crud/v1/read/count-empty.json | 39 + test/crud/{ => v1}/read/count.json | 2 +- .../{ => v1}/read/distinct-collation.json | 0 test/crud/{ => v1}/read/distinct.json | 0 test/crud/{ => v1}/read/find-collation.json | 0 test/crud/{ => v1}/read/find.json | 0 .../write/bulkWrite-arrayFilters.json | 63 +- test/crud/v1/write/bulkWrite-collation.json | 217 +++++ test/crud/v1/write/bulkWrite.json | 778 ++++++++++++++++++ .../{ => v1}/write/deleteMany-collation.json | 0 test/crud/{ => v1}/write/deleteMany.json | 0 .../{ => v1}/write/deleteOne-collation.json | 0 test/crud/{ => v1}/write/deleteOne.json | 0 .../write/findOneAndDelete-collation.json | 0 .../crud/{ => v1}/write/findOneAndDelete.json | 0 .../write/findOneAndReplace-collation.json | 0 .../write/findOneAndReplace-upsert.json | 0 .../{ => v1}/write/findOneAndReplace.json | 0 .../write/findOneAndUpdate-arrayFilters.json | 0 .../write/findOneAndUpdate-collation.json | 0 .../crud/{ => v1}/write/findOneAndUpdate.json | 0 test/crud/v1/write/insertMany.json | 159 ++++ test/crud/{ => v1}/write/insertOne.json | 0 .../{ => v1}/write/replaceOne-collation.json | 0 test/crud/{ => v1}/write/replaceOne.json | 0 .../write/updateMany-arrayFilters.json | 9 +- .../{ => v1}/write/updateMany-collation.json | 0 test/crud/{ => v1}/write/updateMany.json | 0 .../write/updateOne-arrayFilters.json | 15 +- .../{ => v1}/write/updateOne-collation.json | 0 test/crud/{ => v1}/write/updateOne.json | 0 test/crud/v2/aggregate-out-readConcern.json | 377 +++++++++ test/crud/v2/bulkWrite-arrayFilters.json | 156 ++++ test/crud/write/insertMany.json | 52 -- test/{test_crud.py => test_crud_v1.py} | 95 +-- test/test_crud_v2.py | 258 ++++++ test/test_read_concern.py | 14 +- test/test_retryable_writes.py | 2 +- test/test_session.py | 10 +- test/test_transactions.py | 97 +-- test/utils.py | 126 ++- 46 files changed, 2223 insertions(+), 258 deletions(-) rename test/crud/{ => v1}/read/aggregate-collation.json (100%) rename test/crud/{ => v1}/read/aggregate-out.json (100%) rename test/crud/{ => v1}/read/aggregate.json (100%) rename test/crud/{ => v1}/read/count-collation.json (100%) create mode 100644 test/crud/v1/read/count-empty.json rename test/crud/{ => v1}/read/count.json (98%) rename test/crud/{ => v1}/read/distinct-collation.json (100%) rename test/crud/{ => v1}/read/distinct.json (100%) rename test/crud/{ => v1}/read/find-collation.json (100%) rename test/crud/{ => v1}/read/find.json (100%) rename test/crud/{ => v1}/write/bulkWrite-arrayFilters.json (87%) create mode 100644 test/crud/v1/write/bulkWrite-collation.json create mode 100644 test/crud/v1/write/bulkWrite.json rename test/crud/{ => v1}/write/deleteMany-collation.json (100%) rename test/crud/{ => v1}/write/deleteMany.json (100%) rename test/crud/{ => v1}/write/deleteOne-collation.json (100%) rename test/crud/{ => v1}/write/deleteOne.json (100%) rename test/crud/{ => v1}/write/findOneAndDelete-collation.json (100%) rename test/crud/{ => v1}/write/findOneAndDelete.json (100%) rename test/crud/{ => v1}/write/findOneAndReplace-collation.json (100%) rename test/crud/{ => v1}/write/findOneAndReplace-upsert.json (100%) rename test/crud/{ => v1}/write/findOneAndReplace.json (100%) rename test/crud/{ => v1}/write/findOneAndUpdate-arrayFilters.json (100%) rename test/crud/{ => v1}/write/findOneAndUpdate-collation.json (100%) rename test/crud/{ => v1}/write/findOneAndUpdate.json (100%) create mode 100644 test/crud/v1/write/insertMany.json rename test/crud/{ => v1}/write/insertOne.json (100%) rename test/crud/{ => v1}/write/replaceOne-collation.json (100%) rename test/crud/{ => v1}/write/replaceOne.json (100%) rename test/crud/{ => v1}/write/updateMany-arrayFilters.json (94%) rename test/crud/{ => v1}/write/updateMany-collation.json (100%) rename test/crud/{ => v1}/write/updateMany.json (100%) rename test/crud/{ => v1}/write/updateOne-arrayFilters.json (96%) rename test/crud/{ => v1}/write/updateOne-collation.json (100%) rename test/crud/{ => v1}/write/updateOne.json (100%) create mode 100644 test/crud/v2/aggregate-out-readConcern.json create mode 100644 test/crud/v2/bulkWrite-arrayFilters.json delete mode 100644 test/crud/write/insertMany.json rename test/{test_crud.py => test_crud_v1.py} (76%) create mode 100644 test/test_crud_v2.py diff --git a/pymongo/collection.py b/pymongo/collection.py index 26a497876..15353644e 100644 --- a/pymongo/collection.py +++ b/pymongo/collection.py @@ -2276,11 +2276,13 @@ class Collection(common.BaseObject): kwargs["cursor"]["batchSize"] = first_batch_size cmd.update(kwargs) - # Apply this Collection's read concern if $out is not in the - # pipeline. - if (sock_info.max_wire_version >= 4 - and 'readConcern' not in cmd - and not dollar_out): + # Apply this Collection's read concern if + # readConcern has not been specified as a kwarg and either + # - server version is >= 4.2 or + # - server version is >= 3.2 and pipeline doesn't use $out + if (('readConcern' not in cmd) and + ((sock_info.max_wire_version >= 4 and not dollar_out) or + (sock_info.max_wire_version >= 8))): read_concern = self.read_concern else: read_concern = None diff --git a/test/crud/read/aggregate-collation.json b/test/crud/v1/read/aggregate-collation.json similarity index 100% rename from test/crud/read/aggregate-collation.json rename to test/crud/v1/read/aggregate-collation.json diff --git a/test/crud/read/aggregate-out.json b/test/crud/v1/read/aggregate-out.json similarity index 100% rename from test/crud/read/aggregate-out.json rename to test/crud/v1/read/aggregate-out.json diff --git a/test/crud/read/aggregate.json b/test/crud/v1/read/aggregate.json similarity index 100% rename from test/crud/read/aggregate.json rename to test/crud/v1/read/aggregate.json diff --git a/test/crud/read/count-collation.json b/test/crud/v1/read/count-collation.json similarity index 100% rename from test/crud/read/count-collation.json rename to test/crud/v1/read/count-collation.json diff --git a/test/crud/v1/read/count-empty.json b/test/crud/v1/read/count-empty.json new file mode 100644 index 000000000..2b8627e0c --- /dev/null +++ b/test/crud/v1/read/count-empty.json @@ -0,0 +1,39 @@ +{ + "data": [], + "tests": [ + { + "description": "Estimated document count with empty collection", + "operation": { + "name": "estimatedDocumentCount", + "arguments": {} + }, + "outcome": { + "result": 0 + } + }, + { + "description": "Count documents with empty collection", + "operation": { + "name": "countDocuments", + "arguments": { + "filter": {} + } + }, + "outcome": { + "result": 0 + } + }, + { + "description": "Deprecated count with empty collection", + "operation": { + "name": "count", + "arguments": { + "filter": {} + } + }, + "outcome": { + "result": 0 + } + } + ] +} diff --git a/test/crud/read/count.json b/test/crud/v1/read/count.json similarity index 98% rename from test/crud/read/count.json rename to test/crud/v1/read/count.json index e66cc14a2..9642b2fbd 100644 --- a/test/crud/read/count.json +++ b/test/crud/v1/read/count.json @@ -97,7 +97,7 @@ { "description": "Deprecated count with skip and limit", "operation": { - "name": "countDocuments", + "name": "count", "arguments": { "filter": {}, "skip": 1, diff --git a/test/crud/read/distinct-collation.json b/test/crud/v1/read/distinct-collation.json similarity index 100% rename from test/crud/read/distinct-collation.json rename to test/crud/v1/read/distinct-collation.json diff --git a/test/crud/read/distinct.json b/test/crud/v1/read/distinct.json similarity index 100% rename from test/crud/read/distinct.json rename to test/crud/v1/read/distinct.json diff --git a/test/crud/read/find-collation.json b/test/crud/v1/read/find-collation.json similarity index 100% rename from test/crud/read/find-collation.json rename to test/crud/v1/read/find-collation.json diff --git a/test/crud/read/find.json b/test/crud/v1/read/find.json similarity index 100% rename from test/crud/read/find.json rename to test/crud/v1/read/find.json diff --git a/test/crud/write/bulkWrite-arrayFilters.json b/test/crud/v1/write/bulkWrite-arrayFilters.json similarity index 87% rename from test/crud/write/bulkWrite-arrayFilters.json rename to test/crud/v1/write/bulkWrite-arrayFilters.json index 90f32a6fa..99e73f5d7 100644 --- a/test/crud/write/bulkWrite-arrayFilters.json +++ b/test/crud/v1/write/bulkWrite-arrayFilters.json @@ -28,48 +28,57 @@ { "description": "BulkWrite with arrayFilters", "operation": { + "name": "bulkWrite", "arguments": { - "options": { - "ordered": true - }, "requests": [ { + "name": "updateOne", "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].b": 2 + } + }, "arrayFilters": [ { "i.b": 3 } - ], + ] + } + }, + { + "name": "updateMany", + "arguments": { "filter": {}, "update": { "$set": { "y.$[i].b": 2 } - } - }, - "name": "updateOne" - }, - { - "arguments": { + }, "arrayFilters": [ { "i.b": 1 } - ], - "filter": {}, - "update": { - "$set": { - "y.$[i].b": 2 - } - } - }, - "name": "updateMany" + ] + } } - ] - }, - "name": "bulkWrite" + ], + "options": { + "ordered": true + } + } }, "outcome": { + "result": { + "deletedCount": 0, + "insertedCount": 0, + "insertedIds": {}, + "matchedCount": 3, + "modifiedCount": 3, + "upsertedCount": 0, + "upsertedIds": {} + }, "collection": { "data": [ { @@ -95,16 +104,8 @@ ] } ] - }, - "result": { - "deletedCount": 0, - "insertedIds": {}, - "matchedCount": 3, - "modifiedCount": 3, - "upsertedCount": 0, - "upsertedIds": {} } } } ] -} \ No newline at end of file +} diff --git a/test/crud/v1/write/bulkWrite-collation.json b/test/crud/v1/write/bulkWrite-collation.json new file mode 100644 index 000000000..8e9d1bcb1 --- /dev/null +++ b/test/crud/v1/write/bulkWrite-collation.json @@ -0,0 +1,217 @@ +{ + "data": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": "ping" + }, + { + "_id": 3, + "x": "pINg" + }, + { + "_id": 4, + "x": "pong" + }, + { + "_id": 5, + "x": "pONg" + } + ], + "minServerVersion": "3.4", + "tests": [ + { + "description": "BulkWrite with delete operations and collation", + "operation": { + "name": "bulkWrite", + "arguments": { + "requests": [ + { + "name": "deleteOne", + "arguments": { + "filter": { + "x": "PING" + }, + "collation": { + "locale": "en_US", + "strength": 2 + } + } + }, + { + "name": "deleteOne", + "arguments": { + "filter": { + "x": "PING" + }, + "collation": { + "locale": "en_US", + "strength": 2 + } + } + }, + { + "name": "deleteMany", + "arguments": { + "filter": { + "x": "PONG" + }, + "collation": { + "locale": "en_US", + "strength": 2 + } + } + } + ], + "options": { + "ordered": true + } + } + }, + "outcome": { + "result": { + "deletedCount": 4, + "insertedCount": 0, + "insertedIds": {}, + "matchedCount": 0, + "modifiedCount": 0, + "upsertedCount": 0, + "upsertedIds": {} + }, + "collection": { + "data": [ + { + "_id": 1, + "x": 11 + } + ] + } + } + }, + { + "description": "BulkWrite with update operations and collation", + "operation": { + "name": "bulkWrite", + "arguments": { + "requests": [ + { + "name": "updateMany", + "arguments": { + "filter": { + "x": "ping" + }, + "update": { + "$set": { + "x": "PONG" + } + }, + "collation": { + "locale": "en_US", + "strength": 3 + } + } + }, + { + "name": "updateOne", + "arguments": { + "filter": { + "x": "ping" + }, + "update": { + "$set": { + "x": "PONG" + } + }, + "collation": { + "locale": "en_US", + "strength": 2 + } + } + }, + { + "name": "replaceOne", + "arguments": { + "filter": { + "x": "ping" + }, + "replacement": { + "_id": 6, + "x": "ping" + }, + "upsert": true, + "collation": { + "locale": "en_US", + "strength": 3 + } + } + }, + { + "name": "updateMany", + "arguments": { + "filter": { + "x": "pong" + }, + "update": { + "$set": { + "x": "PONG" + } + }, + "collation": { + "locale": "en_US", + "strength": 2 + } + } + } + ], + "options": { + "ordered": true + } + } + }, + "outcome": { + "result": { + "deletedCount": 0, + "insertedCount": 0, + "insertedIds": {}, + "matchedCount": 6, + "modifiedCount": 4, + "upsertedCount": 1, + "upsertedIds": { + "2": 6 + } + }, + "collection": { + "data": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": "PONG" + }, + { + "_id": 3, + "x": "PONG" + }, + { + "_id": 4, + "x": "PONG" + }, + { + "_id": 5, + "x": "PONG" + }, + { + "_id": 6, + "x": "ping" + } + ] + } + } + } + ] +} diff --git a/test/crud/v1/write/bulkWrite.json b/test/crud/v1/write/bulkWrite.json new file mode 100644 index 000000000..dc00da28a --- /dev/null +++ b/test/crud/v1/write/bulkWrite.json @@ -0,0 +1,778 @@ +{ + "data": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + } + ], + "minServerVersion": "2.6", + "tests": [ + { + "description": "BulkWrite with deleteOne operations", + "operation": { + "name": "bulkWrite", + "arguments": { + "requests": [ + { + "name": "deleteOne", + "arguments": { + "filter": { + "_id": 3 + } + } + }, + { + "name": "deleteOne", + "arguments": { + "filter": { + "_id": 2 + } + } + } + ], + "options": { + "ordered": true + } + } + }, + "outcome": { + "result": { + "deletedCount": 1, + "insertedCount": 0, + "insertedIds": {}, + "matchedCount": 0, + "modifiedCount": 0, + "upsertedCount": 0, + "upsertedIds": {} + }, + "collection": { + "data": [ + { + "_id": 1, + "x": 11 + } + ] + } + } + }, + { + "description": "BulkWrite with deleteMany operations", + "operation": { + "name": "bulkWrite", + "arguments": { + "requests": [ + { + "name": "deleteMany", + "arguments": { + "filter": { + "x": { + "$lt": 11 + } + } + } + }, + { + "name": "deleteMany", + "arguments": { + "filter": { + "x": { + "$lte": 22 + } + } + } + } + ], + "options": { + "ordered": true + } + } + }, + "outcome": { + "result": { + "deletedCount": 2, + "insertedCount": 0, + "insertedIds": {}, + "matchedCount": 0, + "modifiedCount": 0, + "upsertedCount": 0, + "upsertedIds": {} + }, + "collection": { + "data": [] + } + } + }, + { + "description": "BulkWrite with insertOne operations", + "operation": { + "name": "bulkWrite", + "arguments": { + "requests": [ + { + "name": "insertOne", + "arguments": { + "document": { + "_id": 3, + "x": 33 + } + } + }, + { + "name": "insertOne", + "arguments": { + "document": { + "_id": 4, + "x": 44 + } + } + } + ], + "options": { + "ordered": true + } + } + }, + "outcome": { + "result": { + "deletedCount": 0, + "insertedCount": 2, + "insertedIds": { + "0": 3, + "1": 4 + }, + "matchedCount": 0, + "modifiedCount": 0, + "upsertedCount": 0, + "upsertedIds": {} + }, + "collection": { + "data": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + } + ] + } + } + }, + { + "description": "BulkWrite with replaceOne operations", + "operation": { + "name": "bulkWrite", + "arguments": { + "requests": [ + { + "name": "replaceOne", + "arguments": { + "filter": { + "_id": 3 + }, + "replacement": { + "x": 33 + } + } + }, + { + "name": "replaceOne", + "arguments": { + "filter": { + "_id": 1 + }, + "replacement": { + "x": 12 + } + } + }, + { + "name": "replaceOne", + "arguments": { + "filter": { + "_id": 3 + }, + "replacement": { + "x": 33 + }, + "upsert": true + } + } + ], + "options": { + "ordered": true + } + } + }, + "outcome": { + "result": { + "deletedCount": 0, + "insertedCount": 0, + "insertedIds": {}, + "matchedCount": 1, + "modifiedCount": 1, + "upsertedCount": 1, + "upsertedIds": { + "2": 3 + } + }, + "collection": { + "data": [ + { + "_id": 1, + "x": 12 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + } + }, + { + "description": "BulkWrite with updateOne operations", + "operation": { + "name": "bulkWrite", + "arguments": { + "requests": [ + { + "name": "updateOne", + "arguments": { + "filter": { + "_id": 0 + }, + "update": { + "$set": { + "x": 0 + } + } + } + }, + { + "name": "updateOne", + "arguments": { + "filter": { + "_id": 1 + }, + "update": { + "$set": { + "x": 11 + } + } + } + }, + { + "name": "updateOne", + "arguments": { + "filter": { + "_id": 2 + }, + "update": { + "$inc": { + "x": 1 + } + } + } + }, + { + "name": "updateOne", + "arguments": { + "filter": { + "_id": 3 + }, + "update": { + "$set": { + "x": 33 + } + }, + "upsert": true + } + } + ], + "options": { + "ordered": true + } + } + }, + "outcome": { + "result": { + "deletedCount": 0, + "insertedCount": 0, + "insertedIds": {}, + "matchedCount": 2, + "modifiedCount": 1, + "upsertedCount": 1, + "upsertedIds": { + "3": 3 + } + }, + "collection": { + "data": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 23 + }, + { + "_id": 3, + "x": 33 + } + ] + } + } + }, + { + "description": "BulkWrite with updateMany operations", + "operation": { + "name": "bulkWrite", + "arguments": { + "requests": [ + { + "name": "updateMany", + "arguments": { + "filter": { + "x": { + "$lt": 11 + } + }, + "update": { + "$set": { + "x": 0 + } + } + } + }, + { + "name": "updateMany", + "arguments": { + "filter": { + "x": { + "$lte": 22 + } + }, + "update": { + "$unset": { + "y": 1 + } + } + } + }, + { + "name": "updateMany", + "arguments": { + "filter": { + "x": { + "$lte": 22 + } + }, + "update": { + "$inc": { + "x": 1 + } + } + } + }, + { + "name": "updateMany", + "arguments": { + "filter": { + "_id": 3 + }, + "update": { + "$set": { + "x": 33 + } + }, + "upsert": true + } + } + ], + "options": { + "ordered": true + } + } + }, + "outcome": { + "result": { + "deletedCount": 0, + "insertedCount": 0, + "insertedIds": {}, + "matchedCount": 4, + "modifiedCount": 2, + "upsertedCount": 1, + "upsertedIds": { + "3": 3 + } + }, + "collection": { + "data": [ + { + "_id": 1, + "x": 12 + }, + { + "_id": 2, + "x": 23 + }, + { + "_id": 3, + "x": 33 + } + ] + } + } + }, + { + "description": "BulkWrite with mixed ordered operations", + "operation": { + "name": "bulkWrite", + "arguments": { + "requests": [ + { + "name": "insertOne", + "arguments": { + "document": { + "_id": 3, + "x": 33 + } + } + }, + { + "name": "updateOne", + "arguments": { + "filter": { + "_id": 2 + }, + "update": { + "$inc": { + "x": 1 + } + } + } + }, + { + "name": "updateMany", + "arguments": { + "filter": { + "_id": { + "$gt": 1 + } + }, + "update": { + "$inc": { + "x": 1 + } + } + } + }, + { + "name": "insertOne", + "arguments": { + "document": { + "_id": 4, + "x": 44 + } + } + }, + { + "name": "deleteMany", + "arguments": { + "filter": { + "x": { + "$nin": [ + 24, + 34 + ] + } + } + } + }, + { + "name": "replaceOne", + "arguments": { + "filter": { + "_id": 4 + }, + "replacement": { + "_id": 4, + "x": 44 + }, + "upsert": true + } + } + ], + "options": { + "ordered": true + } + } + }, + "outcome": { + "result": { + "deletedCount": 2, + "insertedCount": 2, + "insertedIds": { + "0": 3, + "3": 4 + }, + "matchedCount": 3, + "modifiedCount": 3, + "upsertedCount": 1, + "upsertedIds": { + "5": 4 + } + }, + "collection": { + "data": [ + { + "_id": 2, + "x": 24 + }, + { + "_id": 3, + "x": 34 + }, + { + "_id": 4, + "x": 44 + } + ] + } + } + }, + { + "description": "BulkWrite with mixed unordered operations", + "operation": { + "name": "bulkWrite", + "arguments": { + "requests": [ + { + "name": "replaceOne", + "arguments": { + "filter": { + "_id": 3 + }, + "replacement": { + "_id": 3, + "x": 33 + }, + "upsert": true + } + }, + { + "name": "deleteOne", + "arguments": { + "filter": { + "_id": 1 + } + } + }, + { + "name": "updateOne", + "arguments": { + "filter": { + "_id": 2 + }, + "update": { + "$inc": { + "x": 1 + } + } + } + } + ], + "options": { + "ordered": false + } + } + }, + "outcome": { + "result": { + "deletedCount": 1, + "insertedCount": 0, + "insertedIds": {}, + "matchedCount": 1, + "modifiedCount": 1, + "upsertedCount": 1, + "upsertedIds": { + "0": 3 + } + }, + "collection": { + "data": [ + { + "_id": 2, + "x": 23 + }, + { + "_id": 3, + "x": 33 + } + ] + } + } + }, + { + "description": "BulkWrite continue-on-error behavior with unordered (preexisting duplicate key)", + "operation": { + "name": "bulkWrite", + "arguments": { + "requests": [ + { + "name": "insertOne", + "arguments": { + "document": { + "_id": 2, + "x": 22 + } + } + }, + { + "name": "insertOne", + "arguments": { + "document": { + "_id": 3, + "x": 33 + } + } + }, + { + "name": "insertOne", + "arguments": { + "document": { + "_id": 4, + "x": 44 + } + } + } + ], + "options": { + "ordered": false + } + } + }, + "outcome": { + "error": true, + "result": { + "deletedCount": 0, + "insertedCount": 2, + "matchedCount": 0, + "modifiedCount": 0, + "upsertedCount": 0, + "upsertedIds": {} + }, + "collection": { + "data": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + } + ] + } + } + }, + { + "description": "BulkWrite continue-on-error behavior with unordered (duplicate key in requests)", + "operation": { + "name": "bulkWrite", + "arguments": { + "requests": [ + { + "name": "insertOne", + "arguments": { + "document": { + "_id": 3, + "x": 33 + } + } + }, + { + "name": "insertOne", + "arguments": { + "document": { + "_id": 3, + "x": 33 + } + } + }, + { + "name": "insertOne", + "arguments": { + "document": { + "_id": 4, + "x": 44 + } + } + } + ], + "options": { + "ordered": false + } + } + }, + "outcome": { + "error": true, + "result": { + "deletedCount": 0, + "insertedCount": 2, + "matchedCount": 0, + "modifiedCount": 0, + "upsertedCount": 0, + "upsertedIds": {} + }, + "collection": { + "data": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + } + ] + } + } + } + ] +} diff --git a/test/crud/write/deleteMany-collation.json b/test/crud/v1/write/deleteMany-collation.json similarity index 100% rename from test/crud/write/deleteMany-collation.json rename to test/crud/v1/write/deleteMany-collation.json diff --git a/test/crud/write/deleteMany.json b/test/crud/v1/write/deleteMany.json similarity index 100% rename from test/crud/write/deleteMany.json rename to test/crud/v1/write/deleteMany.json diff --git a/test/crud/write/deleteOne-collation.json b/test/crud/v1/write/deleteOne-collation.json similarity index 100% rename from test/crud/write/deleteOne-collation.json rename to test/crud/v1/write/deleteOne-collation.json diff --git a/test/crud/write/deleteOne.json b/test/crud/v1/write/deleteOne.json similarity index 100% rename from test/crud/write/deleteOne.json rename to test/crud/v1/write/deleteOne.json diff --git a/test/crud/write/findOneAndDelete-collation.json b/test/crud/v1/write/findOneAndDelete-collation.json similarity index 100% rename from test/crud/write/findOneAndDelete-collation.json rename to test/crud/v1/write/findOneAndDelete-collation.json diff --git a/test/crud/write/findOneAndDelete.json b/test/crud/v1/write/findOneAndDelete.json similarity index 100% rename from test/crud/write/findOneAndDelete.json rename to test/crud/v1/write/findOneAndDelete.json diff --git a/test/crud/write/findOneAndReplace-collation.json b/test/crud/v1/write/findOneAndReplace-collation.json similarity index 100% rename from test/crud/write/findOneAndReplace-collation.json rename to test/crud/v1/write/findOneAndReplace-collation.json diff --git a/test/crud/write/findOneAndReplace-upsert.json b/test/crud/v1/write/findOneAndReplace-upsert.json similarity index 100% rename from test/crud/write/findOneAndReplace-upsert.json rename to test/crud/v1/write/findOneAndReplace-upsert.json diff --git a/test/crud/write/findOneAndReplace.json b/test/crud/v1/write/findOneAndReplace.json similarity index 100% rename from test/crud/write/findOneAndReplace.json rename to test/crud/v1/write/findOneAndReplace.json diff --git a/test/crud/write/findOneAndUpdate-arrayFilters.json b/test/crud/v1/write/findOneAndUpdate-arrayFilters.json similarity index 100% rename from test/crud/write/findOneAndUpdate-arrayFilters.json rename to test/crud/v1/write/findOneAndUpdate-arrayFilters.json diff --git a/test/crud/write/findOneAndUpdate-collation.json b/test/crud/v1/write/findOneAndUpdate-collation.json similarity index 100% rename from test/crud/write/findOneAndUpdate-collation.json rename to test/crud/v1/write/findOneAndUpdate-collation.json diff --git a/test/crud/write/findOneAndUpdate.json b/test/crud/v1/write/findOneAndUpdate.json similarity index 100% rename from test/crud/write/findOneAndUpdate.json rename to test/crud/v1/write/findOneAndUpdate.json diff --git a/test/crud/v1/write/insertMany.json b/test/crud/v1/write/insertMany.json new file mode 100644 index 000000000..6a2e5261b --- /dev/null +++ b/test/crud/v1/write/insertMany.json @@ -0,0 +1,159 @@ +{ + "data": [ + { + "_id": 1, + "x": 11 + } + ], + "tests": [ + { + "description": "InsertMany with non-existing documents", + "operation": { + "name": "insertMany", + "arguments": { + "documents": [ + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ], + "options": { + "ordered": true + } + } + }, + "outcome": { + "result": { + "insertedIds": { + "0": 2, + "1": 3 + } + }, + "collection": { + "data": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + } + }, + { + "description": "InsertMany continue-on-error behavior with unordered (preexisting duplicate key)", + "operation": { + "name": "insertMany", + "arguments": { + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ], + "options": { + "ordered": false + } + } + }, + "outcome": { + "error": true, + "result": { + "deletedCount": 0, + "insertedCount": 2, + "matchedCount": 0, + "modifiedCount": 0, + "upsertedCount": 0, + "upsertedIds": {} + }, + "collection": { + "data": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + } + }, + { + "description": "InsertMany continue-on-error behavior with unordered (duplicate key in requests)", + "operation": { + "name": "insertMany", + "arguments": { + "documents": [ + { + "_id": 2, + "x": 22 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ], + "options": { + "ordered": false + } + } + }, + "outcome": { + "error": true, + "result": { + "deletedCount": 0, + "insertedCount": 2, + "matchedCount": 0, + "modifiedCount": 0, + "upsertedCount": 0, + "upsertedIds": {} + }, + "collection": { + "data": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + } + } + ] +} diff --git a/test/crud/write/insertOne.json b/test/crud/v1/write/insertOne.json similarity index 100% rename from test/crud/write/insertOne.json rename to test/crud/v1/write/insertOne.json diff --git a/test/crud/write/replaceOne-collation.json b/test/crud/v1/write/replaceOne-collation.json similarity index 100% rename from test/crud/write/replaceOne-collation.json rename to test/crud/v1/write/replaceOne-collation.json diff --git a/test/crud/write/replaceOne.json b/test/crud/v1/write/replaceOne.json similarity index 100% rename from test/crud/write/replaceOne.json rename to test/crud/v1/write/replaceOne.json diff --git a/test/crud/write/updateMany-arrayFilters.json b/test/crud/v1/write/updateMany-arrayFilters.json similarity index 94% rename from test/crud/write/updateMany-arrayFilters.json rename to test/crud/v1/write/updateMany-arrayFilters.json index a86be5835..ae4c123ea 100644 --- a/test/crud/write/updateMany-arrayFilters.json +++ b/test/crud/v1/write/updateMany-arrayFilters.json @@ -46,7 +46,8 @@ "outcome": { "result": { "matchedCount": 2, - "modifiedCount": 0 + "modifiedCount": 0, + "upsertedCount": 0 }, "collection": { "data": [ @@ -97,7 +98,8 @@ "outcome": { "result": { "matchedCount": 2, - "modifiedCount": 1 + "modifiedCount": 1, + "upsertedCount": 0 }, "collection": { "data": [ @@ -148,7 +150,8 @@ "outcome": { "result": { "matchedCount": 2, - "modifiedCount": 2 + "modifiedCount": 2, + "upsertedCount": 0 }, "collection": { "data": [ diff --git a/test/crud/write/updateMany-collation.json b/test/crud/v1/write/updateMany-collation.json similarity index 100% rename from test/crud/write/updateMany-collation.json rename to test/crud/v1/write/updateMany-collation.json diff --git a/test/crud/write/updateMany.json b/test/crud/v1/write/updateMany.json similarity index 100% rename from test/crud/write/updateMany.json rename to test/crud/v1/write/updateMany.json diff --git a/test/crud/write/updateOne-arrayFilters.json b/test/crud/v1/write/updateOne-arrayFilters.json similarity index 96% rename from test/crud/write/updateOne-arrayFilters.json rename to test/crud/v1/write/updateOne-arrayFilters.json index 7ac1986c2..087ed4b82 100644 --- a/test/crud/write/updateOne-arrayFilters.json +++ b/test/crud/v1/write/updateOne-arrayFilters.json @@ -62,7 +62,8 @@ "outcome": { "result": { "matchedCount": 1, - "modifiedCount": 0 + "modifiedCount": 0, + "upsertedCount": 0 }, "collection": { "data": [ @@ -129,7 +130,8 @@ "outcome": { "result": { "matchedCount": 1, - "modifiedCount": 1 + "modifiedCount": 1, + "upsertedCount": 0 }, "collection": { "data": [ @@ -196,7 +198,8 @@ "outcome": { "result": { "matchedCount": 1, - "modifiedCount": 1 + "modifiedCount": 1, + "upsertedCount": 0 }, "collection": { "data": [ @@ -268,7 +271,8 @@ "outcome": { "result": { "matchedCount": 1, - "modifiedCount": 0 + "modifiedCount": 0, + "upsertedCount": 0 }, "collection": { "data": [ @@ -340,7 +344,8 @@ "outcome": { "result": { "matchedCount": 1, - "modifiedCount": 1 + "modifiedCount": 1, + "upsertedCount": 0 }, "collection": { "data": [ diff --git a/test/crud/write/updateOne-collation.json b/test/crud/v1/write/updateOne-collation.json similarity index 100% rename from test/crud/write/updateOne-collation.json rename to test/crud/v1/write/updateOne-collation.json diff --git a/test/crud/write/updateOne.json b/test/crud/v1/write/updateOne.json similarity index 100% rename from test/crud/write/updateOne.json rename to test/crud/v1/write/updateOne.json diff --git a/test/crud/v2/aggregate-out-readConcern.json b/test/crud/v2/aggregate-out-readConcern.json new file mode 100644 index 000000000..02917c8a1 --- /dev/null +++ b/test/crud/v2/aggregate-out-readConcern.json @@ -0,0 +1,377 @@ +{ + "data": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ], + "minServerVersion": "4.1", + "collection_name": "test_aggregate_out_readconcern", + "tests": [ + { + "description": "readConcern majority with out stage", + "operations": [ + { + "object": "collection", + "name": "aggregate", + "collectionOptions": { + "readConcern": { + "level": "majority" + } + }, + "arguments": { + "pipeline": [ + { + "$sort": { + "x": 1 + } + }, + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$out": "other_test_collection" + } + ] + } + } + ], + "expectations": [ + { + "command_started_event": { + "command": { + "aggregate": "test_aggregate_out_readconcern", + "pipeline": [ + { + "$sort": { + "x": 1 + } + }, + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$out": "other_test_collection" + } + ], + "readConcern": { + "level": "majority" + } + } + } + } + ], + "outcome": { + "collection": { + "name": "other_test_collection", + "data": [ + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + } + }, + { + "description": "readConcern local with out stage", + "operations": [ + { + "object": "collection", + "name": "aggregate", + "collectionOptions": { + "readConcern": { + "level": "local" + } + }, + "arguments": { + "pipeline": [ + { + "$sort": { + "x": 1 + } + }, + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$out": "other_test_collection" + } + ] + } + } + ], + "expectations": [ + { + "command_started_event": { + "command": { + "aggregate": "test_aggregate_out_readconcern", + "pipeline": [ + { + "$sort": { + "x": 1 + } + }, + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$out": "other_test_collection" + } + ], + "readConcern": { + "level": "local" + } + } + } + } + ], + "outcome": { + "collection": { + "name": "other_test_collection", + "data": [ + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + } + }, + { + "description": "readConcern available with out stage", + "operations": [ + { + "object": "collection", + "name": "aggregate", + "collectionOptions": { + "readConcern": { + "level": "available" + } + }, + "arguments": { + "pipeline": [ + { + "$sort": { + "x": 1 + } + }, + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$out": "other_test_collection" + } + ] + } + } + ], + "expectations": [ + { + "command_started_event": { + "command": { + "aggregate": "test_aggregate_out_readconcern", + "pipeline": [ + { + "$sort": { + "x": 1 + } + }, + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$out": "other_test_collection" + } + ], + "readConcern": { + "level": "available" + } + } + } + } + ], + "outcome": { + "collection": { + "name": "other_test_collection", + "data": [ + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + } + }, + { + "description": "readConcern linearizable with out stage", + "operations": [ + { + "object": "collection", + "name": "aggregate", + "collectionOptions": { + "readConcern": { + "level": "linearizable" + } + }, + "arguments": { + "pipeline": [ + { + "$sort": { + "x": 1 + } + }, + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$out": "other_test_collection" + } + ] + }, + "error": true + } + ], + "expectations": [ + { + "command_started_event": { + "command": { + "aggregate": "test_aggregate_out_readconcern", + "pipeline": [ + { + "$sort": { + "x": 1 + } + }, + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$out": "other_test_collection" + } + ], + "readConcern": { + "level": "linearizable" + } + } + } + } + ] + }, + { + "description": "invalid readConcern with out stage", + "operations": [ + { + "object": "collection", + "name": "aggregate", + "collectionOptions": { + "readConcern": { + "level": "!invalid123" + } + }, + "arguments": { + "pipeline": [ + { + "$sort": { + "x": 1 + } + }, + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$out": "other_test_collection" + } + ] + }, + "error": true + } + ], + "expectations": [ + { + "command_started_event": { + "command": { + "aggregate": "test_aggregate_out_readconcern", + "pipeline": [ + { + "$sort": { + "x": 1 + } + }, + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$out": "other_test_collection" + } + ], + "readConcern": { + "level": "!invalid123" + } + } + } + } + ] + } + ] +} diff --git a/test/crud/v2/bulkWrite-arrayFilters.json b/test/crud/v2/bulkWrite-arrayFilters.json new file mode 100644 index 000000000..97bf3e789 --- /dev/null +++ b/test/crud/v2/bulkWrite-arrayFilters.json @@ -0,0 +1,156 @@ +{ + "data": [ + { + "_id": 1, + "y": [ + { + "b": 3 + }, + { + "b": 1 + } + ] + }, + { + "_id": 2, + "y": [ + { + "b": 0 + }, + { + "b": 1 + } + ] + } + ], + "minServerVersion": "3.5.6", + "collection_name": "test", + "database_name": "crud-tests", + "tests": [ + { + "description": "BulkWrite with arrayFilters", + "operations": [ + { + "name": "bulkWrite", + "arguments": { + "requests": [ + { + "name": "updateOne", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 3 + } + ] + } + }, + { + "name": "updateMany", + "arguments": { + "filter": {}, + "update": { + "$set": { + "y.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 1 + } + ] + } + } + ], + "options": { + "ordered": true + } + }, + "result": { + "deletedCount": 0, + "insertedCount": 0, + "insertedIds": {}, + "matchedCount": 3, + "modifiedCount": 3, + "upsertedCount": 0, + "upsertedIds": {} + } + } + ], + "expectations": [ + { + "command_started_event": { + "command": { + "update": "test", + "updates": [ + { + "q": {}, + "u": { + "$set": { + "y.$[i].b": 2 + } + }, + "arrayFilters": [ + { + "i.b": 3 + } + ] + }, + { + "q": {}, + "u": { + "$set": { + "y.$[i].b": 2 + } + }, + "multi": true, + "arrayFilters": [ + { + "i.b": 1 + } + ] + } + ], + "ordered": true + }, + "command_name": "update", + "database_name": "crud-tests" + } + } + ], + "outcome": { + "collection": { + "data": [ + { + "_id": 1, + "y": [ + { + "b": 2 + }, + { + "b": 2 + } + ] + }, + { + "_id": 2, + "y": [ + { + "b": 0 + }, + { + "b": 2 + } + ] + } + ] + } + } + } + ] +} diff --git a/test/crud/write/insertMany.json b/test/crud/write/insertMany.json deleted file mode 100644 index a2356c42d..000000000 --- a/test/crud/write/insertMany.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "data": [ - { - "_id": 1, - "x": 11 - } - ], - "tests": [ - { - "description": "InsertMany with non-existing documents", - "operation": { - "name": "insertMany", - "arguments": { - "documents": [ - { - "_id": 2, - "x": 22 - }, - { - "_id": 3, - "x": 33 - } - ] - } - }, - "outcome": { - "result": { - "insertedIds": { - "0": 2, - "1": 3 - } - }, - "collection": { - "data": [ - { - "_id": 1, - "x": 11 - }, - { - "_id": 2, - "x": 22 - }, - { - "_id": 3, - "x": 33 - } - ] - } - } - } - ] -} diff --git a/test/test_crud.py b/test/test_crud_v1.py similarity index 76% rename from test/test_crud.py rename to test/test_crud_v1.py index 1151f9764..37ae328bc 100644 --- a/test/test_crud.py +++ b/test/test_crud_v1.py @@ -22,9 +22,11 @@ import sys sys.path[0:0] = [""] from bson.py3compat import iteritems -from pymongo import operations +from pymongo import operations, WriteConcern from pymongo.command_cursor import CommandCursor from pymongo.cursor import Cursor +from pymongo.errors import PyMongoError +from pymongo.read_concern import ReadConcern from pymongo.results import _WriteResult, BulkWriteResult from pymongo.operations import (InsertOne, DeleteOne, @@ -34,27 +36,18 @@ from pymongo.operations import (InsertOne, UpdateMany) from test import unittest, client_context, IntegrationTest -from test.utils import drop_collections +from test.utils import (camel_to_snake, camel_to_upper_camel, + camel_to_snake_args, drop_collections, TestCreator) # Location of JSON test specifications. _TEST_PATH = os.path.join( - os.path.dirname(os.path.realpath(__file__)), 'crud') + os.path.dirname(os.path.realpath(__file__)), 'crud', 'v1') class TestAllScenarios(IntegrationTest): pass -def camel_to_snake(camel): - # Regex to convert CamelCase to snake_case. - snake = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel) - return re.sub('([a-z0-9])([A-Z])', r'\1_\2', snake).lower() - - -def camel_to_upper_camel(camel): - return camel[0].upper() + camel[1:] - - def check_result(expected_result, result): if isinstance(result, Cursor) or isinstance(result, CommandCursor): return list(result) == expected_result @@ -96,19 +89,12 @@ def check_result(expected_result, result): return False return True else: - if not expected_result: + if expected_result is None: return result is None else: return result == expected_result -def camel_to_snake_args(arguments): - for arg_name in list(arguments): - c2s = camel_to_snake(arg_name) - arguments[c2s] = arguments.pop(arg_name) - return arguments - - def run_operation(collection, test): # Convert command from CamelCase to pymongo.collection method. operation = camel_to_snake(test['operation']['name']) @@ -156,14 +142,25 @@ def run_operation(collection, test): return result -def create_test(scenario_def, test): +def create_test(scenario_def, test, name): def run_scenario(self): - # Load data. - assert scenario_def['data'], "tests must have non-empty data" + # Cleanup state and load data (if provided). drop_collections(self.db) - self.db.test.insert_many(scenario_def['data']) + data = scenario_def.get('data') + if data: + self.db.test.with_options( + write_concern=WriteConcern(w="majority")).insert_many( + scenario_def['data']) - result = run_operation(self.db.test, test) + # Run operations and check results or errors. + expected_result = test.get('outcome', {}).get('result') + expected_error = test.get('outcome', {}).get('error') + if expected_error is True: + with self.assertRaises(PyMongoError): + run_operation(self.db.test, test) + else: + result = run_operation(self.db.test, test) + self.assertTrue(check_result(expected_result, result)) # Assert final state is expected. expected_c = test['outcome'].get('collection') @@ -173,53 +170,15 @@ def create_test(scenario_def, test): db_coll = self.db[expected_name] else: db_coll = self.db.test + db_coll = db_coll.with_options( + read_concern=ReadConcern(level="local")) self.assertEqual(list(db_coll.find()), expected_c['data']) - expected_result = test['outcome'].get('result') - self.assertTrue(check_result(expected_result, result)) return run_scenario -def create_tests(): - for dirpath, _, filenames in os.walk(_TEST_PATH): - dirname = os.path.split(dirpath)[-1] - - for filename in filenames: - with open(os.path.join(dirpath, filename)) as scenario_stream: - scenario_def = json.load(scenario_stream) - - test_type = os.path.splitext(filename)[0] - - min_ver, max_ver = None, None - if 'minServerVersion' in scenario_def: - min_ver = tuple( - int(elt) for - elt in scenario_def['minServerVersion'].split('.')) - if 'maxServerVersion' in scenario_def: - max_ver = tuple( - int(elt) for - elt in scenario_def['maxServerVersion'].split('.')) - - # Construct test from scenario. - for test in scenario_def['tests']: - new_test = create_test(scenario_def, test) - if min_ver is not None: - new_test = client_context.require_version_min(*min_ver)( - new_test) - if max_ver is not None: - new_test = client_context.require_version_max(*max_ver)( - new_test) - - test_name = 'test_%s_%s_%s' % ( - dirname, - test_type, - str(test['description'].replace(" ", "_"))) - - new_test.__name__ = test_name - setattr(TestAllScenarios, new_test.__name__, new_test) - - -create_tests() +test_creator = TestCreator(create_test, TestAllScenarios, _TEST_PATH) +test_creator.create_tests() class TestWriteOpsComparison(unittest.TestCase): diff --git a/test/test_crud_v2.py b/test/test_crud_v2.py new file mode 100644 index 000000000..9bd84d79d --- /dev/null +++ b/test/test_crud_v2.py @@ -0,0 +1,258 @@ +# Copyright 2019-present MongoDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test the collection module.""" + +import json +import os +import sys + +sys.path[0:0] = [""] + +from bson.py3compat import iteritems +from pymongo import operations, WriteConcern +from pymongo.command_cursor import CommandCursor +from pymongo.cursor import Cursor +from pymongo.errors import PyMongoError +from pymongo.read_concern import ReadConcern +from pymongo.results import _WriteResult, BulkWriteResult + +from test import unittest, client_context, IntegrationTest +from test.utils import (camel_to_snake, camel_to_upper_camel, + camel_to_snake_args, drop_collections, + parse_collection_options, rs_client, + OvertCommandListener, TestCreator) + +# Location of JSON test specifications. +_TEST_PATH = os.path.join( + os.path.dirname(os.path.realpath(__file__)), 'crud', 'v2') + +# Default test database and collection names. +TEST_DB = 'testdb' +TEST_COLLECTION = 'testcollection' + + +class TestAllScenarios(IntegrationTest): + def run_operation(self, collection, test): + # Iterate over all operations. + for opdef in test['operations']: + # Convert command from CamelCase to pymongo.collection method. + operation = camel_to_snake(opdef['name']) + + # Get command handle on target entity (collection/database). + target_object = opdef.get('object', 'collection') + if target_object == 'database': + cmd = getattr(collection.database, operation) + elif target_object == 'collection': + collection = collection.with_options(**dict( + parse_collection_options(opdef.get( + 'collectionOptions', {})))) + cmd = getattr(collection, operation) + else: + self.fail("Unknown object name %s" % (target_object,)) + + # Convert arguments to snake_case and handle special cases. + arguments = opdef['arguments'] + options = arguments.pop("options", {}) + + for option_name in options: + arguments[camel_to_snake(option_name)] = options[option_name] + + if operation == "bulk_write": + # Parse each request into a bulk write model. + requests = [] + for request in arguments["requests"]: + bulk_model = camel_to_upper_camel(request["name"]) + bulk_class = getattr(operations, bulk_model) + bulk_arguments = camel_to_snake_args(request["arguments"]) + requests.append(bulk_class(**bulk_arguments)) + arguments["requests"] = requests + else: + for arg_name in list(arguments): + c2s = camel_to_snake(arg_name) + # PyMongo accepts sort as list of tuples. + if arg_name == "sort": + sort_dict = arguments[arg_name] + arguments[arg_name] = list(iteritems(sort_dict)) + # Named "key" instead not fieldName. + if arg_name == "fieldName": + arguments["key"] = arguments.pop(arg_name) + # Aggregate uses "batchSize", while find uses batch_size. + elif arg_name == "batchSize" and operation == "aggregate": + continue + # Requires boolean returnDocument. + elif arg_name == "returnDocument": + arguments[c2s] = arguments[arg_name] == "After" + else: + arguments[c2s] = arguments.pop(arg_name) + + if opdef.get('error') is True: + with self.assertRaises(PyMongoError): + cmd(**arguments) + else: + result = cmd(**arguments) + self.check_result(opdef.get('result'), result) + + def check_result(self, expected_result, result): + if expected_result is None: + return True + + if isinstance(result, Cursor) or isinstance(result, CommandCursor): + return list(result) == expected_result + + elif isinstance(result, _WriteResult): + for res in expected_result: + prop = camel_to_snake(res) + # SPEC-869: Only BulkWriteResult has upserted_count. + if (prop == "upserted_count" and + not isinstance(result, BulkWriteResult)): + if result.upserted_id is not None: + upserted_count = 1 + else: + upserted_count = 0 + if upserted_count != expected_result[res]: + return False + elif prop == "inserted_ids": + # BulkWriteResult does not have inserted_ids. + if isinstance(result, BulkWriteResult): + if len(expected_result[res]) != result.inserted_count: + return False + else: + # InsertManyResult may be compared to [id1] from the + # crud spec or {"0": id1} from the retryable write spec. + ids = expected_result[res] + if isinstance(ids, dict): + ids = [ids[str(i)] for i in range(len(ids))] + if ids != result.inserted_ids: + return False + elif prop == "upserted_ids": + # Convert indexes from strings to integers. + ids = expected_result[res] + expected_ids = {} + for str_index in ids: + expected_ids[int(str_index)] = ids[str_index] + if expected_ids != result.upserted_ids: + return False + elif getattr(result, prop) != expected_result[res]: + return False + return True + else: + if not expected_result: + return result is None + else: + return result == expected_result + + def check_events(self, expected_events, listener): + res = listener.results + if not len(expected_events): + return + + # Expectations only have CommandStartedEvents. + self.assertEqual(len(res['started']), len(expected_events)) + for i, expectation in enumerate(expected_events): + event_type = next(iter(expectation)) + event = res['started'][i] + + # The tests substitute 42 for any number other than 0. + if (event.command_name == 'getMore' + and event.command['getMore']): + event.command['getMore'] = 42 + elif event.command_name == 'killCursors': + event.command['cursors'] = [42] + # Add upsert and multi fields back into expectations. + elif event.command_name == 'update': + updates = expectation[event_type]['command']['updates'] + for update in updates: + update.setdefault('upsert', False) + update.setdefault('multi', False) + + # Replace afterClusterTime: 42 with actual afterClusterTime. + expected_cmd = expectation[event_type]['command'] + expected_read_concern = expected_cmd.get('readConcern') + if expected_read_concern is not None: + time = expected_read_concern.get('afterClusterTime') + if time == 42: + actual_time = event.command.get( + 'readConcern', {}).get('afterClusterTime') + if actual_time is not None: + expected_read_concern['afterClusterTime'] = actual_time + + for attr, expected in expectation[event_type].items(): + actual = getattr(event, attr) + if isinstance(expected, dict): + for key, val in expected.items(): + if val is None: + if key in actual: + self.fail("Unexpected key [%s] in %r" % ( + key, actual)) + elif key not in actual: + self.fail("Expected key [%s] in %r" % ( + key, actual)) + else: + self.assertEqual(val, actual[key], + "Key [%s] in %s" % (key, actual)) + else: + self.assertEqual(actual, expected) + + +def create_test(scenario_def, test, name): + def run_scenario(self): + listener = OvertCommandListener() + # New client, to avoid interference from pooled sessions. + # Convert test['clientOptions'] to dict to avoid a Jython bug using "**" + # with ScenarioDict. + client = rs_client(event_listeners=[listener], + **dict(test.get('clientOptions', {}))) + # Close the client explicitly to avoid having too many threads open. + self.addCleanup(client.close) + + # Get database and collection objects. + database = getattr( + client, scenario_def.get('database_name', TEST_DB)) + drop_collections(database) + collection = getattr( + database, scenario_def.get('collection_name', TEST_COLLECTION)) + + # Populate collection with data and run test. + collection.with_options( + write_concern=WriteConcern(w="majority")).insert_many( + scenario_def.get('data', [])) + listener.results.clear() + self.run_operation(collection, test) + + # Assert expected events. + self.check_events(test.get('expectations', {}), listener) + + # Assert final state is expected. + expected_outcome = test.get('outcome', {}).get('collection') + if expected_outcome is not None: + collname = expected_outcome.get('name') + if collname is not None: + o_collection = getattr(database, collname) + else: + o_collection = collection + o_collection = o_collection.with_options( + read_concern=ReadConcern(level="local")) + self.assertEqual(list(o_collection.find()), + expected_outcome['data']) + + return run_scenario + + +test_creator = TestCreator(create_test, TestAllScenarios, _TEST_PATH) +test_creator.create_tests() + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/test/test_read_concern.py b/test/test_read_concern.py index c6d7cc889..8a408b8d0 100644 --- a/test/test_read_concern.py +++ b/test/test_read_concern.py @@ -20,7 +20,7 @@ from pymongo.errors import ConfigurationError, OperationFailure from pymongo.read_concern import ReadConcern from test import client_context, PyMongoTestCase -from test.utils import single_client, rs_or_single_client, EventListener +from test.utils import single_client, rs_or_single_client, OvertCommandListener class TestReadConcern(PyMongoTestCase): @@ -28,7 +28,7 @@ class TestReadConcern(PyMongoTestCase): @classmethod @client_context.require_connection def setUpClass(cls): - cls.listener = EventListener() + cls.listener = OvertCommandListener() cls.saved_listeners = monitoring._LISTENERS # Don't use any global subscribers. monitoring._LISTENERS = monitoring._Listeners([], [], [], []) @@ -118,8 +118,14 @@ class TestReadConcern(PyMongoTestCase): except OperationFailure: # "ns doesn't exist" pass - self.assertNotIn('readConcern', - self.listener.results['started'][0].command) + + # Aggregate with $out supports readConcern MongoDB 4.2 onwards. + if client_context.version >= (4, 1): + self.assertIn('readConcern', + self.listener.results['started'][0].command) + else: + self.assertNotIn('readConcern', + self.listener.results['started'][0].command) def test_map_reduce_out(self): coll = self.db.get_collection('coll', read_concern=ReadConcern('local')) diff --git a/test/test_retryable_writes.py b/test/test_retryable_writes.py index c96cfd08f..94c47d5f0 100644 --- a/test/test_retryable_writes.py +++ b/test/test_retryable_writes.py @@ -43,7 +43,7 @@ from test import unittest, client_context, IntegrationTest, SkipTest, client_kno from test.utils import (rs_or_single_client, DeprecationFilter, OvertCommandListener) -from test.test_crud import check_result, run_operation +from test.test_crud_v1 import check_result, run_operation # Location of JSON test specifications. _TEST_PATH = os.path.join( diff --git a/test/test_session.py b/test/test_session.py index 6ffaf3f09..5ac3e4664 100644 --- a/test/test_session.py +++ b/test/test_session.py @@ -981,9 +981,6 @@ class TestCausalConsistency(unittest.TestCase): lambda coll, session: coll.drop_index("foo_1", session=session)) self._test_no_read_concern( lambda coll, session: coll.drop_indexes(session=session)) - self._test_no_read_concern( - lambda coll, session: list( - coll.aggregate([{"$out": "aggout"}], session=session))) self._test_no_read_concern( lambda coll, session: coll.map_reduce( 'function() {}', 'function() {}', 'mrout', session=session)) @@ -999,6 +996,13 @@ class TestCausalConsistency(unittest.TestCase): self._test_no_read_concern( lambda coll, session: coll.reindex(session=session)) + @client_context.require_no_standalone + @client_context.require_version_max(4, 1, 0) + def test_aggregate_out_does_not_include_read_concern(self): + self._test_no_read_concern( + lambda coll, session: list( + coll.aggregate([{"$out": "aggout"}], session=session))) + @client_context.require_no_standalone def test_get_more_does_not_include_read_concern(self): coll = self.client.pymongo_test.test diff --git a/test/test_transactions.py b/test/test_transactions.py index ed399a346..1eaf5bcdf 100644 --- a/test/test_transactions.py +++ b/test/test_transactions.py @@ -14,15 +14,11 @@ """Execute Transactions Spec tests.""" -import collections -from functools import partial import os -import re import sys sys.path[0:0] = [""] -from bson import json_util, py3compat from bson.py3compat import iteritems from bson.son import SON from pymongo import client_session, operations, WriteConcern @@ -38,10 +34,9 @@ from pymongo.read_preferences import ReadPreference from pymongo.results import _WriteResult, BulkWriteResult from test import unittest, client_context, IntegrationTest -from test.utils import (OvertCommandListener, - rs_client, - single_client, - wait_until) +from test.utils import (camel_to_snake, camel_to_upper_camel, + camel_to_snake_args, rs_client, single_client, + wait_until, OvertCommandListener, TestCreator) from test.utils_selection_tests import parse_read_preference # Location of JSON test specifications. @@ -57,26 +52,7 @@ _TXN_TESTS_DEBUG = os.environ.get('TRANSACTION_TESTS_DEBUG') UNPIN_TEST_MAX_ATTEMPTS = 50 -# TODO: factor the following functions with test_crud.py. -def camel_to_snake(camel): - # Regex to convert CamelCase to snake_case. - snake = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel) - return re.sub('([a-z0-9])([A-Z])', r'\1_\2', snake).lower() - - -def camel_to_upper_camel(camel): - return camel[0].upper() + camel[1:] - - -def camel_to_snake_args(arguments): - for arg_name in list(arguments): - c2s = camel_to_snake(arg_name) - arguments[c2s] = arguments.pop(arg_name) - return arguments - - class TestTransactions(IntegrationTest): - @classmethod def setUpClass(cls): super(TestTransactions, cls).setUpClass() @@ -255,7 +231,7 @@ class TestTransactions(IntegrationTest): pass self.assertEqual(filtered_result, expected_result) - # TODO: factor the following function with test_crud.py. + # TODO: factor the following function with CRUD v2 test runner. def check_result(self, expected_result, result): if isinstance(result, _WriteResult): for res in expected_result: @@ -476,7 +452,8 @@ def end_sessions(sessions): s.end_session() -def create_test(scenario_def, test): +def create_test(scenario_def, test, name): + @client_context.require_transactions def run_scenario(self): if test.get('skipReason'): raise unittest.SkipTest(test.get('skipReason')) @@ -608,65 +585,17 @@ def create_test(scenario_def, test): read_concern=ReadConcern('local')) self.assertEqual(list(primary_coll.find()), expected_c['data']) + if 'secondary' in name: + run_scenario = client_context._require( + lambda: client_context.has_secondaries, 'No secondaries', + run_scenario) + return run_scenario -class ScenarioDict(dict): - """Dict that returns {} for any unknown key, recursively.""" - def __init__(self, data): - def convert(v): - if isinstance(v, collections.Mapping): - return ScenarioDict(v) - if isinstance(v, py3compat.string_type): - return v - if isinstance(v, collections.Sequence): - return [convert(item) for item in v] - return v +test_creator = TestCreator(create_test, TestTransactions, _TEST_PATH) +test_creator.create_tests() - dict.__init__(self, [(k, convert(v)) for k, v in data.items()]) - - def __getitem__(self, item): - try: - return dict.__getitem__(self, item) - except KeyError: - # Unlike a defaultdict, don't set the key, just return a dict. - return ScenarioDict({}) - - -def create_tests(): - for dirpath, _, filenames in os.walk(_TEST_PATH): - dirname = os.path.split(dirpath)[-1] - - for filename in filenames: - test_type, ext = os.path.splitext(filename) - if ext != '.json': - continue - - with open(os.path.join(dirpath, filename)) as scenario_stream: - scenario_def = ScenarioDict( - json_util.loads(scenario_stream.read())) - - # Construct test from scenario. - for test in scenario_def['tests']: - test_name = 'test_%s_%s_%s' % ( - dirname, - test_type.replace("-", "_"), - str(test['description'].replace(" ", "_"))) - - new_test = create_test(scenario_def, test) - new_test = client_context.require_transactions(new_test) - - if 'secondary' in test_name: - new_test = client_context._require( - lambda: client_context.has_secondaries, - 'No secondaries', - new_test) - - new_test.__name__ = test_name - setattr(TestTransactions, new_test.__name__, new_test) - - -create_tests() if __name__ == "__main__": unittest.main() diff --git a/test/utils.py b/test/utils.py index c6db94a4f..35ab191c2 100644 --- a/test/utils.py +++ b/test/utils.py @@ -15,8 +15,11 @@ """Utilities for testing pymongo """ +import collections import contextlib import functools +import os +import re import sys import threading import time @@ -25,14 +28,16 @@ import warnings from collections import defaultdict from functools import partial +from bson import json_util, py3compat from bson.objectid import ObjectId from pymongo import MongoClient, monitoring from pymongo.errors import OperationFailure from pymongo.monitoring import _SENSITIVE_COMMANDS +from pymongo.read_concern import ReadConcern from pymongo.server_selectors import (any_server_selector, writable_server_selector) from pymongo.write_concern import WriteConcern -from test import (client_context, db_user, db_pwd) +from test import client_context, db_user, db_pwd IMPOSSIBLE_WRITE_CONCERN = WriteConcern(w=1000) @@ -120,6 +125,93 @@ class HeartbeatEventListener(monitoring.ServerHeartbeatListener): self.results.append(event) +class ScenarioDict(dict): + """Dict that returns {} for any unknown key, recursively.""" + def __init__(self, data): + def convert(v): + if isinstance(v, collections.Mapping): + return ScenarioDict(v) + if isinstance(v, py3compat.string_type): + return v + if isinstance(v, collections.Sequence): + return [convert(item) for item in v] + return v + + dict.__init__(self, [(k, convert(v)) for k, v in data.items()]) + + def __getitem__(self, item): + try: + return dict.__getitem__(self, item) + except KeyError: + # Unlike a defaultdict, don't set the key, just return a dict. + return ScenarioDict({}) + + +class TestCreator(object): + """Class to create test cases from specifications.""" + def __init__(self, create_test, test_class, test_path): + """Create a TestCreator object. + + :Parameters: + - `create_test`: callback that returns a test case. The callback + must accept the following arguments - a dictionary containing the + entire test specification (the `scenario_def`), a dictionary + containing the specification for which the test case will be + generated (the `test_def`). + - `test_class`: the unittest.TestCase class in which to create the + test case. + - `test_path`: path to the directory containing the JSON files with + the test specifications. + """ + self._create_test = create_test + self._test_class= test_class + self.test_path = test_path + + def _ensure_min_max_server_version(self, scenario_def, method): + """Test modifier that enforces a version range for the server on a + test case.""" + if 'minServerVersion' in scenario_def: + min_ver = tuple( + int(elt) for + elt in scenario_def['minServerVersion'].split('.')) + if min_ver is not None: + method = client_context.require_version_min(*min_ver)(method) + + if 'maxServerVersion' in scenario_def: + max_ver = tuple( + int(elt) for + elt in scenario_def['maxServerVersion'].split('.')) + if max_ver is not None: + method = client_context.require_version_max(*max_ver)(method) + + return method + + def create_tests(self): + for dirpath, _, filenames in os.walk(self.test_path): + dirname = os.path.split(dirpath)[-1] + + for filename in filenames: + with open(os.path.join(dirpath, filename)) as scenario_stream: + scenario_def = ScenarioDict( + json_util.loads(scenario_stream.read())) + + test_type = os.path.splitext(filename)[0] + + # Construct test from scenario. + for test_def in scenario_def['tests']: + test_name = 'test_%s_%s_%s' % ( + dirname, test_type, + str(test_def['description'].replace(" ", "_"))) + + new_test = self._create_test( + scenario_def, test_def, test_name) + new_test = self._ensure_min_max_server_version( + scenario_def, new_test) + + new_test.__name__ = test_name + setattr(self._test_class, new_test.__name__, new_test) + + def _connection_string(h, authenticate): if h.startswith("mongodb://"): return h @@ -202,6 +294,38 @@ def get_command_line(client): return command_line +def camel_to_snake(camel): + # Regex to convert CamelCase to snake_case. + snake = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel) + return re.sub('([a-z0-9])([A-Z])', r'\1_\2', snake).lower() + + +def camel_to_upper_camel(camel): + return camel[0].upper() + camel[1:] + + +def camel_to_snake_args(arguments): + for arg_name in list(arguments): + c2s = camel_to_snake(arg_name) + arguments[c2s] = arguments.pop(arg_name) + return arguments + + +def parse_collection_options(opts): + if 'readPreference' in opts: + opts['read_preference'] = parse_read_preference( + opts.pop('readPreference')) + + if 'writeConcern' in opts: + opts['write_concern'] = WriteConcern( + **dict(opts.pop('writeConcern'))) + + if 'readConcern' in opts: + opts['read_concern'] = ReadConcern( + **dict(opts.pop('readConcern'))) + return opts + + def server_started_with_option(client, cmdline_opt, config_opt): """Check if the server was started with a particular option.