From d0a0ff39284f92853b803615e6981186407d1a77 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Fri, 22 May 2026 15:52:12 -0700 Subject: [PATCH] Add views collection tests Signed-off-by: Daniel Frankcom --- .../commands/utils/command_test_case.py | 21 +- .../collections/views/test_views_chaining.py | 90 ++++++ .../views/test_views_pipeline_composition.py | 286 ++++++++++++++++++ .../views/test_views_read_operations.py | 126 ++++++++ .../views/test_views_write_rejection.py | 94 ++++++ documentdb_tests/framework/assertions.py | 6 + .../framework/target_collection.py | 20 +- 7 files changed, 633 insertions(+), 10 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/collections/views/test_views_chaining.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/views/test_views_pipeline_composition.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/views/test_views_read_operations.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/views/test_views_write_rejection.py diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/utils/command_test_case.py b/documentdb_tests/compatibility/tests/core/collections/commands/utils/command_test_case.py index 7d6072b2..4146b228 100644 --- a/documentdb_tests/compatibility/tests/core/collections/commands/utils/command_test_case.py +++ b/documentdb_tests/compatibility/tests/core/collections/commands/utils/command_test_case.py @@ -72,26 +72,33 @@ class CommandTestCase(BaseTestCase): docs: list[dict[str, Any]] | None = None command: dict[str, Any] | Callable[..., dict[str, Any]] | None = None expected: dict[str, Any] | list[dict[str, Any]] | Callable[..., dict[str, Any]] | None = None + ignore_order_in: list[str] | None = None def prepare(self, db: Database, collection: Collection) -> Collection: """Resolve the target collection and apply indexes/docs. + Documents and indexes are inserted into the collection returned + by ``target_collection.writable(source, resolved)``. For views + this is the source; for regular collections it is the resolved + collection itself. + - If ``docs=None``, the collection is not created and will not exist. - If ``docs=[]``, the collection is explicitly created but left empty. - If ``docs=[...]``, the collection is created and documents are inserted. """ - collection = self.target_collection.resolve(db, collection) + resolved = self.target_collection.resolve(db, collection) + target = self.target_collection.writable(collection, resolved) if self.indexes: - collection.create_indexes(self.indexes) + target.create_indexes(self.indexes) if self.docs is not None: - if collection.name not in collection.database.list_collection_names(): - collection.database.create_collection(collection.name) + if target.name not in target.database.list_collection_names(): + target.database.create_collection(target.name) if self.docs: - collection.insert_many(self.docs) + target.insert_many(self.docs) if self.siblings: for sibling in self.siblings: - sibling.create(db, collection) - return collection + sibling.create(db, resolved) + return resolved def build_command(self, ctx: CommandContext) -> dict[str, Any]: """Resolve the command dict from a callable or plain dict.""" diff --git a/documentdb_tests/compatibility/tests/core/collections/views/test_views_chaining.py b/documentdb_tests/compatibility/tests/core/collections/views/test_views_chaining.py new file mode 100644 index 00000000..9b5ffcbb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/views/test_views_chaining.py @@ -0,0 +1,90 @@ +"""Tests for view chaining and depth limits.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.error_codes import VIEW_DEPTH_LIMIT_ERROR +from documentdb_tests.framework.executor import execute_command + + +# Property [View-on-View Composition]: a view referencing another view +# composes both pipelines correctly. +@pytest.mark.collection_mgmt +def test_views_chaining_composition(database_client, collection): + """Test view-on-view pipeline composition.""" + collection.insert_many( + [ + {"_id": 1, "x": 10, "y": "a"}, + {"_id": 2, "x": 20, "y": "b"}, + {"_id": 3, "x": 30, "y": "a"}, + ] + ) + view1 = f"{collection.name}_v1" + view2 = f"{collection.name}_v2" + database_client.command( + "create", + view1, + viewOn=collection.name, + pipeline=[{"$match": {"x": {"$gte": 20}}}], + ) + database_client.command( + "create", + view2, + viewOn=view1, + pipeline=[{"$project": {"x": 1}}], + ) + result = execute_command( + database_client[view2], + {"find": view2, "sort": {"_id": 1}}, + ) + assertSuccess( + result, + [{"_id": 2, "x": 20}, {"_id": 3, "x": 30}], + msg="view-on-view should compose both pipelines correctly", + ) + + +# Property [Depth Limit Exceeded]: querying a view chain deeper than 20 +# levels produces the view-depth-limit error. +@pytest.mark.collection_mgmt +def test_views_depth_limit_exceeded(database_client, collection): + """Test view depth limit is enforced at query time.""" + collection.insert_many([{"_id": 1, "x": 10}]) + prev = collection.name + for i in range(1, 21): + name = f"{collection.name}_d{i}" + database_client.command("create", name, viewOn=prev, pipeline=[]) + prev = name + result = execute_command( + database_client[prev], + {"find": prev}, + ) + assertFailureCode( + result, + VIEW_DEPTH_LIMIT_ERROR, + msg="querying a view chain exceeding depth 20 should fail", + ) + + +# Property [Depth Limit Boundary]: a view chain at exactly depth 19 is +# queryable without error. +@pytest.mark.collection_mgmt +def test_views_depth_limit_at_boundary(database_client, collection): + """Test view at exactly depth 19 is queryable.""" + collection.insert_many([{"_id": 1, "x": 10}]) + prev = collection.name + for i in range(1, 20): + name = f"{collection.name}_b{i}" + database_client.command("create", name, viewOn=prev, pipeline=[]) + prev = name + result = execute_command( + database_client[prev], + {"find": prev, "sort": {"_id": 1}}, + ) + assertSuccess( + result, + [{"_id": 1, "x": 10}], + msg="view chain at depth 19 should be queryable", + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/views/test_views_pipeline_composition.py b/documentdb_tests/compatibility/tests/core/collections/views/test_views_pipeline_composition.py new file mode 100644 index 00000000..65cf2b13 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/views/test_views_pipeline_composition.py @@ -0,0 +1,286 @@ +"""Tests for view pipeline composition and source reflection.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import ViewCollection + + +# Property [Source Reflection Insert]: a view reflects documents inserted +# into the source collection immediately. +@pytest.mark.collection_mgmt +def test_views_source_reflection_insert(database_client, collection): + """Test view reflects inserted documents.""" + collection.insert_many([{"_id": 1, "x": 10}, {"_id": 2, "x": 20}]) + view_name = f"{collection.name}_view" + database_client.command("create", view_name, viewOn=collection.name, pipeline=[]) + view = database_client[view_name] + + collection.insert_one({"_id": 3, "x": 30}) + result = execute_command(view, {"find": view_name, "sort": {"_id": 1}}) + assertSuccess( + result, + [{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 30}], + msg="view should reflect inserted document immediately", + ) + + +# Property [Source Reflection Update]: a view reflects updates to the source +# collection immediately. +@pytest.mark.collection_mgmt +def test_views_source_reflection_update(database_client, collection): + """Test view reflects updated documents.""" + collection.insert_many([{"_id": 1, "x": 10}, {"_id": 2, "x": 20}]) + view_name = f"{collection.name}_view" + database_client.command("create", view_name, viewOn=collection.name, pipeline=[]) + view = database_client[view_name] + + collection.update_one({"_id": 1}, {"$set": {"x": 99}}) + result = execute_command(view, {"find": view_name, "filter": {"_id": 1}}) + assertSuccess( + result, + [{"_id": 1, "x": 99}], + msg="view should reflect updated document immediately", + ) + + +# Property [Source Reflection Delete]: a view reflects deletions from the +# source collection immediately. +@pytest.mark.collection_mgmt +def test_views_source_reflection_delete(database_client, collection): + """Test view reflects deleted documents.""" + collection.insert_many([{"_id": 1, "x": 10}, {"_id": 2, "x": 20}]) + view_name = f"{collection.name}_view" + database_client.command("create", view_name, viewOn=collection.name, pipeline=[]) + view = database_client[view_name] + + collection.delete_one({"_id": 2}) + result = execute_command(view, {"find": view_name, "sort": {"_id": 1}}) + assertSuccess( + result, + [{"_id": 1, "x": 10}], + msg="view should reflect deleted document immediately", + ) + + +# Property [Filtered Reflection Insert Matching]: a newly inserted document +# that matches the view's filter appears in the view. +@pytest.mark.collection_mgmt +def test_views_filtered_reflection_insert_matching(database_client, collection): + """Test view includes newly inserted document that matches filter.""" + collection.insert_many([{"_id": 1, "x": 10}, {"_id": 2, "x": 20}]) + view_name = f"{collection.name}_filt_view" + database_client.command( + "create", view_name, viewOn=collection.name, pipeline=[{"$match": {"x": {"$gte": 20}}}] + ) + view = database_client[view_name] + + collection.insert_one({"_id": 3, "x": 30}) + result = execute_command(view, {"find": view_name, "sort": {"_id": 1}}) + assertSuccess( + result, + [{"_id": 2, "x": 20}, {"_id": 3, "x": 30}], + msg="view should include newly inserted document that matches filter", + ) + + +# Property [Filtered Reflection Insert Non-Matching]: a newly inserted document +# that does not match the view's filter does not appear in the view. +@pytest.mark.collection_mgmt +def test_views_filtered_reflection_insert_non_matching(database_client, collection): + """Test view excludes newly inserted document that does not match filter.""" + collection.insert_many([{"_id": 1, "x": 10}, {"_id": 2, "x": 20}]) + view_name = f"{collection.name}_filt_view" + database_client.command( + "create", view_name, viewOn=collection.name, pipeline=[{"$match": {"x": {"$gte": 20}}}] + ) + view = database_client[view_name] + + collection.insert_one({"_id": 3, "x": 5}) + result = execute_command(view, {"find": view_name, "sort": {"_id": 1}}) + assertSuccess( + result, + [{"_id": 2, "x": 20}], + msg="view should exclude newly inserted document that does not match filter", + ) + + +# Property [Filtered Reflection Update Out]: a document disappears from the +# view when updated to no longer match the view's filter. +@pytest.mark.collection_mgmt +def test_views_filtered_reflection_update_out(database_client, collection): + """Test document disappears from view when updated to no longer match.""" + collection.insert_many([{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 30}]) + view_name = f"{collection.name}_filt_view" + database_client.command( + "create", view_name, viewOn=collection.name, pipeline=[{"$match": {"x": {"$gte": 20}}}] + ) + view = database_client[view_name] + + collection.update_one({"_id": 2}, {"$set": {"x": 5}}) + result = execute_command(view, {"find": view_name, "sort": {"_id": 1}}) + assertSuccess( + result, + [{"_id": 3, "x": 30}], + msg="document should disappear from view when updated out of filter", + ) + + +# Property [Filtered Reflection Update In]: a document appears in the view +# when updated to match the view's filter. +@pytest.mark.collection_mgmt +def test_views_filtered_reflection_update_in(database_client, collection): + """Test document appears in view when updated to match filter.""" + collection.insert_many([{"_id": 1, "x": 10}, {"_id": 2, "x": 20}]) + view_name = f"{collection.name}_filt_view" + database_client.command( + "create", view_name, viewOn=collection.name, pipeline=[{"$match": {"x": {"$gte": 20}}}] + ) + view = database_client[view_name] + + collection.update_one({"_id": 1}, {"$set": {"x": 25}}) + result = execute_command(view, {"find": view_name, "sort": {"_id": 1}}) + assertSuccess( + result, + [{"_id": 1, "x": 25}, {"_id": 2, "x": 20}], + msg="document should appear in view when updated to match filter", + ) + + +# Property [Orphaned View]: a view whose source collection does not exist +# returns empty results without error. +@pytest.mark.collection_mgmt +def test_views_orphaned_source(database_client, collection): + """Test view on non-existent source returns empty results.""" + view_name = f"{collection.name}_orphan_view" + database_client.command( + "create", view_name, viewOn="nonexistent_source_collection", pipeline=[] + ) + view = database_client[view_name] + result = execute_command(view, {"find": view_name}) + assertSuccess( + result, + [], + msg="view on non-existent source should return empty results", + ) + + +# Property [Dropped Source]: dropping the source collection causes the view +# to return empty results. +@pytest.mark.collection_mgmt +def test_views_dropped_source(database_client, collection): + """Test view returns empty after source is dropped.""" + collection.insert_many([{"_id": 1, "x": 10}, {"_id": 2, "x": 20}]) + view_name = f"{collection.name}_drop_view" + database_client.command("create", view_name, viewOn=collection.name, pipeline=[]) + view = database_client[view_name] + + collection.drop() + + result = execute_command(view, {"find": view_name}) + assertSuccess( + result, + [], + msg="view should return empty results after source is dropped", + ) + + +# Property [Pipeline Composition]: the view's pipeline is prepended to any +# additional pipeline specified in an aggregate command. +VIEWS_PIPELINE_COMPOSITION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "match_then_match", + target_collection=ViewCollection(options={"pipeline": [{"$match": {"x": {"$gte": 20}}}]}), + docs=[ + {"_id": 1, "x": 10}, + {"_id": 2, "x": 20}, + {"_id": 3, "x": 30}, + {"_id": 4, "x": 40}, + ], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"x": {"$lte": 30}}}], + "cursor": {}, + }, + expected=[{"_id": 2, "x": 20}, {"_id": 3, "x": 30}], + msg="aggregate $match should compose with view $match pipeline", + ), + CommandTestCase( + "project_then_match", + target_collection=ViewCollection( + options={ + "pipeline": [ + { + "$project": { + "x": 1, + "label": {"$concat": ["item_", {"$toString": "$x"}]}, + } + } + ] + } + ), + docs=[{"_id": 1, "x": 10, "y": "extra"}, {"_id": 2, "x": 20, "y": "extra"}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"label": "item_10"}}], + "cursor": {}, + }, + expected=[{"_id": 1, "x": 10, "label": "item_10"}], + msg="aggregate $match should see fields added by view $project", + ), + CommandTestCase( + "addfields_then_sort", + target_collection=ViewCollection( + options={"pipeline": [{"$addFields": {"doubled": {"$multiply": ["$x", 2]}}}]} + ), + docs=[{"_id": 1, "x": 3}, {"_id": 2, "x": 1}, {"_id": 3, "x": 2}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$sort": {"doubled": 1}}], + "cursor": {}, + }, + expected=[ + {"_id": 2, "x": 1, "doubled": 2}, + {"_id": 3, "x": 2, "doubled": 4}, + {"_id": 1, "x": 3, "doubled": 6}, + ], + msg="aggregate should be able to sort by fields added in view pipeline", + ), + CommandTestCase( + "sort_limit_in_view", + target_collection=ViewCollection( + options={"pipeline": [{"$sort": {"x": -1}}, {"$limit": 2}]} + ), + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 30}], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$project": {"x": 1, "_id": 0}}], + "cursor": {}, + }, + expected=[{"x": 30}, {"x": 20}], + msg="view pipeline with $sort and $limit should restrict visible documents", + ), +] + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(VIEWS_PIPELINE_COMPOSITION_TESTS)) +def test_views_pipeline_composition(database_client, collection, test): + """Test view pipeline composition.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/views/test_views_read_operations.py b/documentdb_tests/compatibility/tests/core/collections/views/test_views_read_operations.py new file mode 100644 index 00000000..a5e08559 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/views/test_views_read_operations.py @@ -0,0 +1,126 @@ +"""Tests for read operations on views.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import ViewCollection + +# Property [Find on View]: find queries on a view return documents filtered +# through the view's pipeline. +VIEWS_FIND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "find_with_filter", + target_collection=ViewCollection(options={"pipeline": [{"$match": {"x": {"$gte": 20}}}]}), + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 30}], + command=lambda ctx: {"find": ctx.collection, "sort": {"_id": 1}}, + expected=[{"_id": 2, "x": 20}, {"_id": 3, "x": 30}], + msg="find on view with $match pipeline should return only matching documents", + ), + CommandTestCase( + "find_with_query_filter", + target_collection=ViewCollection(options={"pipeline": [{"$match": {"x": {"$gte": 20}}}]}), + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 30}], + command=lambda ctx: { + "find": ctx.collection, + "filter": {"x": {"$lte": 20}}, + "sort": {"_id": 1}, + }, + expected=[{"_id": 2, "x": 20}], + msg="find filter should compose with view pipeline filter", + ), +] + +# Property [Aggregate on View]: aggregate pipelines on a view compose with +# the view's pipeline. +VIEWS_AGGREGATE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "aggregate_group", + target_collection=ViewCollection(options={"pipeline": [{"$match": {"dept": "eng"}}]}), + docs=[ + {"_id": 1, "dept": "eng", "val": 10}, + {"_id": 2, "dept": "sales", "val": 20}, + {"_id": 3, "dept": "eng", "val": 30}, + ], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$group": {"_id": None, "total": {"$sum": "$val"}}}], + "cursor": {}, + }, + expected=[{"_id": None, "total": 40}], + msg="aggregate on filtered view should only see view-filtered documents", + ), +] + +VIEWS_CURSOR_TESTS: list[CommandTestCase] = VIEWS_FIND_TESTS + VIEWS_AGGREGATE_TESTS + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(VIEWS_CURSOR_TESTS)) +def test_views_cursor_operations(database_client, collection, test): + """Test find and aggregate operations on views.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + ) + + +# Property [Distinct on View]: distinct returns unique values from the view's +# visible documents. +VIEWS_DISTINCT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "distinct_all", + target_collection=ViewCollection(), + docs=[ + {"_id": 1, "cat": "a"}, + {"_id": 2, "cat": "b"}, + {"_id": 3, "cat": "a"}, + ], + command=lambda ctx: {"distinct": ctx.collection, "key": "cat"}, + expected={"values": ["a", "b"], "ok": 1}, + ignore_order_in=["values"], + msg="distinct on view should return unique values", + ), + CommandTestCase( + "distinct_filtered_view", + target_collection=ViewCollection(options={"pipeline": [{"$match": {"x": {"$gte": 20}}}]}), + docs=[ + {"_id": 1, "x": 10, "cat": "a"}, + {"_id": 2, "x": 20, "cat": "b"}, + {"_id": 3, "x": 30, "cat": "b"}, + ], + command=lambda ctx: {"distinct": ctx.collection, "key": "cat"}, + expected={"values": ["b"], "ok": 1}, + ignore_order_in=["values"], + msg="distinct on filtered view should only see visible documents", + ), +] + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(VIEWS_DISTINCT_TESTS)) +def test_views_distinct(database_client, collection, test): + """Test distinct on views.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ignore_order_in=test.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/views/test_views_write_rejection.py b/documentdb_tests/compatibility/tests/core/collections/views/test_views_write_rejection.py new file mode 100644 index 00000000..6a03cda7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/views/test_views_write_rejection.py @@ -0,0 +1,94 @@ +"""Tests for write rejection on views.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import ViewCollection + +# Property [Write Rejection]: insert, update, delete, findAndModify, and +# mapReduce on a view are rejected with the command-not-supported-on-view error. +VIEWS_WRITE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "insert_rejected", + target_collection=ViewCollection(), + docs=[{"_id": 1, "x": 10}], + command=lambda ctx: { + "insert": ctx.collection, + "documents": [{"_id": 99, "x": 99}], + }, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="insert on a view should be rejected", + ), + CommandTestCase( + "update_rejected", + target_collection=ViewCollection(), + docs=[{"_id": 1, "x": 10}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [{"q": {"_id": 1}, "u": {"$set": {"x": 99}}}], + }, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="update on a view should be rejected", + ), + CommandTestCase( + "delete_rejected", + target_collection=ViewCollection(), + docs=[{"_id": 1, "x": 10}], + command=lambda ctx: { + "delete": ctx.collection, + "deletes": [{"q": {"_id": 1}, "limit": 1}], + }, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="delete on a view should be rejected", + ), + CommandTestCase( + "find_and_modify_rejected", + target_collection=ViewCollection(), + docs=[{"_id": 1, "x": 10}], + command=lambda ctx: { + "findAndModify": ctx.collection, + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + }, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="findAndModify on a view should be rejected", + ), + CommandTestCase( + "map_reduce_rejected", + target_collection=ViewCollection(), + docs=[{"_id": 1, "x": 10}], + command=lambda ctx: { + "mapReduce": ctx.collection, + "map": "function(){emit(this.x,1)}", + "reduce": "function(k,v){return Array.sum(v)}", + "out": {"inline": 1}, + }, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="mapReduce on a view should be rejected", + ), +] + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(VIEWS_WRITE_REJECTION_TESTS)) +def test_views_write_rejection(database_client, collection, test): + """Test write operations are rejected on views.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/framework/assertions.py b/documentdb_tests/framework/assertions.py index eb911629..399a73fa 100644 --- a/documentdb_tests/framework/assertions.py +++ b/documentdb_tests/framework/assertions.py @@ -73,6 +73,12 @@ def _sort_if_list(value): def _sort_fields(docs, fields): """Sort list values for the named fields in each document.""" + if isinstance(docs, dict): + doc = dict(docs) + for field in fields: + if field in doc: + doc[field] = _sort_if_list(doc[field]) + return doc sorted_docs = [] for doc in docs: doc = dict(doc) diff --git a/documentdb_tests/framework/target_collection.py b/documentdb_tests/framework/target_collection.py index f4d31f75..be27fe5c 100644 --- a/documentdb_tests/framework/target_collection.py +++ b/documentdb_tests/framework/target_collection.py @@ -22,16 +22,30 @@ class TargetCollection: def resolve(self, db: Database, collection: Collection) -> Collection: return collection + def writable(self, source: Collection, resolved: Collection) -> Collection: + """Return the collection where docs and indexes should be inserted.""" + return resolved + @dataclass(frozen=True) class ViewCollection(TargetCollection): - """A view on the fixture collection.""" + """A view on the fixture collection. + + Pass any extra keyword arguments accepted by the ``create`` command + (e.g. ``pipeline``, ``collation``) via the ``options`` dict. + """ + + options: dict[str, Any] = field(default_factory=dict) + suffix: str = "_view" def resolve(self, db: Database, collection: Collection) -> Collection: - view_name = f"{collection.name}_view" - db.command("create", view_name, viewOn=collection.name, pipeline=[]) + view_name = f"{collection.name}{self.suffix}" + db.command("create", view_name, viewOn=collection.name, **self.options) return db[view_name] + def writable(self, source: Collection, resolved: Collection) -> Collection: + return source + @dataclass(frozen=True) class SystemViewsCollection(ViewCollection):